Pull request #2493: Release 1.10.1

Merge in MCB_FE/mcb-platform-monorepo from release-1.10.1 to master

* commit '47d3052ec3c41dc7e4ef10e655b104b1b46b283e':
  feat(TEAMMSBMOB-22080): задержка закрытия модального окна
  feat(TEAMMSBMOB-22080): блокирующие уведомления и их подписание
  fix(TEAMMSBMOB-56633): Исправление нач значений при повторе выписки
This commit is contained in:
Егор Онуфрийчук
2026-02-02 18:36:58 +03:00
42 changed files with 1169 additions and 321 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@msb/ib-module",
"version": "1.10.0",
"version": "1.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@msb/ib-module",
"version": "1.10.0",
"version": "1.10.1",
"files": [
"msb-host",
"msb-main-page",
@@ -238,7 +238,7 @@
},
"txt": {
"dstList": {
"certExpireWarning_0":"Истекает через {{count}} день",
"certExpireWarning_0": "Истекает через {{count}} день",
"certExpireWarning_1": "Истекает через {{count}} дня",
"certExpireWarning": "Истекает через {{count}} дней"
},
+1
View File
@@ -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 };
+11 -1
View File
@@ -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 };
@@ -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,
};
@@ -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 };
@@ -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<AppContextValue | null>(null);
@@ -1,2 +1,3 @@
export { DateFieldWithCalendar } from './ui';
export { PERIOD_TYPE, type DateWithPeriod } from './models';
export { getIntervalByPeriod } from './lib';
@@ -126,6 +126,10 @@
{
"featureCode": "metricaSetIdIBMSB",
"isEnabled": true
},
{
"featureCode": "blockingNotificationsIBMSB",
"isEnabled": true
}
]
}
@@ -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<OrganizationDto | null>(
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,
]
);
@@ -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>облачную электронную подпись</0>.\n\nДокумент сохранён.",
"desktopNoCertContent": "Для подписания документа выпустите или зарегистрируйте <0>электронную подпись</0>.\n\nДокумент сохранён."
"mobileNoCertContent": "Для подписания документа на мобильном устройстве выпустите <0>облачную электронную подпись</0>.",
"desktopNoCertContent": "Для подписания документа выпустите или зарегистрируйте <0>электронную подпись</0>."
},
"btn": {
"toProfile": "В раздел Электронные подписи"
@@ -241,7 +241,7 @@
}
},
"btn": {
"toList": "К списку заявок"
"toList": "Назад"
},
"lbl": {
"signToolAbb": "СЭП",
@@ -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';
@@ -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<CategoryCountDto>
return response.data;
};
const fetchRequiredReadUserNotifications = async (): Promise<ServerResponsePaginationData<NotificationDto>> => {
const response = await network.client.post<ServerResponsePaginationData<NotificationDto>>(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,
};
@@ -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 };
@@ -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 };
@@ -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 };
@@ -1,3 +1,4 @@
export * from './model';
export * from './ui';
export * from './constants';
export * from './api';
@@ -0,0 +1 @@
export { attachmentsZipName } from './attachment-zip-name';
@@ -1,2 +1,4 @@
export * from './useUserNotifications';
export * from './useUserNotificationsLazyLoad';
export * from './useDownloadFiles';
export * from './openSignModal';
@@ -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 };
@@ -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 };
@@ -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<NotificationDto, Error | undefined>({
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 };
@@ -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 = <T>({ activeTab, filters }: { activeTab: T; fil
})
);
queryClient.invalidateQueries({ queryKey: [QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT] });
await queryClient.invalidateQueries({ queryKey: [QUERY_KEY_USER_NOTIFICATION_CATEGORY_COUNT] });
}
}
},
@@ -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<NotificationAttachmentDto, 'attachmentId' | 'attachmentToken'>) => {
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 <Skeleton dataName={`loading-attachment-${attachmentId}`} height="160px" mb="3" variant="rounded" width="100%" />;
}
if (error) {
return null;
}
return <S.Image height="160px" mb="3" src={data} />;
}, [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 (
<S.AttachmentContainer onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex row>
<S.AttachmentPictogram row alignItems="center" justifyContent="center">
<FileIcon />
</S.AttachmentPictogram>
<Flex column ml="3" mr="5">
<S.FileName>{attachment.fileName}</S.FileName>
<Text.P4 color="text.secondary">{formatSize(attachment.dataSize)}</Text.P4>
</Flex>
<Flex row columnGap="2" ml="auto" opacity={isHovered || isTouchDevice ? 1 : 0}>
{isPreviewAvailable && (
<ButtonIcon
color="text.secondary"
dataAction="preview-attachment"
icon={EyeOpenIcon}
isLoading={isPreviewLoading}
size="S"
title="Посмотреть"
variant="ghost"
onClick={() => preview()}
/>
)}
<ButtonIcon
color="text.secondary"
dataAction="download-attachment"
icon={DownloadIcon}
isLoading={isDownloadLoading}
size="S"
title="Скачать"
variant="ghost"
onClick={() => download()}
/>
</Flex>
</Flex>
</S.AttachmentContainer>
);
};
export { Attachment, AttachmentPicture };
@@ -0,0 +1 @@
export * from './Attachement';
@@ -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 };
@@ -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<any, unknown, string>;
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 <Loader dataName="loader" />;
}
if (error) {
return (
<>
<YaMetrikaReachGoal goalType={YM_GOALS.ERROR} params={{ [YM_GOALS.ERROR]: { error_description: LOCALIZATION.DATA_IS_FAILED } }} />
<DataIsFailedError
onClick={() => {
refetch();
}}
/>
</>
);
}
return (
notification && (
<>
<Badge size="S" type={isTakenIntoAccount ? 'success' : 'error'}>
{isTakenIntoAccount ? LOCALIZATION.TAKEN_INTO_ACCOUNT : LOCALIZATION.REQUIRED_READING}
</Badge>
<Flex column marginTop="20px" wordBreak="break-word">
{notification.bankClient?.shortName && (
<Text.P3 color="text.secondary" marginBottom="4px">
{notification.bankClient.shortName}
</Text.P3>
)}
{notification.createdAt && (
<Text.P3 color="text.secondary" marginBottom="16px">
{dayjs(notification.createdAt).format('DD.MM.YYYY, HH:mm')}
</Text.P3>
)}
{notification.type.notificationHeader && <Title.H4 marginBottom="12px">{notification.type.notificationHeader}</Title.H4>}
{notification.picture && (
<AttachmentPicture attachmentId={notification.picture.attachmentId} attachmentToken={notification.picture.attachmentToken} />
)}
{notification.body && (
<Text.P2
dangerouslySetInnerHTML={{
__html: notification.body,
}}
/>
)}
</Flex>
{notification.attachments.length > 0 && (
<div>
<Flex row justifyContent="space-between" marginBottom="12px">
<Flex row gap={2}>
<Title.H4>{LOCALIZATION.ATTACHMENTS}</Title.H4>
<Badge size="XS" type="system">
{notification.attachments.length}
</Badge>
</Flex>
{!isMobile && (
<ButtonLink
dataAction="download-all"
size="S"
onClick={() => {
downloadAll();
}}
>
{downloadAllLoading ? <Spinner dataName="download-all-loader" size="S" /> : LOCALIZATION.DOWNLOAD_ALL}
</ButtonLink>
)}
</Flex>
<Flex column gap={3}>
{notification.attachments.map(attachment => (
<Attachment key={attachment.attachmentId} attachment={attachment} notificationId={notification.id} />
))}
</Flex>
</div>
)}
</>
)
);
}, [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 (
<div>
{content}
<Flex row gap={2} justifyContent="flex-end" marginTop="20px" width="100%">
{actions.map(action => (
<Button key={action.title} size="S" {...action}>
{action.title}
</Button>
))}
</Flex>
</div>
);
};
export { BlockingNotification };
@@ -0,0 +1,203 @@
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<HTMLDivElement>(null);
const [expandedId, setExpandedId] = useState<string | null>(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)) {
const closeTimer = setTimeout(() => {
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);
});
}, DELAY_MS);
return () => clearTimeout(closeTimer);
}
}, [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 (
<Modal
hideBackIcon
preventCloseOnOutside
preventSimpleClose
header={notifications.length > 1 ? LOCALIZATION.REQUIRED_NOTIFICATIONS : LOCALIZATION.REQUIRED_NOTIFICATION}
isNeedScroll={false}
isOpen={shouldShowModal && isModalOpen && !isSignModalOpened}
minHeight={638}
width={768}
onClose={handleCloseModal}
>
<Flex
ref={node => {
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 (
<S.Card key={notification.id} column backgroundColor="bg.four">
<Flex row alignItems="flex-start" gap="12px">
<S.Pictogram
row
alignItems="center"
bg={`control.multicolored.${notification.status === READ_STATUS.READ ? 'bgGreen' : 'bgOrange'}`}
justifyContent="center"
transition="background-color 0.3s ease"
>
{notification.status === READ_STATUS.READ ? (
<OkIcon color="control.multicolored.textGreen" size="L" />
) : (
<AttentionIcon color="control.multicolored.textOrange" size="L" />
)}
</S.Pictogram>
<Flex
column
flex={1}
onClick={() => {
if (isTouchDevice) {
handleExpandCard(notification.id);
}
}}
>
<Text.P1 flex={1} fontSize={`typography.P${isMobile ? 2 : 1}.fontSize.S`} wordBreak="break-word">
{notification.type.notificationHeader}
</Text.P1>
{notifications.length > 1 && !isTouchDevice && (
<S.ExpandButtonWrapper $isCollapsed={!isExpanded}>
<ButtonIcon
aria-expanded={isExpanded}
aria-label={isExpanded ? LOCALIZATION.COLLAPSE_NOTIFICATION : LOCALIZATION.EXPAND_NOTIFICATION}
dataAction={`toggle-notification-${notification.id}`}
icon={UpIcon}
iconColor="text.secondary"
size="S"
variant="ghost"
onClick={() => handleExpandCard(notification.id)}
/>
</S.ExpandButtonWrapper>
)}
<Text.P2 color="text.secondary" fontSize={`typography.P${isMobile ? 3 : 2}.fontSize.S`}>
{dayjs(notification.createdAt).format('DD.MM.YYYY, HH:mm')}
</Text.P2>
</Flex>
</Flex>
<SmoothAutoHeight key={`accordion-${notification.id}`} isAccordionMode elementRef={collapsedRef} isCollapsed={!isExpanded}>
<S.CollapsedArea ref={collapsedRef}>
<BlockingNotification
id={notification.id}
markAsRead={markAsRead}
markAsReadLoading={markAsReadLoading}
setSignModalOpened={setSignModalOpened}
/>
</S.CollapsedArea>
</SmoothAutoHeight>
</S.Card>
);
})}
</Flex>
</Modal>
);
};
export { BlockingNotificationModal };
@@ -0,0 +1 @@
export { BlockingNotificationModal } from './BlockingNotificationModal';
@@ -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 };
@@ -1 +1,3 @@
export { UserNotifications } from './UserNotifications';
export * from './Attachment';
export * from './BlockingNotification';
@@ -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]);
<StyledThemeProvider>
<S.GlobalStyles />
<LayoutContent modules={modules} />
</StyledThemeProvider>
);
return (
<StyledThemeProvider>
<S.GlobalStyles />
<LayoutContent modules={modules} />
{isEnabled && <BlockingNotificationModal notifications={requiredNotifications} />}
</StyledThemeProvider>
);
};
export default Layout;
@@ -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 };
@@ -1 +0,0 @@
export * from './attachment-zip-name';
@@ -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<NotificationAttachmentDto, 'attachmentId' | 'attachmentToken'>) => {
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 <Skeleton dataName={`loading-attachment-${attachmentId}`} height="160px" mb="3" variant="rounded" width="100%" />;
}
if (error) {
return null;
}
return <S.Image height="160px" mb="3" src={data} />;
}, [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 (
<S.AttachmentContainer onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Flex row>
<S.AttachmentPictogram row alignItems="center" justifyContent="center">
<FileIcon />
</S.AttachmentPictogram>
<Flex column ml="3" mr="5">
<S.FileName>{attachment.fileName}</S.FileName>
<Text.P4 color="text.secondary">{formatSize(attachment.dataSize)}</Text.P4>
</Flex>
<Flex row columnGap="2" ml="auto" opacity={isHovered || isMobile ? 1 : 0}>
{isPreviewAvailable && (
<ButtonIcon
color="text.secondary"
dataAction="preview-attachment"
icon={EyeOpenIcon}
isLoading={isPreviewLoading}
size="S"
title="Посмотреть"
variant="ghost"
onClick={() => preview()}
/>
)}
<ButtonIcon
color="text.secondary"
dataAction="download-attachment"
icon={DownloadIcon}
isLoading={isDownloadLoading}
size="S"
title="Скачать"
variant="ghost"
onClick={() => download()}
/>
</Flex>
</Flex>
</S.AttachmentContainer>
);
};
const NotificationPageDrawer = ({ setBadge, readNotification, setNotificationExecutedStatus }: Props) => {
const { id } = useParams<Params>();
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 (
<Drawer
@@ -1,12 +1,10 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable compat/compat */
import type { ReactNode } from 'react';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Tabs } from '@fractal-ui/composites';
import { Button, ButtonIcon, Skeleton, Spinner } from '@fractal-ui/core';
import { Badge } from '@fractal-ui/extended';
import { MailIcon } from '@fractal-ui/library';
import { queryClient, EXECUTE_STATUS, IMPORTANCE_VALUES, type NotificationDto } from '@msb/http';
import { queryClient, EXECUTE_STATUS, IMPORTANCE_VALUES, type NotificationDto, READ_STATUS } from '@msb/http';
import {
DataIsFailedError,
EXTERNAL_PATHS,
@@ -21,7 +19,7 @@ import {
} from '@msb/shared';
import { generatePath, useHistory } from 'react-router-dom';
import { TABS, TABS_LABELS } from '../constants';
import { LOCALIZATION } from '../constants/localization';
import { LOCALIZATION } from '@/entities/UserNotification/constants';
import { Filters } from './Filters';
import { NotificationItem, NotificatioMobileItem } from './NotificationItem';
import { NotificationPageDrawer } from './NotificationPageDrawer';
@@ -117,6 +115,16 @@ const UserNotificationsListPage = () => {
const setBadge = useCallback(
(notification: NotificationDto, isDrawerMode?: boolean): ReactNode => {
if (notification.isRequiredRead && notification.status === READ_STATUS.READ) {
return (
<div>
<Badge size="S" type="success">
{LOCALIZATION.TAKEN_INTO_ACCOUNT}
</Badge>
</div>
);
}
if (notification.executionStatus === EXECUTE_STATUS.EXECUTED) {
return (
<div>
@@ -1,6 +1,6 @@
const LOCALIZATION = {
START: 'Начать',
DEAR_CUSTOMER: 'Уважаемый клиент, с\u00A0наступающим\u00A0новым\u00A0годом!',
DEAR_CUSTOMER: 'Уважаемый клиент',
WE_ADAPTED_ONLINE_BANKING:
'Мы адаптировали интернет-банк под задачи малого и среднего бизнеса. Вы в числе первых можете оценить обновленный дизайн и использовать новые возможности в работе.',
INVALID_DATA: 'Некорректные данные',
@@ -40,8 +40,8 @@ const accountsTableGlobalStyles = css`
`;
const ImageNewBank = styled.img`
width: 226px;
height: 190px;
width: 320px;
height: 160px;
`;
export { accountsTableGlobalStyles, ImageNewBank };
@@ -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(() => {
@@ -4,8 +4,7 @@ import {
STATEMENT_REQUEST_TYPES,
type StatementRequestFormDto,
} from '@msb/http/statements';
import type { PERIOD_TYPE } from '@msb/shared';
import { formatDateTime } from '@msb/shared';
import { PERIOD_TYPE, formatDateTime, getIntervalByPeriod } from '@msb/shared';
import type { DateForReconcile, DateIntervalItem, StatementFormFromDto } from './types';
import { FILE_FORMATS_SHORT } from '@/shared/constants';
@@ -68,14 +67,16 @@ function mapStatementRequestDtoToForm(requestData: StatementRequestFormDto | und
} = requestData;
const { separateDocumentsFiles, ...includedDocuments } = documentOptionsDto;
const calculatedInterval = getIntervalByPeriod(periodType as unknown as PERIOD_TYPE);
const [calculatedPeriodStart, calculatedPeriodEnd] = calculatedInterval || [periodStart, periodEnd];
return {
accountIds: accountsIds,
action: statementActionDto,
dateWithPeriod: {
dateInterval: [new Date(periodStart), new Date(periodEnd)],
dateInterval: [new Date(calculatedPeriodStart), new Date(calculatedPeriodEnd)],
/** FIXME [m.kudryashov 23.09.2025]: PERIOD_TYPE !== STATEMENT_REQUEST_DATE_PERIODS. */
period: periodType as unknown as PERIOD_TYPE,
period: calculatedInterval === undefined ? PERIOD_TYPE.SELECT_PERIOD : (periodType as unknown as PERIOD_TYPE),
},
operations: statementOperationDto,
...documentOptionsDto,