diff --git a/packages/crypto/sign/dictionaries/crypto-sign.ru.i18n.json b/packages/crypto/sign/dictionaries/crypto-sign.ru.i18n.json index 2216b033b..554ef16ec 100644 --- a/packages/crypto/sign/dictionaries/crypto-sign.ru.i18n.json +++ b/packages/crypto/sign/dictionaries/crypto-sign.ru.i18n.json @@ -238,7 +238,7 @@ }, "txt": { "dstList": { - "certExpireWarning_0":"Истекает через {{count}} день", + "certExpireWarning_0": "Истекает через {{count}} день", "certExpireWarning_1": "Истекает через {{count}} дня", "certExpireWarning": "Истекает через {{count}} дней" }, diff --git a/packages/http/feature-toggles/types.ts b/packages/http/feature-toggles/types.ts index b442be94d..7a2f21eb1 100644 --- a/packages/http/feature-toggles/types.ts +++ b/packages/http/feature-toggles/types.ts @@ -48,6 +48,7 @@ enum FEATURE_TOGGLE_NAMES { DEPOSIT_FORM = 'depositFormIBMSB', DEPOSIT_ADVANCED_FILTER = 'depositFilterIBMSB', VED_CALLBACK_IMSB = 'vedCallbackIBMSB', + BLOCKING_NOTIFICATIONS = 'blockingNotificationsIBMSB', } export { FEATURE_TOGGLE_NAMES, type FeatureToggleData, type FeatureToggleItem, type FeatureToggleResponse }; diff --git a/packages/http/user-notifications/constants.ts b/packages/http/user-notifications/constants.ts index 775c0f072..a07f8df43 100644 --- a/packages/http/user-notifications/constants.ts +++ b/packages/http/user-notifications/constants.ts @@ -8,6 +8,16 @@ enum NOTIFICATION_TYPE { ALERT = 'ALERT', } +/** Статусы прочтения. */ +enum READ_STATUS { + /** Новое. */ + NEW = 'NEW', + /** Прочтено. */ + READ = 'READ', + /** В архиве. */ + ARCHIVE = 'ARCHIVE', +} + /** Статусы выполнения. */ enum EXECUTE_STATUS { /** Не выполнено. */ @@ -34,4 +44,4 @@ enum IMPORTANCE_VALUES { LOW = 'LOW', } -export { NOTIFICATION_TYPE, EXECUTE_STATUS, IMPORTANCE_VALUES }; +export { NOTIFICATION_TYPE, EXECUTE_STATUS, IMPORTANCE_VALUES, READ_STATUS }; diff --git a/packages/http/user-notifications/endpoints/constants.ts b/packages/http/user-notifications/endpoints/constants.ts index 6cbfaa55b..1c07ea27c 100644 --- a/packages/http/user-notifications/endpoints/constants.ts +++ b/packages/http/user-notifications/endpoints/constants.ts @@ -1,9 +1,14 @@ +const NOTIFICATION_URL_V2 = 'notification/v2'; const USER_NOTIFICATIONS_CATEGORY_COUNT = '/notification/user-notification/category-count' as const; -const USER_NOTIFICATIONS_GET_LIST = 'notification/v2/user-notification/get-page' as const; -const USER_NOTIFICATION_BY_ID = 'notification/v2/user-notification/:id' as const; -const MARK_NOTIFICATION_AS_READ = 'notification/v2/user-notification/mark-as-read' as const; -const USER_NOTIFICATONS_CREATE_ZIP = 'notification/v2/user-notification/create-zip'; -const USER_NOTIFICATONS_EXECUTE = 'notification/v2/user-notification/execute'; +const USER_NOTIFICATIONS_GET_LIST = `${NOTIFICATION_URL_V2}/user-notification/get-page` as const; +const USER_NOTIFICATION_BY_ID = `${NOTIFICATION_URL_V2}/user-notification/:id` as const; +const MARK_NOTIFICATION_AS_READ = `${NOTIFICATION_URL_V2}/user-notification/mark-as-read` as const; +const USER_NOTIFICATONS_CREATE_ZIP = `${NOTIFICATION_URL_V2}/user-notification/create-zip` as const; +const USER_NOTIFICATONS_EXECUTE = `${NOTIFICATION_URL_V2}/user-notification/execute` as const; +const USER_NOTIFICATONS_SIGN = `${NOTIFICATION_URL_V2}/user-notification/sign` as const; +const USER_NOTIFICATONS_GENERATE_SIGN_DATA = `${NOTIFICATION_URL_V2}/user-notification/generate-sign-data` as const; +const USER_NOTIFICATONS_GENERATE_SIGN_DATA_V2 = `${NOTIFICATION_URL_V2}/user-notification/v2.1/generate-sign-data` as const; +const USER_NOTIFICATONS_REQUIRED_READ = `${NOTIFICATION_URL_V2}/user-notification/required-read` as const; export { USER_NOTIFICATIONS_CATEGORY_COUNT, @@ -12,4 +17,8 @@ export { MARK_NOTIFICATION_AS_READ, USER_NOTIFICATONS_CREATE_ZIP, USER_NOTIFICATONS_EXECUTE, + USER_NOTIFICATONS_SIGN, + USER_NOTIFICATONS_GENERATE_SIGN_DATA, + USER_NOTIFICATONS_GENERATE_SIGN_DATA_V2, + USER_NOTIFICATONS_REQUIRED_READ, }; diff --git a/packages/shared/constants/localStorageKeys.ts b/packages/shared/constants/localStorageKeys.ts index 93bf3f643..43a7f4c18 100644 --- a/packages/shared/constants/localStorageKeys.ts +++ b/packages/shared/constants/localStorageKeys.ts @@ -1,3 +1,4 @@ const IS_WELCOME_MESSAGE_READ_KEY = 'isWelcomeMessageRead'; +const BLOCKING_NOTIFICATION_MODAL_KEY = 'blocking_notification_modal_is_open'; -export { IS_WELCOME_MESSAGE_READ_KEY }; +export { IS_WELCOME_MESSAGE_READ_KEY, BLOCKING_NOTIFICATION_MODAL_KEY }; diff --git a/packages/shared/context/AppContext/AppContext.ts b/packages/shared/context/AppContext/AppContext.ts index a2540f44d..aa17d0d37 100644 --- a/packages/shared/context/AppContext/AppContext.ts +++ b/packages/shared/context/AppContext/AppContext.ts @@ -1,21 +1,22 @@ import { createContext, useContext } from 'react'; -import type { AuthoritiesResponseDto, OrganizationDto, UserProfileDto } from '@msb/http'; +import type { AuthoritiesResponseDto, NotificationDto, OrganizationDto, UserProfileDto } from '@msb/http'; interface AppContextValue { userProfile: UserProfileDto | undefined; isUserProfileError: boolean; refetchUserProfile(): void; - userAuthorities: AuthoritiesResponseDto | undefined; userAuthoritiesError: Error | null | undefined; refetchUserAuthorities(): void; - organizations: OrganizationDto[]; isOrganizationsLoading: boolean; organizationsError: Error | null | undefined; refetchOrganizations(): void; selectedOrganization: OrganizationDto | null; changeSelectedOrganization(organization: OrganizationDto | null): void; + requiredNotifications: NotificationDto[]; + isRequiredNotificationsLoaded: boolean; + isRequiredNotificationsFailed: boolean; } const AppContext = createContext(null); diff --git a/services/msb-host/public/feature-toggles.json b/services/msb-host/public/feature-toggles.json index 7c727f26e..2aa9f4758 100644 --- a/services/msb-host/public/feature-toggles.json +++ b/services/msb-host/public/feature-toggles.json @@ -126,6 +126,10 @@ { "featureCode": "metricaSetIdIBMSB", "isEnabled": true + }, + { + "featureCode": "blockingNotificationsIBMSB", + "isEnabled": true } ] } diff --git a/services/msb-host/src/app/providers/AppProvider.tsx b/services/msb-host/src/app/providers/AppProvider.tsx index 435ecf273..77557d52a 100644 --- a/services/msb-host/src/app/providers/AppProvider.tsx +++ b/services/msb-host/src/app/providers/AppProvider.tsx @@ -3,6 +3,7 @@ import type { OrganizationDto } from '@msb/http'; import type { AppContextValue } from '@msb/shared'; import { AppContext, useScrollLock } from '@msb/shared'; import { useOrganizations, useUserAuthorities, useUserProfile } from '../model'; +import { useUserNotificationRequiredRead } from '@/entities/UserNotification'; import { SELECTED_ORGANIZATION_KEY } from '@/shared/constants'; import { LayoutSkeletons } from '@/shared/ui/components'; @@ -16,6 +17,12 @@ const AppProvider = ({ children }: { children: ReactNode }) => { isLoading: isOrganizationsLoading, } = useOrganizations(Boolean(userAuthoritiesData)); + const { + notifications, + isSuccess: isRequiredNotificationsLoaded, + error: requiredNotificationsError, + } = useUserNotificationRequiredRead(Boolean(userAuthoritiesData)); + const [selectedOrganization, setSelectedOrganization] = useState( sessionStorage.getItem(SELECTED_ORGANIZATION_KEY) ? JSON.parse(sessionStorage.getItem(SELECTED_ORGANIZATION_KEY)!) : null ); @@ -46,6 +53,9 @@ const AppProvider = ({ children }: { children: ReactNode }) => { refetchOrganizations, changeSelectedOrganization, selectedOrganization, + requiredNotifications: notifications, + isRequiredNotificationsLoaded, + isRequiredNotificationsFailed: !!requiredNotificationsError, }), [ userProfileData, @@ -58,7 +68,10 @@ const AppProvider = ({ children }: { children: ReactNode }) => { organizations, isOrganizationsLoading, refetchOrganizations, + notifications, selectedOrganization, + requiredNotificationsError, + isRequiredNotificationsLoaded, ] ); diff --git a/services/msb-host/src/dictionaries/crypto.ru.i18n.json b/services/msb-host/src/dictionaries/crypto.ru.i18n.json index 1c02bb748..36fede240 100644 --- a/services/msb-host/src/dictionaries/crypto.ru.i18n.json +++ b/services/msb-host/src/dictionaries/crypto.ru.i18n.json @@ -78,7 +78,7 @@ "noCloudCertView": { "txt": { "noCloudCertTitle": "Подпишите документ на персональном компьютере", - "noCloudCert": "Вы можете подписать документ только на компьютере при помощи локальной подписи.\n\nДля подписания документа на мобильном устройстве необходима облачная электронная подпись. Выпустить её можно также на компьютере.\n\nДокумент сохранён." + "noCloudCert": "Вы можете подписать документ только на компьютере при помощи локальной подписи.\n\nДля подписания документа на мобильном устройстве необходима облачная электронная подпись. Выпустить её можно также на компьютере." } }, "noSignDataView": { @@ -89,8 +89,8 @@ "noCertificatesView": { "txt": { "noCertTitle": "Не найдена подходящая электронная подпись", - "mobileNoCertContent": "Для подписания документа на мобильном устройстве выпустите <0>облачную электронную подпись.\n\nДокумент сохранён.", - "desktopNoCertContent": "Для подписания документа выпустите или зарегистрируйте <0>электронную подпись.\n\nДокумент сохранён." + "mobileNoCertContent": "Для подписания документа на мобильном устройстве выпустите <0>облачную электронную подпись.", + "desktopNoCertContent": "Для подписания документа выпустите или зарегистрируйте <0>электронную подпись." }, "btn": { "toProfile": "В раздел Электронные подписи" @@ -241,7 +241,7 @@ } }, "btn": { - "toList": "К списку заявок" + "toList": "Назад" }, "lbl": { "signToolAbb": "СЭП", diff --git a/services/msb-host/src/entities/UserNotification/api/queryKeys.ts b/services/msb-host/src/entities/UserNotification/api/queryKeys.ts index 55b28849b..f978099f7 100644 --- a/services/msb-host/src/entities/UserNotification/api/queryKeys.ts +++ b/services/msb-host/src/entities/UserNotification/api/queryKeys.ts @@ -1,3 +1,4 @@ export const QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT = 'user-notification-category-count'; export const QUERY_KEY_USER_NOTIFICATION_LIST = 'user-notification-list'; export const QUERY_KEY_USER_NOTIFICATION_BY_ID = 'user-notification-by-id'; +export const QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION = 'required-read-user-notifications'; diff --git a/services/msb-host/src/entities/UserNotification/api/requests.ts b/services/msb-host/src/entities/UserNotification/api/requests.ts index a82edabd2..0f0fbecf3 100644 --- a/services/msb-host/src/entities/UserNotification/api/requests.ts +++ b/services/msb-host/src/entities/UserNotification/api/requests.ts @@ -6,6 +6,7 @@ import { USER_NOTIFICATION_BY_ID, USER_NOTIFICATONS_CREATE_ZIP, USER_NOTIFICATONS_EXECUTE, + USER_NOTIFICATONS_REQUIRED_READ, } from '@msb/http'; import type { ServerResponsePaginationData } from '@msb/http/model'; @@ -17,6 +18,14 @@ const fetchUserNotificationsCategoryCount = async (): Promise return response.data; }; +const fetchRequiredReadUserNotifications = async (): Promise> => { + const response = await network.client.post>(USER_NOTIFICATONS_REQUIRED_READ, { + params: { paging: { offset: 0, limit: 100 } }, + }); + + return response.data; +}; + const createZip = async (attachmentIds: string[], fileName: string) => { const response = await network.client.post( USER_NOTIFICATONS_CREATE_ZIP, @@ -63,4 +72,11 @@ const fetchUserNotifications = async ({ return response.data; }; -export { fetchUserNotificationsCategoryCount, fetchUserNotifications, fetchUserNotificationById, createZip, executeNotification }; +export { + fetchUserNotificationsCategoryCount, + fetchUserNotifications, + fetchUserNotificationById, + createZip, + executeNotification, + fetchRequiredReadUserNotifications, +}; diff --git a/services/msb-host/src/entities/UserNotification/api/sign.ts b/services/msb-host/src/entities/UserNotification/api/sign.ts new file mode 100644 index 000000000..1bf916cb0 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/api/sign.ts @@ -0,0 +1,25 @@ +import type { ISignRequestData } from '@msb/crypto'; +import { network, USER_NOTIFICATONS_SIGN, USER_NOTIFICATONS_GENERATE_SIGN_DATA, USER_NOTIFICATONS_GENERATE_SIGN_DATA_V2 } from '@msb/http'; + +const signService = { + sign: async (data: ISignRequestData) => { + const response = await network.client.post(USER_NOTIFICATONS_SIGN, data); + + return response.data.data; + }, + send: async () => Promise.resolve(true), + getSignData: async (id: string) => { + const response = await network.client.post(USER_NOTIFICATONS_GENERATE_SIGN_DATA, { + data: { data: id }, + }); + + return response.data.data; + }, + getSignDataV2: async (id: string) => { + const response = await network.client.post(USER_NOTIFICATONS_GENERATE_SIGN_DATA_V2, { data: [id] }); + + return response.data.data; + }, +}; + +export { signService }; diff --git a/services/msb-host/src/entities/UserNotification/constants/index.ts b/services/msb-host/src/entities/UserNotification/constants/index.ts index 25afc4d80..474ccee0f 100644 --- a/services/msb-host/src/entities/UserNotification/constants/index.ts +++ b/services/msb-host/src/entities/UserNotification/constants/index.ts @@ -1,3 +1,5 @@ +import { LOCALIZATION } from './localization'; + /* eslint-disable sonarjs/no-duplicate-string */ enum NOTIFICATION_TYPE { STATEMENT = 'STATEMENT', @@ -19,4 +21,4 @@ const NOTIFICATION_TYPE_LABELS = { [NOTIFICATION_TYPE.LETTERS]: 'Письма', }; -export { NOTIFICATION_TYPE_LABELS, NOTIFICATION_TYPE }; +export { NOTIFICATION_TYPE_LABELS, NOTIFICATION_TYPE, LOCALIZATION }; diff --git a/services/msb-host/src/entities/UserNotification/constants/localization.ts b/services/msb-host/src/entities/UserNotification/constants/localization.ts index 8d71bca35..b856759e9 100644 --- a/services/msb-host/src/entities/UserNotification/constants/localization.ts +++ b/services/msb-host/src/entities/UserNotification/constants/localization.ts @@ -1,7 +1,28 @@ const LOCALIZATION = { TITLE: 'Новые уведомления', + REQUIRED_READING: 'Обязательное к прочтению', GO_TO_NOTIFICATION_PAGE: ' Перейти в раздел', DATA_IS_FAILED: 'Данные не загрузились', + ATTACHMENTS: 'Вложения', + DOWNLOAD_ALL: 'Скачать все', + ERROR_LOAD_ARCHIVE: 'Ошибка загрузки архива', + ERROR_EXECUTE: 'Ошибка исполнения. Попробуйте еще раз', + ERROR_LOAD_FILE: 'Ошибка загрузки файла', + MARK_AS_READ: 'Отметить прочитанным', + SIGNED: 'Подписано', + READ: 'Прочитано', + TAKEN_INTO_ACCOUNT: 'Принято к сведению', + IMPORTANT: 'Важное', + REQUIRING_EXECUTION: 'Требует исполнения', + EXECUTED: 'Исполнено', + LETTERS: 'Письма', + EXECUTE: 'Исполнить', + NOTIFICATION_IS_SIGNED: 'Прочтение уведомления успешно подписано', + REQUIRED_NOTIFICATIONS: 'Обязательные уведомления', + REQUIRED_NOTIFICATION: 'Обязательное уведомление', + EXPAND_NOTIFICATION: 'Развернуть уведомление', + COLLAPSE_NOTIFICATION: 'Свернуть уведомление', + ALL_NOTIFICATIONS_IS_TAKEN_INTO_ACTION: 'Все уведомления приняты к сведению', }; export { LOCALIZATION }; diff --git a/services/msb-host/src/entities/UserNotification/index.ts b/services/msb-host/src/entities/UserNotification/index.ts index 4f58d801b..e32b356a6 100644 --- a/services/msb-host/src/entities/UserNotification/index.ts +++ b/services/msb-host/src/entities/UserNotification/index.ts @@ -1,3 +1,4 @@ export * from './model'; export * from './ui'; export * from './constants'; +export * from './api'; diff --git a/services/msb-host/src/pages/UserNotificationsPage/lib/attachment-zip-name.ts b/services/msb-host/src/entities/UserNotification/lib/attachment-zip-name.ts similarity index 100% rename from services/msb-host/src/pages/UserNotificationsPage/lib/attachment-zip-name.ts rename to services/msb-host/src/entities/UserNotification/lib/attachment-zip-name.ts diff --git a/services/msb-host/src/entities/UserNotification/lib/index.ts b/services/msb-host/src/entities/UserNotification/lib/index.ts new file mode 100644 index 000000000..38b31220a --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/lib/index.ts @@ -0,0 +1 @@ +export { attachmentsZipName } from './attachment-zip-name'; diff --git a/services/msb-host/src/entities/UserNotification/model/index.ts b/services/msb-host/src/entities/UserNotification/model/index.ts index a21c351d9..5a9c7dd41 100644 --- a/services/msb-host/src/entities/UserNotification/model/index.ts +++ b/services/msb-host/src/entities/UserNotification/model/index.ts @@ -1,2 +1,4 @@ export * from './useUserNotifications'; export * from './useUserNotificationsLazyLoad'; +export * from './useDownloadFiles'; +export * from './openSignModal'; diff --git a/services/msb-host/src/entities/UserNotification/model/openSignModal.ts b/services/msb-host/src/entities/UserNotification/model/openSignModal.ts new file mode 100644 index 000000000..72224f667 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/model/openSignModal.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { ISignRequestData } from '@msb/crypto'; +import { openSignModal } from '@msb/crypto'; +import { type NotificationDto } from '@msb/http'; + +import { handleReachGoal, YM_GOALS } from '@msb/shared'; +import { signService } from '../api/sign'; + +function openNotificationSignModal( + document: NotificationDto, + readNotification: (notification: NotificationDto) => void, + setSignModalOpened?: (value: boolean) => void +) { + return openSignModal({ + type: 'single', + modalParams: { + signToolView: { continueBtnText: 'Подписать' }, + noCertificatesModalProps: { + toListText: 'Назад', + }, + }, + onClose: () => { + setSignModalOpened?.(false); + }, + signData: { + documentsRegistry: [ + { + clientId: document.bankClient.clientId, + documents: [{ id: document.id }], + }, + ], + }, + api: { + sign: async ([doc]: ISignRequestData[]) => { + try { + const { id: _id, ...docWithoutId } = doc; + const data = await signService.sign(docWithoutId); + + if (data) { + readNotification(document); + handleReachGoal(YM_GOALS.VIEW_ELEMENT, { [YM_GOALS.VIEW_ELEMENT]: { element_name: 'notification_signed' } }); + } + + return [data]; + } catch (error) { + console.error('Ошибка подписания документа: ', error); + + return Promise.reject(); + } + }, + getSignData: async ([id]: string[]) => { + try { + const resp = await signService.getSignDataV2(id); + + return resp; + } catch (error) { + console.error('Ошибка генерации данных подписания: ', error); + + return Promise.reject(); + } + }, + }, + }); +} + +export { openNotificationSignModal }; diff --git a/services/msb-host/src/entities/UserNotification/model/useDownloadFiles.ts b/services/msb-host/src/entities/UserNotification/model/useDownloadFiles.ts new file mode 100644 index 000000000..6c87e4656 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/model/useDownloadFiles.ts @@ -0,0 +1,38 @@ +import { useSnackbar } from '@fractal-ui/overlays'; +import { download as downloadFile, useMutation, type NotificationDto } from '@msb/http'; +import { showFile } from '@msb/shared'; +import { createZip } from '../api'; +import { LOCALIZATION } from '../constants/localization'; +import { attachmentsZipName } from '../lib'; + +const useDownloadFiles = (notification?: NotificationDto, callback?: () => void) => { + const { showSnackbarMessage } = useSnackbar(); + + const { mutateAsync: downloadAll, isLoading: downloadAllLoading } = useMutation({ + mutationFn: () => { + const zipName = attachmentsZipName({ + organizationName: notification?.bankClient?.shortName, + notificationId: notification?.id ?? '', + notificationCreationDate: notification?.createdAt ?? '', + }); + + return createZip(notification?.attachments.map(el => el.attachmentId) ?? [], zipName).then(response => + downloadFile(response.data.attachmentId, response.data.attachmentToken) + ); + }, + onSuccess: (response: any) => { + showFile(response.data, response.fileName, response.type); + callback?.(); + }, + onError: () => { + showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_ARCHIVE }); + }, + }); + + return { + downloadAll, + downloadAllLoading, + }; +}; + +export { useDownloadFiles }; diff --git a/services/msb-host/src/entities/UserNotification/model/useUserNotifications.ts b/services/msb-host/src/entities/UserNotification/model/useUserNotifications.ts index 45c4d5a32..6ea2a60b6 100644 --- a/services/msb-host/src/entities/UserNotification/model/useUserNotifications.ts +++ b/services/msb-host/src/entities/UserNotification/model/useUserNotifications.ts @@ -1,15 +1,18 @@ /* eslint-disable @typescript-eslint/no-shadow */ import { useRef } from 'react'; -import { useQuery } from '@msb/http'; -import type { CategoryCountDto, FiltersDto, NotificationDto } from '@msb/http'; -import { handleReachGoal, YM_GOALS } from '@msb/shared'; +import { type CategoryCountDto, FEATURE_TOGGLE_NAMES, type FiltersDto, type NotificationDto, useMutation, useQuery } from '@msb/http'; +import { BLOCKING_NOTIFICATION_MODAL_KEY, handleReachGoal, useFeatureToggles, YM_GOALS } from '@msb/shared'; +import { useLocation } from 'react-router-dom'; import { fetchUserNotificationsCategoryCount, fetchUserNotifications, + fetchRequiredReadUserNotifications, fetchUserNotificationById, + markNotificationAsRead, QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT, QUERY_KEY_USER_NOTIFICATION_LIST, QUERY_KEY_USER_NOTIFICATION_BY_ID, + QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION, } from '../api'; import { LOCALIZATION } from '../constants/localization'; @@ -70,12 +73,10 @@ const useUserNotifications = ({ }; const useUserNotificationByid = (id: string) => { - const { data, isLoading, isFetching, isSuccess, refetch, error } = useQuery({ + const { data, isLoading, isFetching, isSuccess, refetch, error } = useQuery({ queryKey: [QUERY_KEY_USER_NOTIFICATION_BY_ID, id], queryFn: () => fetchUserNotificationById(id), enabled: !!id, - staleTime: 0, - cacheTime: 0, retry: 3, retryDelay: 1000, }); @@ -90,4 +91,38 @@ const useUserNotificationByid = (id: string) => { }; }; -export { useUserNotificationCategoryCount, useUserNotifications, useUserNotificationByid }; +/** Пометить сообщение как прочитанное. */ +const useMarkAsRead = () => + useMutation({ + mutationFn: (id: string) => markNotificationAsRead(id), + }); + +const useUserNotificationRequiredRead = (enabled: boolean) => { + const { isEnabled } = useFeatureToggles(FEATURE_TOGGLE_NAMES.BLOCKING_NOTIFICATIONS); + const location = useLocation(); + const { data, isLoading, isFetching, isSuccess, refetch, error } = useQuery<{ page: NotificationDto[]; size: number }, Error | undefined>( + { + queryKey: [QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION, location.pathname], + queryFn: fetchRequiredReadUserNotifications, + staleTime: 0, + cacheTime: 0, + enabled, + onSettled: data => { + if (data?.page && data?.page.length > 0 && isEnabled) { + localStorage.setItem(BLOCKING_NOTIFICATION_MODAL_KEY, 'true'); + } + }, + } + ); + + return { + notifications: data?.page || [], + isLoading, + isFetching, + isSuccess, + error, + refetch, + }; +}; + +export { useUserNotificationCategoryCount, useUserNotificationByid, useUserNotificationRequiredRead, useMarkAsRead, useUserNotifications }; diff --git a/services/msb-host/src/entities/UserNotification/model/useUserNotificationsLazyLoad.ts b/services/msb-host/src/entities/UserNotification/model/useUserNotificationsLazyLoad.ts index 447676e1b..017ac1357 100644 --- a/services/msb-host/src/entities/UserNotification/model/useUserNotificationsLazyLoad.ts +++ b/services/msb-host/src/entities/UserNotification/model/useUserNotificationsLazyLoad.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import { useState, useCallback, useEffect, useMemo } from 'react'; import { debounce } from '@fractal-ui/core'; import type { NotificationCategory } from '@msb/http'; @@ -115,7 +116,7 @@ const useNotificationsLazyLoad = ({ activeTab, filters }: { activeTab: T; fil }) ); - queryClient.invalidateQueries({ queryKey: [QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT] }); + await queryClient.invalidateQueries({ queryKey: [QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT] }); } } }, diff --git a/services/msb-host/src/entities/UserNotification/ui/Attachment/Attachement.tsx b/services/msb-host/src/entities/UserNotification/ui/Attachment/Attachement.tsx new file mode 100644 index 000000000..060f189e8 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/Attachment/Attachement.tsx @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import { useState, useMemo } from 'react'; +import { ButtonIcon, Skeleton } from '@fractal-ui/core'; +import type { IconComponent } from '@fractal-ui/library'; +import { FormatJpgIcon, FormatPngIcon, FormatPdfIcon, DocumentBlankIcon, EyeOpenIcon, DownloadIcon } from '@fractal-ui/library'; +import { useSnackbar } from '@fractal-ui/overlays'; +import { Text } from '@fractal-ui/styling'; +import { type NotificationAttachmentDto, download as downloadFile, useMutation, useQuery, useQueryClient } from '@msb/http'; +import { Flex, formatSize, showFile, previewFile, useDeviceType } from '@msb/shared'; + +import { LOCALIZATION } from '../../constants/localization'; + +import * as S from './styles'; + +import { QUERY_KEY_USER_NOTIFICATION_BY_ID } from '@/entities/UserNotification/api'; + +const MAXIMUM_PREVIEW_SIZE = 104_857_600; // 100MB + +enum ATTACHMENT_FILE_TYPE { + PDF = 'application/pdf', + PNG = 'image/png', + JPEG = 'image/jpeg', +} + +/** Получает иконку файла по его типу. */ +const getFileIcon = (contentType?: string): IconComponent => { + switch (contentType) { + case ATTACHMENT_FILE_TYPE.JPEG: + return FormatJpgIcon; + case ATTACHMENT_FILE_TYPE.PNG: + return FormatPngIcon; + case ATTACHMENT_FILE_TYPE.PDF: + return FormatPdfIcon; + default: + return DocumentBlankIcon; + } +}; + +const responseToObjectURL = (response: any) => { + if (!response.data) { + return; + } + + const blob = new Blob([response.data]); + + return URL.createObjectURL(blob); +}; + +/** Компонент для показа изображения из file-storage. */ +const AttachmentPicture = ({ attachmentId, attachmentToken }: Pick) => { + const { data, error, isLoading } = useQuery({ + queryKey: ['attachment-image', attachmentId], + queryFn: () => downloadFile(attachmentId, attachmentToken), + enabled: Boolean(attachmentId && attachmentToken), + select: responseToObjectURL, + retry: false, + }); + + const content = useMemo(() => { + if (isLoading) { + return ; + } + + if (error) { + return null; + } + + return ; + }, [attachmentId, data, error, isLoading]); + + return content; +}; + +const Attachment = ({ attachment, notificationId }: { attachment: NotificationAttachmentDto; notificationId: string }) => { + const FileIcon = useMemo(() => getFileIcon(attachment.contentType), [attachment]); + const { isTouchDevice } = useDeviceType(); + const { showSnackbarMessage } = useSnackbar(); + const [isHovered, setIsHovered] = useState(false); + const onMouseEnter = () => setIsHovered(true); + const onMouseLeave = () => setIsHovered(false); + + const queryClient = useQueryClient(); + + const invalidateMessage = () => { + queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, notificationId]); + }; + + const isPreviewAvailable = + !isTouchDevice && + [ATTACHMENT_FILE_TYPE.PDF, ATTACHMENT_FILE_TYPE.PNG, ATTACHMENT_FILE_TYPE.JPEG].includes( + attachment.contentType as ATTACHMENT_FILE_TYPE + ) && + attachment.dataSize <= MAXIMUM_PREVIEW_SIZE; + + const { mutateAsync: preview, isLoading: isPreviewLoading } = useMutation({ + mutationFn: () => downloadFile(attachment.attachmentId, attachment.attachmentToken), + onSuccess: response => { + previewFile(response.data, response.type); + invalidateMessage(); + }, + onError: () => { + showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_FILE }); + }, + }); + + const { mutateAsync: download, isLoading: isDownloadLoading } = useMutation({ + mutationFn: () => downloadFile(attachment.attachmentId, attachment.attachmentToken), + onSuccess: response => { + showFile(response.data, response.fileName, response.type); + invalidateMessage(); + }, + onError: () => { + showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_FILE }); + }, + }); + + return ( + + + + + + + {attachment.fileName} + {formatSize(attachment.dataSize)} + + + {isPreviewAvailable && ( + preview()} + /> + )} + download()} + /> + + + + ); +}; + +export { Attachment, AttachmentPicture }; diff --git a/services/msb-host/src/entities/UserNotification/ui/Attachment/index.ts b/services/msb-host/src/entities/UserNotification/ui/Attachment/index.ts new file mode 100644 index 000000000..0b6492477 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/Attachment/index.ts @@ -0,0 +1 @@ +export * from './Attachement'; diff --git a/services/msb-host/src/entities/UserNotification/ui/Attachment/styles.ts b/services/msb-host/src/entities/UserNotification/ui/Attachment/styles.ts new file mode 100644 index 000000000..00ef4c85b --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/Attachment/styles.ts @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; +import { Text } from '@fractal-ui/styling'; +import { Flex } from '@msb/shared'; +import { layout, space } from 'styled-system'; + +const AttachmentContainer = styled.div` + padding: 16px; + background-color: #f9f9fb; + border-radius: 12px; +`; + +const FileName = styled(Text.P2)` + max-width: 275px; + margin-bottom: 4px; +`; + +const Image = styled.img( + { + display: 'block', + height: '160px', + width: '100%', + objectFit: 'cover', + borderRadius: '8px', + }, + layout, + space +); + +const AttachmentPictogram = styled(Flex)` + width: 40px; + height: 40px; + border-radius: 10px; + padding: 5px; + background: linear-gradient(225deg, #f0f6ff 0%, #d4e1fa 100%); + svg { + width: 24px; + height: 24px; + color: #1e222e8f; + } +`; + +export { Image, AttachmentContainer, FileName, AttachmentPictogram }; diff --git a/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotification.tsx b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotification.tsx new file mode 100644 index 000000000..5270bdf79 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotification.tsx @@ -0,0 +1,238 @@ +import { useMemo, useCallback } from 'react'; +import type { ButtonProps } from '@fractal-ui/core'; +import { Button, ButtonLink, Spinner } from '@fractal-ui/core'; +import { Badge } from '@fractal-ui/extended'; +import { DoneIcon } from '@fractal-ui/library'; +import { Text, Title } from '@fractal-ui/styling'; +import type { NotificationDto, UseMutateFunction } from '@msb/http'; +import { useQueryClient, READ_STATUS } from '@msb/http'; +import { DataIsFailedError, Flex, handleReachGoal, MEDIA, useMediaQuery, YaMetrikaReachGoal, YM_GOALS } from '@msb/shared'; +import dayjs from 'dayjs'; +import { useLocation } from 'react-router-dom'; +import { + Attachment, + AttachmentPicture, + useDownloadFiles, + useUserNotificationByid, + openNotificationSignModal, + LOCALIZATION, + QUERY_KEY_USER_NOTIFICATION_BY_ID, + QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT, + QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION, +} from '@/entities/UserNotification'; +import { Loader } from '@/shared/ui/components'; + +interface Props { + id: string; + setSignModalOpened(value: boolean): void; + markAsRead: UseMutateFunction; + markAsReadLoading: boolean; +} + +const BlockingNotification = ({ id, setSignModalOpened, markAsRead, markAsReadLoading }: Props) => { + const isMobile = useMediaQuery(MEDIA.mobile); + const location = useLocation(); + + const queryClient = useQueryClient(); + + const { notification, isLoading, isFetching, error, refetch } = useUserNotificationByid(id); + + const { downloadAll, downloadAllLoading } = useDownloadFiles(notification, () => { + queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); + }); + const isSigned = notification?.signatures && notification.signatures.length > 0; + + const isSignable = notification?.isRequiredSing && notification.bankClient; + + const isReadButtonVisible = notification?.isRequiredRead && notification.status === READ_STATUS.NEW; + + const isTakenIntoAccount = notification?.isRequiredRead && notification.status === READ_STATUS.READ; + + const updateNotificationStatus = useCallback(() => { + if (notification) { + markAsRead(notification.id, { + onSuccess: async () => { + await queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); + await Promise.resolve( + queryClient.setQueryData( + [QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION, location.pathname], + (oldData?: { page: NotificationDto[]; size: number }) => { + if (!oldData || !oldData.page) { + return oldData; + } + + const updatedPage = oldData.page.map(item => (notification.id === item.id ? { ...item, status: READ_STATUS.READ } : item)); + + return { ...oldData, page: updatedPage }; + } + ) + ); + + await queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT]); + }, + }); + } + }, [notification, markAsRead, queryClient, id, location.pathname]); + + const handleMarkAsRead = useCallback(() => { + if (!notification) { + return; + } + + handleReachGoal(YM_GOALS.BUTTON_CLICK, { [YM_GOALS.BUTTON_CLICK]: { element_name: 'mark_as_read' } }); + + if (isSignable) { + setSignModalOpened(true); + openNotificationSignModal(notification, updateNotificationStatus, setSignModalOpened); + } else { + updateNotificationStatus(); + } + }, [isSignable, notification, setSignModalOpened, updateNotificationStatus]); + + const content = useMemo(() => { + if (isLoading && id) { + return ; + } + + if (error) { + return ( + <> + + { + refetch(); + }} + /> + + ); + } + + return ( + notification && ( + <> + + {isTakenIntoAccount ? LOCALIZATION.TAKEN_INTO_ACCOUNT : LOCALIZATION.REQUIRED_READING} + + + {notification.bankClient?.shortName && ( + + {notification.bankClient.shortName} + + )} + {notification.createdAt && ( + + {dayjs(notification.createdAt).format('DD.MM.YYYY, HH:mm')} + + )} + {notification.type.notificationHeader && {notification.type.notificationHeader}} + {notification.picture && ( + + )} + {notification.body && ( + + )} + + {notification.attachments.length > 0 && ( +
+ + + {LOCALIZATION.ATTACHMENTS} + + {notification.attachments.length} + + + {!isMobile && ( + { + downloadAll(); + }} + > + {downloadAllLoading ? : LOCALIZATION.DOWNLOAD_ALL} + + )} + + + + {notification.attachments.map(attachment => ( + + ))} + +
+ )} + + ) + ); + }, [isLoading, id, error, notification, isTakenIntoAccount, isMobile, downloadAllLoading, refetch, downloadAll]); + + const isFooterShown = useMemo( + () => + [notification?.isRequiredRead && notification.status === READ_STATUS.READ && !isSigned, isSigned, isReadButtonVisible].some( + option => option + ), + [notification, isSigned, isReadButtonVisible] + ); + + const actions: ButtonProps[] = useMemo(() => { + const secondaryButtons: ButtonProps[] = []; + const primaryButtons: ButtonProps[] = []; + + if (!isFooterShown || !notification) { + return []; + } + + if (isReadButtonVisible) { + primaryButtons.push({ + title: LOCALIZATION.MARK_AS_READ, + dataAction: 'read', + onClick: handleMarkAsRead, + disabled: isFetching, + isLoading: markAsReadLoading, + width: 'auto', + variant: 'primary', + }); + } + + if (isSigned) { + secondaryButtons.push({ + title: LOCALIZATION.SIGNED, + dataAction: 'signed', + icon: DoneIcon, + disabled: true, + width: 'auto', + variant: 'secondary', + }); + } else if (notification.isRequiredRead && notification.status === READ_STATUS.READ && !isSigned) { + secondaryButtons.push({ + title: LOCALIZATION.READ, + dataAction: 'read', + icon: DoneIcon, + disabled: true, + width: 'auto', + variant: 'secondary', + }); + } + + return [...secondaryButtons, ...primaryButtons]; + }, [isFooterShown, notification, isReadButtonVisible, isSigned, handleMarkAsRead, markAsReadLoading, isFetching]); + + return ( +
+ {content} + + {actions.map(action => ( + + ))} + +
+ ); +}; + +export { BlockingNotification }; diff --git a/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotificationModal.tsx b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotificationModal.tsx new file mode 100644 index 000000000..a4db7dd1e --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/BlockingNotificationModal.tsx @@ -0,0 +1,198 @@ +import { type ReactElement, useRef, useState, useCallback, useEffect } from 'react'; +import { ButtonIcon } from '@fractal-ui/core'; +import { AttentionIcon, UpIcon, OkIcon } from '@fractal-ui/library'; +import { Modal, useSnackbar } from '@fractal-ui/overlays'; +import { Text } from '@fractal-ui/styling'; +import type { NotificationDto } from '@msb/http'; +import { READ_STATUS, useQueryClient } from '@msb/http'; +import { + Flex, + MEDIA, + PATHS_PROFILE, + PATHS_NOTIFICATIONS, + removeCmpFromPortal, + SmoothAutoHeight, + useDeviceType, + useMediaQuery, + BLOCKING_NOTIFICATION_MODAL_KEY, + handleReachGoal, + YM_GOALS, +} from '@msb/shared'; +import dayjs from 'dayjs'; +import { useLocation } from 'react-router-dom'; +import { useMarkAsRead } from '../../model'; +import { BlockingNotification } from './BlockingNotification'; +import * as S from './styles'; +import { QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION } from '@/entities/UserNotification/api'; +import { LOCALIZATION } from '@/entities/UserNotification/constants'; + +const DELAY_MS = 1000; +const excludePaths = [PATHS_NOTIFICATIONS.NOTIFICATIONS, PATHS_PROFILE.ORGANIZATIONS]; + +interface Props { + notifications: NotificationDto[]; +} + +const BlockingNotificationModal = ({ notifications }: Props): ReactElement => { + const collapsedRef = useRef(null); + const [expandedId, setExpandedId] = useState(null); + const [isSignModalOpened, setIsSignModalOpened] = useState(false); + + const location = useLocation(); + const { showSnackbarMessage } = useSnackbar(); + const queryClient = useQueryClient(); + const isMobile = useMediaQuery(MEDIA.mobile); + const { isTouchDevice } = useDeviceType(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [shouldShowModal, setShouldShowModal] = useState(false); + + const { mutate: markAsRead, isLoading: markAsReadLoading } = useMarkAsRead(); + + useEffect(() => { + const shouldShow = notifications.length > 0 && !excludePaths.some(path => location.pathname.includes(path)); + + setShouldShowModal(shouldShow); + + if (shouldShow && !isModalOpen) { + const timer = setTimeout(() => { + setIsModalOpen(true); + handleReachGoal(YM_GOALS.VIEW_ELEMENT, { [YM_GOALS.VIEW_ELEMENT]: { element_name: 'blocking_modal' } }); + }, DELAY_MS); + + return () => clearTimeout(timer); + } + + if (notifications.length > 0 && notifications.every(notification => notification.status === READ_STATUS.READ)) { + queryClient.invalidateQueries([QUERY_KEY_REQUIRED_READ_USER_NOTIFICATION]).then(() => { + setIsModalOpen(false); + showSnackbarMessage({ type: 'success', icon: OkIcon, message: LOCALIZATION.ALL_NOTIFICATIONS_IS_TAKEN_INTO_ACTION }); + localStorage.removeItem(BLOCKING_NOTIFICATION_MODAL_KEY); + }); + } + }, [location.pathname, notifications, isModalOpen, queryClient, showSnackbarMessage]); + + useEffect(() => { + if (location.pathname.includes(PATHS_PROFILE.ORGANIZATIONS) && isSignModalOpened) { + removeCmpFromPortal('SignModal'); + setIsSignModalOpened(false); + } + }, [location.pathname, isSignModalOpened]); + + useEffect(() => { + if (notifications.length === 1) { + setExpandedId(notifications[0].id); + } + }, [notifications]); + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false); + setShouldShowModal(false); + }, []); + + const handleExpandCard = useCallback((id: string) => { + handleReachGoal(YM_GOALS.BUTTON_CLICK, { [YM_GOALS.BUTTON_CLICK]: { element_name: 'opening_a_notification' } }); + setExpandedId(prevExpandedId => (prevExpandedId === id ? null : id)); + }, []); + + const setSignModalOpened = (value: boolean) => { + setIsSignModalOpened(value); + }; + + return ( + 1 ? LOCALIZATION.REQUIRED_NOTIFICATIONS : LOCALIZATION.REQUIRED_NOTIFICATION} + isNeedScroll={false} + isOpen={shouldShowModal && isModalOpen && !isSignModalOpened} + minHeight={638} + width={768} + onClose={handleCloseModal} + > + { + if (node) { + const parent = node.parentElement; + + if (parent) { + parent.style.marginBottom = '0px'; + } + } + }} + column + gap="16px" + marginTop="20px" + > + {notifications.map(notification => { + const isExpanded = notifications.length === 1 ? true : expandedId === notification.id; + + return ( + + + + {notification.status === READ_STATUS.READ ? ( + + ) : ( + + )} + + + { + if (isTouchDevice) { + handleExpandCard(notification.id); + } + }} + > + + {notification.type.notificationHeader} + + + {notifications.length > 1 && !isTouchDevice && ( + + handleExpandCard(notification.id)} + /> + + )} + + + {dayjs(notification.createdAt).format('DD.MM.YYYY, HH:mm')} + + + + + + + + + + + ); + })} + + + ); +}; + +export { BlockingNotificationModal }; diff --git a/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/index.ts b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/index.ts new file mode 100644 index 000000000..32bbdcb4f --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/index.ts @@ -0,0 +1 @@ +export { BlockingNotificationModal } from './BlockingNotificationModal'; diff --git a/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/styles.ts b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/styles.ts new file mode 100644 index 000000000..7b347dca2 --- /dev/null +++ b/services/msb-host/src/entities/UserNotification/ui/BlockingNotification/styles.ts @@ -0,0 +1,38 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { DEFAULT_ANIMATION_DURATION_MS, Flex } from '@msb/shared'; + +const CollapsedArea = styled.div` + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 138px; + margin-top: 16px; + padding: 20px; + border-radius: 12px; + + ${({ theme }) => css` + background-color: ${theme.colors.bg.primary}; + `} +`; + +const Pictogram = styled(Flex)` + width: 40px; + height: 40px; + border-radius: 10px; + padding: 8px; +`; + +const Card = styled(Flex)({ padding: '20px', borderRadius: '12px', position: 'relative' }); + +const ExpandButtonWrapper = styled.div<{ $isCollapsed: boolean }>(({ $isCollapsed }) => ({ + position: 'absolute', + right: 20, + svg: { + transition: `transform ${DEFAULT_ANIMATION_DURATION_MS}ms ease`, + transform: $isCollapsed ? 'rotate(180deg)' : 'rotate(0)', + }, +})); + +export { ExpandButtonWrapper, CollapsedArea, Card, Pictogram }; diff --git a/services/msb-host/src/entities/UserNotification/ui/index.ts b/services/msb-host/src/entities/UserNotification/ui/index.ts index 1ad91c673..1624edbd7 100644 --- a/services/msb-host/src/entities/UserNotification/ui/index.ts +++ b/services/msb-host/src/entities/UserNotification/ui/index.ts @@ -1 +1,3 @@ export { UserNotifications } from './UserNotifications'; +export * from './Attachment'; +export * from './BlockingNotification'; diff --git a/services/msb-host/src/pages/Layout/ui/Layout.tsx b/services/msb-host/src/pages/Layout/ui/Layout.tsx index 01980aad8..5cea5be8d 100644 --- a/services/msb-host/src/pages/Layout/ui/Layout.tsx +++ b/services/msb-host/src/pages/Layout/ui/Layout.tsx @@ -1,28 +1,39 @@ -import { type ReactElement } from 'react'; -import type { RemoteRouteConfigDto } from '@msb/http'; -// import { IS_WELCOME_MESSAGE_READ_KEY, useModal } from '@msb/shared'; +import { useEffect, type ReactElement } from 'react'; +import { FEATURE_TOGGLE_NAMES, type RemoteRouteConfigDto } from '@msb/http'; +import { IS_WELCOME_MESSAGE_READ_KEY, BLOCKING_NOTIFICATION_MODAL_KEY, useModal, useAppContext, useFeatureToggles } from '@msb/shared'; import * as S from './Layout.styles'; import LayoutContent from './LayoutContent'; import { StyledThemeProvider } from '@/app/providers'; -// import { WelcomeMessageModal } from '@/shared/ui/components'; +import { BlockingNotificationModal } from '@/entities/UserNotification/ui/BlockingNotification'; +import { WelcomeMessageModal } from '@/shared/ui/components'; interface Props { modules: RemoteRouteConfigDto[] | undefined; } -const Layout = ({ modules }: Props): ReactElement => ( - // const { showModal, isReady } = useModal(WelcomeMessageModal); +const Layout = ({ modules }: Props): ReactElement => { + const { requiredNotifications, isRequiredNotificationsLoaded, isRequiredNotificationsFailed } = useAppContext(); + const { isEnabled } = useFeatureToggles(FEATURE_TOGGLE_NAMES.BLOCKING_NOTIFICATIONS); + const { showModal, isReady } = useModal(WelcomeMessageModal); - // useEffect(() => { - // if (isReady && !localStorage.getItem(IS_WELCOME_MESSAGE_READ_KEY)) { - // showModal({ onClose: () => localStorage.setItem(IS_WELCOME_MESSAGE_READ_KEY, 'true') }); - // } - // }, [isReady]); + useEffect(() => { + if ( + isReady && + !localStorage.getItem(BLOCKING_NOTIFICATION_MODAL_KEY) && + !localStorage.getItem(IS_WELCOME_MESSAGE_READ_KEY) && + (isRequiredNotificationsLoaded || isRequiredNotificationsFailed) + ) { + showModal({ onClose: () => localStorage.setItem(IS_WELCOME_MESSAGE_READ_KEY, 'true') }); + } + }, [isRequiredNotificationsLoaded, isReady, isRequiredNotificationsFailed, showModal]); - - - - -); + return ( + + + + {isEnabled && } + + ); +}; export default Layout; diff --git a/services/msb-host/src/pages/UserNotificationsPage/constants/localization.ts b/services/msb-host/src/pages/UserNotificationsPage/constants/localization.ts deleted file mode 100644 index d91c48860..000000000 --- a/services/msb-host/src/pages/UserNotificationsPage/constants/localization.ts +++ /dev/null @@ -1,15 +0,0 @@ -const LOCALIZATION = { - IMPORTANT: 'Важное', - REQUIRED_READING: 'Обязательное к прочтению', - REQUIRING_EXECUTION: 'Требует исполнения', - EXECUTED: 'Исполнено', - LETTERS: 'Письма', - ATTACHMENTS: 'Вложения', - DOWNLOAD_ALL: 'Скачать все', - EXECUTE: 'Исполнить', - ERROR_LOAD_ARCHIVE: 'Ошибка загрузки архива', - ERROR_EXECUTE: 'Ошибка исполнения. Попробуйте еще раз', - ERROR_LOAD_FILE: 'Ошибка загрузки файла', -}; - -export { LOCALIZATION }; diff --git a/services/msb-host/src/pages/UserNotificationsPage/lib/index.ts b/services/msb-host/src/pages/UserNotificationsPage/lib/index.ts deleted file mode 100644 index b17b635f0..000000000 --- a/services/msb-host/src/pages/UserNotificationsPage/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './attachment-zip-name'; diff --git a/services/msb-host/src/pages/UserNotificationsPage/ui/NotificationPageDrawer.tsx b/services/msb-host/src/pages/UserNotificationsPage/ui/NotificationPageDrawer.tsx index 6a1c19a30..e23550582 100644 --- a/services/msb-host/src/pages/UserNotificationsPage/ui/NotificationPageDrawer.tsx +++ b/services/msb-host/src/pages/UserNotificationsPage/ui/NotificationPageDrawer.tsx @@ -1,48 +1,31 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import type { ReactNode } from 'react'; -import { useState, useMemo, useEffect, useCallback } from 'react'; -import { ButtonIcon, ButtonLink, Skeleton, Spinner } from '@fractal-ui/core'; +import { useMemo, useEffect, useCallback } from 'react'; +import { ButtonLink, Spinner } from '@fractal-ui/core'; import { Badge } from '@fractal-ui/extended'; -import type { IconComponent } from '@fractal-ui/library'; -import { FormatJpgIcon, FormatPngIcon, FormatPdfIcon, DocumentBlankIcon, EyeOpenIcon, DownloadIcon } from '@fractal-ui/library'; +import { DoneIcon } from '@fractal-ui/library'; import { Drawer, useSnackbar } from '@fractal-ui/overlays'; import type { ModalButtonProps } from '@fractal-ui/overlays'; import { Text, Title } from '@fractal-ui/styling'; -import { - type NotificationDto, - type NotificationAttachmentDto, - download as downloadFile, - useMutation, - useQuery, - EXECUTE_STATUS, - useQueryClient, -} from '@msb/http'; -import { - DataIsFailedError, - Flex, - formatSize, - MEDIA, - PATHS, - showFile, - previewFile, - useMediaQuery, - useRedirect, - EXTERNAL_URL_REGEXP, -} from '@msb/shared'; +import { type NotificationDto, useMutation, EXECUTE_STATUS, useQueryClient, READ_STATUS } from '@msb/http'; +import { DataIsFailedError, Flex, MEDIA, PATHS, useMediaQuery, useRedirect, EXTERNAL_URL_REGEXP, useDeviceType } from '@msb/shared'; import dayjs from 'dayjs'; -import { useHistory, useParams } from 'react-router-dom'; -import { LOCALIZATION } from '../constants/localization'; -import { attachmentsZipName } from '../lib'; +import { useParams } from 'react-router-dom'; import * as S from './UserNotificationsPage.styles'; -import { useUserNotificationByid } from '@/entities/UserNotification'; import { - createZip, + Attachment, + AttachmentPicture, + useDownloadFiles, + useUserNotificationByid, + openNotificationSignModal, +} from '@/entities/UserNotification'; +import { executeNotification, QUERY_KEY_USER_NOTIFICATION_BY_ID, QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT, } from '@/entities/UserNotification/api'; import { icons } from '@/entities/UserNotification/assets'; -import { NOTIFICATION_TYPE_LABELS } from '@/entities/UserNotification/constants'; +import { NOTIFICATION_TYPE_LABELS, LOCALIZATION } from '@/entities/UserNotification/constants'; import { Loader } from '@/shared/ui/components'; interface Props { @@ -55,161 +38,37 @@ interface Params { id: string; } -const MAXIMUM_PREVIEW_SIZE = 104_857_600; // 100MB - -enum ATTACHMENT_FILE_TYPE { - PDF = 'application/pdf', - PNG = 'image/png', - JPEG = 'image/jpeg', -} - -/** Получает иконку файла по его типу. */ -const getFileIcon = (contentType?: string): IconComponent => { - switch (contentType) { - case ATTACHMENT_FILE_TYPE.JPEG: - return FormatJpgIcon; - case ATTACHMENT_FILE_TYPE.PNG: - return FormatPngIcon; - case ATTACHMENT_FILE_TYPE.PDF: - return FormatPdfIcon; - default: - return DocumentBlankIcon; - } -}; - -const responseToObjectURL = (response: any) => { - if (!response.data) { - return; - } - - const blob = new Blob([response.data]); - - return URL.createObjectURL(blob); -}; - -/** Компонент для показа изображения из file-storage. */ -export const AttachmentPicture = ({ - attachmentId, - attachmentToken, -}: Pick) => { - const { data, error, isLoading } = useQuery({ - queryKey: ['attachment-image', attachmentId], - queryFn: () => downloadFile(attachmentId, attachmentToken), - enabled: Boolean(attachmentId && attachmentToken), - select: responseToObjectURL, - retry: false, - }); - - const content = useMemo(() => { - if (isLoading) { - return ; - } - - if (error) { - return null; - } - - return ; - }, [attachmentId, data, error, isLoading]); - - return content; -}; - -const Attachment = ({ attachment, notificationId }: { attachment: NotificationAttachmentDto; notificationId: string }) => { - const FileIcon = useMemo(() => getFileIcon(attachment.contentType), [attachment]); - const isMobile = useMediaQuery(MEDIA.mobile); - const { showSnackbarMessage } = useSnackbar(); - const [isHovered, setIsHovered] = useState(false); - const onMouseEnter = () => setIsHovered(true); - const onMouseLeave = () => setIsHovered(false); - - const queryClient = useQueryClient(); - - const invalidateMessage = () => { - queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, notificationId]); - }; - - const isPreviewAvailable = - !isMobile && - [ATTACHMENT_FILE_TYPE.PDF, ATTACHMENT_FILE_TYPE.PNG, ATTACHMENT_FILE_TYPE.JPEG].includes( - attachment.contentType as ATTACHMENT_FILE_TYPE - ) && - attachment.dataSize <= MAXIMUM_PREVIEW_SIZE; - - const { mutateAsync: preview, isLoading: isPreviewLoading } = useMutation({ - mutationFn: () => downloadFile(attachment.attachmentId, attachment.attachmentToken), - onSuccess: response => { - previewFile(response.data, response.type); - invalidateMessage(); - }, - onError: () => { - showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_FILE }); - }, - }); - - const { mutateAsync: download, isLoading: isDownloadLoading } = useMutation({ - mutationFn: () => downloadFile(attachment.attachmentId, attachment.attachmentToken), - onSuccess: response => { - showFile(response.data, response.fileName, response.type); - invalidateMessage(); - }, - onError: () => { - showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_FILE }); - }, - }); - - return ( - - - - - - - {attachment.fileName} - {formatSize(attachment.dataSize)} - - - {isPreviewAvailable && ( - preview()} - /> - )} - download()} - /> - - - - ); -}; - const NotificationPageDrawer = ({ setBadge, readNotification, setNotificationExecutedStatus }: Props) => { const { id } = useParams(); - const { push } = useHistory(); const isMobile = useMediaQuery(MEDIA.mobile); const { showSnackbarMessage } = useSnackbar(); const queryClient = useQueryClient(); const goToNotificationsList = useRedirect(PATHS.NOTIFICATIONS); - const { notification, isLoading, error, refetch } = useUserNotificationByid(id); + const { isTouchDevice } = useDeviceType(); + + const targetOpen = isTouchDevice ? '_self' : '_blank'; + + const invalidateQueries = useCallback(() => { + queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); + queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT]); + }, [id, queryClient]); + + const { notification, isLoading, error, isSuccess, refetch } = useUserNotificationByid(id); + const { downloadAll, downloadAllLoading } = useDownloadFiles(notification, () => { + queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); + }); + const isSigned = notification?.signatures && notification.signatures.length > 0; + + const isSignable = notification?.isRequiredSing && notification.bankClient; + + const isReadButtonVisible = notification?.isRequiredRead && notification.status === READ_STATUS.NEW; const isExecuteButtonVisible = - notification?.actionUrl && + notification && + !notification.isBlocked && + notification.actionUrl && (!notification.executionDeadline || dayjs(notification.executionDeadline).isSameOrAfter(dayjs())) && notification.executionStatus !== EXECUTE_STATUS.EXECUTED; @@ -224,15 +83,12 @@ const NotificationPageDrawer = ({ setBadge, readNotification, setNotificationExe setNotificationExecutedStatus(id); } - queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); - queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT]); + invalidateQueries(); - if (notification.actionUrl?.includes('letter') && !EXTERNAL_URL_REGEXP.test(notification.actionUrl)) { - window.open(`${process.env.ECO_CLIENT_ENDPOINT ?? ''}${notification.actionUrl}`, '_self'); + if (notification.actionUrl && !EXTERNAL_URL_REGEXP.test(notification.actionUrl)) { + window.open(`${process.env.ECO_CLIENT_ENDPOINT ?? ''}${notification.actionUrl}`, targetOpen); } else if (notification.actionUrl && EXTERNAL_URL_REGEXP.test(notification.actionUrl)) { - window.open(notification.actionUrl, '_self'); - } else if (notification.actionUrl) { - push(notification.actionUrl); + window.open(notification.actionUrl, targetOpen); } }, onError: () => { @@ -240,36 +96,29 @@ const NotificationPageDrawer = ({ setBadge, readNotification, setNotificationExe }, }); - const { mutateAsync: downloadAll, isLoading: downloadAllLoading } = useMutation({ - mutationFn: () => { - const zipName = attachmentsZipName({ - organizationName: notification?.bankClient?.shortName, - notificationId: notification?.id ?? '', - notificationCreationDate: notification?.createdAt ?? '', - }); + const handleMarkAsRead = useCallback(() => { + if (!notification) { + return; + } - return createZip(notification?.attachments.map(el => el.attachmentId) ?? [], zipName).then(response => - downloadFile(response.data.attachmentId, response.data.attachmentToken) - ); - }, - onSuccess: (response: any) => { - showFile(response.data, response.fileName, response.type); - queryClient.invalidateQueries([QUERY_KEY_USER_NOTIFICATION_BY_ID, id]); - }, - onError: () => { - showSnackbarMessage({ type: 'error', message: LOCALIZATION.ERROR_LOAD_ARCHIVE }); - }, - }); + if (isSignable) { + openNotificationSignModal(notification, () => { + readNotification(notification); + }); + } else { + readNotification(notification); + } + }, [isSignable, notification, readNotification]); const handleClose = () => { goToNotificationsList(); }; useEffect(() => { - if (notification) { + if (notification && !notification.isBlocked && isSuccess && !notification?.isRequiredRead) { readNotification(notification); } - }, [notification, readNotification]); + }, [isSuccess, notification, readNotification]); const content = useMemo(() => { if (isLoading && id) { @@ -353,21 +202,72 @@ const NotificationPageDrawer = ({ setBadge, readNotification, setNotificationExe execute(); }, [execute]); - const actions: ModalButtonProps[] | undefined = useMemo(() => { - if (isExecuteButtonVisible) { - return [ - { - text: notification.type.actionName || LOCALIZATION.EXECUTE, - dataAction: 'execute', - preventClose: true, - isLoading: isExecuteLoading, - onClick: handleClick, - width: 'auto', - variant: 'primary', - }, - ]; + const isFooterShown = useMemo( + () => + [ + notification?.isRequiredRead && notification.status === READ_STATUS.READ && !isSigned, + isSigned, + isExecuteButtonVisible, + isReadButtonVisible, + ].some(option => option), + [notification, isSigned, isExecuteButtonVisible, isReadButtonVisible] + ); + + const actions: ModalButtonProps[] = useMemo(() => { + const secondaryButtons: ModalButtonProps[] = []; + const primaryButtons: ModalButtonProps[] = []; + + if (!isFooterShown || !notification) { + return []; } - }, [notification, handleClick, isExecuteLoading, isExecuteButtonVisible]); + + if (isReadButtonVisible) { + primaryButtons.push({ + text: LOCALIZATION.MARK_AS_READ, + dataAction: 'read', + preventClose: true, + onClick: handleMarkAsRead, + width: 'auto', + variant: 'primary', + }); + } + + if (isExecuteButtonVisible) { + primaryButtons.push({ + text: notification.type.actionName || LOCALIZATION.EXECUTE, + dataAction: 'execute', + preventClose: true, + isLoading: isExecuteLoading, + onClick: handleClick, + width: 'auto', + variant: 'primary', + }); + } + + if (isSigned) { + secondaryButtons.push({ + text: LOCALIZATION.SIGNED, + dataAction: 'signed', + preventClose: true, + icon: DoneIcon, + disabled: true, + width: 'auto', + variant: 'secondary', + }); + } else if (notification.isRequiredRead && notification.status === READ_STATUS.READ && !isSigned) { + secondaryButtons.push({ + text: LOCALIZATION.READ, + dataAction: 'read', + preventClose: true, + icon: DoneIcon, + disabled: true, + width: 'auto', + variant: 'secondary', + }); + } + + return [...secondaryButtons, ...primaryButtons]; + }, [isFooterShown, notification, isReadButtonVisible, isExecuteButtonVisible, isSigned, handleMarkAsRead, isExecuteLoading, handleClick]); return ( { const setBadge = useCallback( (notification: NotificationDto, isDrawerMode?: boolean): ReactNode => { + if (notification.isRequiredRead && notification.status === READ_STATUS.READ) { + return ( +
+ + {LOCALIZATION.TAKEN_INTO_ACCOUNT} + +
+ ); + } + if (notification.executionStatus === EXECUTE_STATUS.EXECUTED) { return (
diff --git a/services/msb-host/src/shared/constants/localization.ts b/services/msb-host/src/shared/constants/localization.ts index 4e646828b..cf2a2ead3 100644 --- a/services/msb-host/src/shared/constants/localization.ts +++ b/services/msb-host/src/shared/constants/localization.ts @@ -1,6 +1,6 @@ const LOCALIZATION = { START: 'Начать', - DEAR_CUSTOMER: 'Уважаемый клиент, с\u00A0наступающим\u00A0новым\u00A0годом!', + DEAR_CUSTOMER: 'Уважаемый клиент', WE_ADAPTED_ONLINE_BANKING: 'Мы адаптировали интернет-банк под задачи малого и среднего бизнеса. Вы в числе первых можете оценить обновленный дизайн и использовать новые возможности в работе.', INVALID_DATA: 'Некорректные данные', diff --git a/services/msb-host/src/shared/ui/components/WelcomeMessageModal/WelcomeMessageModal.styles.ts b/services/msb-host/src/shared/ui/components/WelcomeMessageModal/WelcomeMessageModal.styles.ts index c8ea7c258..88d3b4231 100644 --- a/services/msb-host/src/shared/ui/components/WelcomeMessageModal/WelcomeMessageModal.styles.ts +++ b/services/msb-host/src/shared/ui/components/WelcomeMessageModal/WelcomeMessageModal.styles.ts @@ -40,8 +40,8 @@ const accountsTableGlobalStyles = css` `; const ImageNewBank = styled.img` - width: 226px; - height: 190px; + width: 320px; + height: 160px; `; export { accountsTableGlobalStyles, ImageNewBank }; diff --git a/services/msb-main-page/src/pages/MainPage/lib/useMegaBanners.ts b/services/msb-main-page/src/pages/MainPage/lib/useMegaBanners.ts index a1f8ebe22..17dbc4b36 100644 --- a/services/msb-main-page/src/pages/MainPage/lib/useMegaBanners.ts +++ b/services/msb-main-page/src/pages/MainPage/lib/useMegaBanners.ts @@ -1,6 +1,14 @@ import { useState, useEffect, useRef } from 'react'; import { FEATURE_TOGGLE_NAMES, network } from '@msb/http'; -import { EXTERNAL_URL_REGEXP, useFeatureToggles, useYaMetrika, YM_GOALS } from '@msb/shared'; +import { + EXTERNAL_URL_REGEXP, + IS_WELCOME_MESSAGE_READ_KEY, + BLOCKING_NOTIFICATION_MODAL_KEY, + useFeatureToggles, + useYaMetrika, + YM_GOALS, + useAppContext, +} from '@msb/shared'; import { useHistory } from 'react-router-dom'; interface UseMegaBannerReturn { @@ -49,6 +57,7 @@ const EBG_BANNER_GOALS = { const useMegaBanners = (): UseMegaBannerReturn => { const history = useHistory(); const { handleReachGoal } = useYaMetrika(); + const { isRequiredNotificationsLoaded, isRequiredNotificationsFailed } = useAppContext(); const isMegaBannerConfirmedRef = useRef(false); const isEBGConfirmedRef = useRef(false); const hasSentEBGShowMetric = useRef(false); @@ -85,54 +94,58 @@ const useMegaBanners = (): UseMegaBannerReturn => { // проверка, какие баннеры нужно показать useEffect(() => { const checkBanners = () => { - // if (localStorage.getItem(IS_WELCOME_MESSAGE_READ_KEY)) { - const now = Date.now(); - let showEBG = false; - let showMegaBanner = false; - - const ebgCooldownData = localStorage.getItem(EBG_BANNER_COOLDOWN_KEY); - - if (ebgCooldownData) { - const { expiryDate } = JSON.parse(ebgCooldownData); - - if (now >= expiryDate) { - showEBG = true; - } - } else { - showEBG = true; + if (localStorage.getItem(BLOCKING_NOTIFICATION_MODAL_KEY)) { + return; } - if (!showEBG) { - const megaBannerCooldownData = localStorage.getItem(MEGA_BANNER_COOLDOWN_KEY); + if (localStorage.getItem(IS_WELCOME_MESSAGE_READ_KEY) && (isRequiredNotificationsLoaded || isRequiredNotificationsFailed)) { + const now = Date.now(); + let showEBG = false; + let showMegaBanner = false; - if (megaBannerCooldownData) { - const { expiryDate } = JSON.parse(megaBannerCooldownData); + const ebgCooldownData = localStorage.getItem(EBG_BANNER_COOLDOWN_KEY); + + if (ebgCooldownData) { + const { expiryDate } = JSON.parse(ebgCooldownData); if (now >= expiryDate) { - const displayCount = Number(localStorage.getItem(DISPLAY_COUNT_KEY) || '0'); - - setCanIncrementCounter(true); - - showMegaBanner = displayCount >= MEGA_BANNER_THRESHOLD; + showEBG = true; } } else { - const displayCount = Number(localStorage.getItem(DISPLAY_COUNT_KEY) || '0'); + showEBG = true; + } - if (displayCount >= MEGA_BANNER_THRESHOLD) { - showMegaBanner = true; + if (!showEBG) { + const megaBannerCooldownData = localStorage.getItem(MEGA_BANNER_COOLDOWN_KEY); + + if (megaBannerCooldownData) { + const { expiryDate } = JSON.parse(megaBannerCooldownData); + + if (now >= expiryDate) { + const displayCount = Number(localStorage.getItem(DISPLAY_COUNT_KEY) || '0'); + + setCanIncrementCounter(true); + + showMegaBanner = displayCount >= MEGA_BANNER_THRESHOLD; + } + } else { + const displayCount = Number(localStorage.getItem(DISPLAY_COUNT_KEY) || '0'); + + if (displayCount >= MEGA_BANNER_THRESHOLD) { + showMegaBanner = true; + } } } - } - setIsEBGOpen(showEBG); - setIsMegaBannerOpen(showMegaBanner); - // } + setIsEBGOpen(showEBG); + setIsMegaBannerOpen(showMegaBanner); + } }; if (isMegaBannerEnabled) { checkBanners(); } - }, [isMegaBannerEnabled]); + }, [isMegaBannerEnabled, isRequiredNotificationsFailed, isRequiredNotificationsLoaded]); // запускает счетчик после закрытия EBG баннера useEffect(() => {