feat(TEAMMSBMOB-19897): платеж из шаблона

This commit is contained in:
2025-11-20 15:03:31 +03:00
parent b229994eda
commit 94e5120372
32 changed files with 573 additions and 105 deletions
@@ -0,0 +1,14 @@
import { network, getDataOrThrowError, RUBLE_PAYMENT_TEMPLATE_FILL_ENDPOINT, type RublePaymentTemplateFill } from '@msb/http';
import type { ServerResponse } from '../../../model';
import { getTimeOffsetHours } from '../../../network/utils';
const fetchPaymentTemplateFill = async (id: string, organizationId: string): Promise<RublePaymentTemplateFill.Record> => {
const path = RUBLE_PAYMENT_TEMPLATE_FILL_ENDPOINT.replace(':id', id);
const response = await network.client.get<ServerResponse<RublePaymentTemplateFill.Record>>(path, {
headers: { OrganizationId: organizationId, TimeOffset: getTimeOffsetHours() },
});
return getDataOrThrowError(response).data;
};
export { fetchPaymentTemplateFill };
@@ -0,0 +1 @@
export * from './fetchPaymentTemplateFill';
@@ -6,12 +6,13 @@ const fetchPaymentTemplate = async (
organizationId: string,
offset: number,
limit: number,
sortingField: string,
search?: string
): Promise<RublePaymentTemplate.Record[]> => {
const data = {
params: {
paging: { offset, limit },
multiSort: [{ field: 'usedAt', direction: 'DESC' }],
multiSort: [{ field: sortingField, direction: 'DESC' }],
...(search
? {
filter: {
@@ -7,3 +7,4 @@ export * from './getPaymentCopy';
export * from './getPaymentInfo';
export * from './getPaymentTemplate';
export * from './fetchPaymentCreateTemplate';
export * from './fetchPaymentTemplateFill';
@@ -0,0 +1,3 @@
const RUBLE_PAYMENT_TEMPLATE_FILL_ENDPOINT = '/ruble-payment-client/ruble-payment/template/fill/:id' as const;
export { RUBLE_PAYMENT_TEMPLATE_FILL_ENDPOINT };
@@ -0,0 +1,2 @@
export * from './const';
export * from './types';
@@ -0,0 +1,74 @@
namespace RublePaymentTemplateFill {
export interface PaymentInfo {
paymentTypeName?: string;
paymentType?: string;
operationType?: string;
paymentPriority?: string;
paymentPurpose?: string;
}
export interface PaymentAmount {
paymentAmount: string;
paymentAmountIncludeVat: string;
paymentCurrency: string;
vatCalculationMethod: string;
vatAmount: string;
vatPercentRate: number;
}
export interface PayerDetails {
name: string;
innKio: string;
kpp: string;
accountNumber: string;
accountType: string;
clientAccountId: string;
bankName: string;
bankBic: string;
bankCorrespondentAccountNumber: string;
bankLocalityName: string;
bankLocalityType: string;
thirdPartyPayment: boolean;
branchName: string;
balanceBranchName: string;
}
export interface BudgetInfo {
budgetPayment: boolean;
docAuthorStatus: string;
kbk: string;
oktmo: string;
paymentBasis: string;
taxPeriodValue: string;
taxPeriodValueType: string;
paymentBasisDocNumber: string;
paymentBasisDocDate: string;
}
export interface RecipientDetails {
name: string;
innKio: string;
kpp: string;
accountNumber: string;
bankName: string;
bankBic: string;
bankCorrespondentAccountNumber: string;
bankLocalityName: string;
bankLocalityType: string;
}
export interface Record {
id: string;
userId: string;
userFio: string;
templateName: string;
bankClientId: string;
clientRnk: string;
eskClientId: string,
paymentInfo: PaymentInfo;
paymentAmount: PaymentAmount;
payerDetails: PayerDetails;
recipientDetails?: RecipientDetails;
budgetInfo: BudgetInfo;
version: number;
accountExists?: boolean;
}
}
export type { RublePaymentTemplateFill };
@@ -30,7 +30,7 @@ namespace RublePaymentInfo {
readonly accountNumber: string;
readonly accountType?: string;
readonly clientAccountId: string;
readonly clientF1AccountId: number;
readonly clientF1AccountId?: number;
readonly bankName: string;
readonly bankBic: string;
readonly bankCorrespondentAccountNumber: string;
@@ -54,6 +54,21 @@ namespace RublePaymentInfo {
export interface DocumentDetails {
readonly payerAccountReceiptDate: string;
}
export interface BudgetInfo {
readonly budgetPayment: boolean;
readonly docAuthorStatus?: string;
readonly kbk?: string;
readonly oktmo?: string;
readonly paymentBasis?: string;
readonly taxPeriodValueType?: string;
readonly taxPeriodType?: string;
readonly taxPeriod?: string;
readonly taxPeriodYear?: number;
readonly taxPeriodDate?: string;
readonly taxPeriodCustomsCode?: string;
readonly paymentBasisDocNumber?: string;
readonly paymentBasisDocDate?: string;
}
export interface Record {
readonly id: string;
readonly status: string;
@@ -73,21 +88,7 @@ namespace RublePaymentInfo {
readonly paymentAmount?: PaymentAmount;
readonly payerDetails?: PayerDetails;
readonly recipientDetails?: RecipientDetails;
readonly budgetInfo: {
readonly budgetPayment: boolean;
readonly docAuthorStatus?: string;
readonly kbk?: string;
readonly oktmo?: string;
readonly paymentBasis?: string;
readonly taxPeriodValueType?: string;
readonly taxPeriodType?: string;
readonly taxPeriod?: string;
readonly taxPeriodYear?: number;
readonly taxPeriodDate?: string;
readonly taxPeriodCustomsCode?: string;
readonly paymentBasisDocNumber?: string;
readonly paymentBasisDocDate?: string;
};
readonly budgetInfo?: BudgetInfo;
readonly createdAt: string;
readonly version: number;
}
@@ -12,3 +12,4 @@ export * from './getPaymentCopy';
export * from './getPaymentInfo';
export * from './getPaymentTemplate';
export * from './fetchPaymentCreateTemplate';
export * from './fetchPaymentTemplateFill';
@@ -4,11 +4,19 @@ import type { RublePaymentTemplate } from '../../endpoints';
const tenSeconds = 10_000;
const usePaymentTemplate = (organizationId: string, offset: number = 0, limit: number = 25, isStrict: boolean = false, search?: string) =>
const usePaymentTemplate = (
organizationId: string,
offset: number = 0,
limit: number = 25,
isStrict: boolean = false,
sortByDate: boolean = false,
search?: string
) =>
useInfiniteQuery<RublePaymentTemplate.Record[], Error | undefined>({
cacheTime: tenSeconds,
queryKey: [GET_RUBLE_PAYMENTS_TEMPLATE_QUERY, organizationId, offset, limit, ...(search ? [search] : [])],
queryFn: ({ pageParam = { offset, limit } }) => fetchPaymentTemplate(organizationId, pageParam.offset, pageParam.limit, search),
queryFn: ({ pageParam = { offset, limit } }) =>
fetchPaymentTemplate(organizationId, pageParam.offset, pageParam.limit, sortByDate ? 'createdAt' : 'usedAt', search),
getNextPageParam: (page, allPages) => {
if (isStrict) {
return;
+2 -1
View File
@@ -38,10 +38,11 @@ const PATHS_DEPOSITS = {
const PATHS_PAYMENTS = {
HOME: PATHS.PAYMENTS,
EDIT_OR_DETAILS: `${PATHS.PAYMENTS}/:id`,
PAYMENT_ORDER: `${PATHS.PAYMENTS}/payment-order`,
TEMPLATES: `${PATHS.PAYMENTS}/templates`,
CREATE_TEMPLATE: `${PATHS.PAYMENTS}/templates/new`,
EDIT_OR_DETAILS: `${PATHS.PAYMENTS}/:id`,
EDIT_TEMPLATE: `${PATHS.PAYMENTS}/templates/:id`,
} as const;
const STATEMENTS_AND_INQUIRIES_PATHS = {
@@ -26,4 +26,15 @@ const useHashedRedirect = (path: string) => {
);
};
export { useRedirect, useHashedRedirect };
const useHashedReplace = (path: string) => {
const history = useHistory();
return useCallback(
(hash: string) => {
history.replace(`${path}#${hash}`);
},
[path, history]
);
};
export { useRedirect, useHashedRedirect, useHashedReplace };
@@ -12,6 +12,7 @@ const AppRouter = () => (
<Route component={PaymentOrderPage} path={PATHS.PAYMENT_ORDER.PATH} />
<Route exact component={TemplatesPage.Page} path={PATHS.TEMPLATES.PATH} />
<Route component={CreateTemplatePage.Page} path={PATHS.CREATE_TEMPLATE.PATH} />
<Route component={CreateTemplatePage.Page} path={PATHS.EDIT_TEMPLATE.PATH} />
<Route exact component={OrderDetailsPage.Page} path={PATHS.EDIT_OR_DETAILS.PATH} />
</Switch>
);
@@ -1,4 +1,12 @@
const LOCALIZATION = {
TEMPLATE: {
TITLE: 'Название шаблона',
ERROR: {
TITLE: 'Указанный в шаблоне счёт списания недоступен',
DESCRIPTION: 'Для создания платежа выберите действующий счёт',
BUTTON: 'Закрыть',
},
},
PAYEE_BLOCK: {
TITLE: 'Получатель',
FIELD_NAME_OR_INN: {
@@ -1,7 +1,13 @@
import { ValidationBankSerials } from '@msb/shared';
import type { TaxPeriodType, TaxPeriodValueType } from '../constants';
import { FIELD_IDENTITY, validationErrorMessages, BUDGET_FIELD_IDS, type BudgetFieldId } from '../constants';
import { TAX_PERIOD_TYPE, TAX_PERIOD_VALUE_TYPE } from '../constants';
import {
FIELD_IDENTITY,
validationErrorMessages,
BUDGET_FIELD_IDS,
TAX_PERIOD_TYPE,
TAX_PERIOD_VALUE_TYPE,
type BudgetFieldId,
} from '../constants';
import { ValidationMessage } from './ValidationMessage';
namespace FormValidation {
@@ -1,14 +1,14 @@
import type { RefObject } from 'react';
import { fetchPaymentInfo, fetchPaymentCopy, RUBLE_PAYMENT_CLIENT_STATUS } from '@msb/http';
import { fetchPaymentInfo, fetchPaymentCopy, RUBLE_PAYMENT_CLIENT_STATUS, fetchPaymentTemplateFill } from '@msb/http';
import type {
PaymentDocumentData,
RublePaymentClientRublePaymentRequestData,
RublePaymentCopy,
RublePaymentDto,
RublePaymentInfo,
RublePaymentTemplateFill,
} from '@msb/http';
import type { UserDeviceInfo, UserBlockingStatus } from '@msb/shared';
import { getUserDeviceInfo, isOrganizationBlocked } from '@msb/shared';
import { type UserDeviceInfo, type UserBlockingStatus, getLocalDateTimeISO, getUserDeviceInfo, isOrganizationBlocked } from '@msb/shared';
import { FIELD_IDENTITY } from '../constants';
import type { PayeeAccount } from '../ui/PayeeFormFields';
import type { PayerFieldForm } from '../ui/PayerFormFields';
@@ -19,6 +19,7 @@ import { rublePaymentClientRublePayment } from '@/entities/sign';
namespace PaymentOrderFormFields {
export type ForceUpdate = () => void;
export type ShowStatusModal = () => void;
export type ShowSnackbarMessageFn = (type: TOAST_TYPE) => void;
export type ShowSignResultModalFn = (isSuccess: boolean) => void;
@@ -37,6 +38,7 @@ namespace PaymentOrderFormFields {
export enum ACTION_TYPE {
REPEAT = 'R',
EDIT = 'E',
TEMPLATE = 'T',
}
export enum VALIDATION_LEVEL {
@@ -76,17 +78,47 @@ namespace PaymentOrderFormFields {
version: number;
}
interface Template {
name: string;
}
export interface Form extends PayeeAccount.Input, PayerFieldForm.Input {
check(): boolean;
save(): boolean;
signAndSend(): boolean;
restore(identity: string): void;
restore(identity: string, showStatusModal?: ShowStatusModal): void;
readonly isSaving: boolean;
readonly isSigningAndSending: boolean;
readonly isFromTemplate: boolean;
readonly templateName: string;
getDocumentStatus(): string | null;
updateAddressFromPayeeAccount(payeeAccount: string): void;
}
const mapTemplate2Info = (record: RublePaymentTemplateFill.Record): RublePaymentInfo.Record => {
const result = {
...record,
status: 'NEW',
canCreateDio: false,
docSourceSystem: 'WEB',
archive: false,
docCreationType: 'TEMPLATE',
docCreationPage: '',
id: '',
sampleDocId: record.id,
createdAt: getLocalDateTimeISO(new Date()),
paymentInfo: {
paymentTypeName: record.paymentInfo?.paymentTypeName || '',
paymentType: record.paymentInfo?.paymentType || '',
operationType: record.paymentInfo?.operationType || '',
paymentPriority: record.paymentInfo?.paymentPriority || '',
paymentPurpose: record.paymentInfo?.paymentPurpose || '',
},
};
return result as RublePaymentInfo.Record;
};
class FormImpl implements Form {
private _payeeFieldsOutput: PayeeAccount.Output | null = null;
private _payerFieldsOutput: PayerFieldForm.Output | null = null;
@@ -101,6 +133,7 @@ namespace PaymentOrderFormFields {
private readonly _blockingStatus: UserBlockingStatus | null;
private _isSaving = false;
private _isSigningAndSending = false;
private _template: Template | null = null;
constructor(
organizationId: string,
forceUpdate: ForceUpdate,
@@ -125,6 +158,14 @@ namespace PaymentOrderFormFields {
return this._isSigningAndSending;
}
get isFromTemplate(): boolean {
return this._template !== null;
}
get templateName(): string {
return this._template?.name || '';
}
getDocumentStatus(): string | null {
return this._documentData?.status || null;
}
@@ -137,11 +178,15 @@ namespace PaymentOrderFormFields {
// interface Form
restore(identity: string) {
restore(identity: string, showStatusModal?: ShowStatusModal) {
this._template = null;
const identityValue = identity.startsWith('#') ? identity.substring(1) : identity;
const iValue = identityValue.substring(0, 1);
if (iValue.length === 0 || !(iValue === ACTION_TYPE.EDIT || iValue === ACTION_TYPE.REPEAT)) {
const types = [ACTION_TYPE.EDIT, ACTION_TYPE.REPEAT, ACTION_TYPE.TEMPLATE];
if (iValue.length === 0 || !types.includes(iValue as ACTION_TYPE)) {
return;
}
@@ -163,6 +208,18 @@ namespace PaymentOrderFormFields {
this.restoreForm(result, this._payeeFieldsOutput, this._payerFieldsOutput);
});
break;
case ACTION_TYPE.TEMPLATE:
fetchPaymentTemplateFill(id, organizationId).then(result => {
this._template = { name: result.templateName };
this._documentData = { docCreationType: DOC_CREATION_TYPE.TEMPLATE };
this._repeatDocument = { ...mapTemplate2Info(result), docCreationType: DOC_CREATION_TYPE.TEMPLATE };
this.restoreForm(mapTemplate2Info(result), this._payeeFieldsOutput, this._payerFieldsOutput);
if (result.accountExists !== true && showStatusModal) {
showStatusModal();
}
});
break;
default:
break;
}
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
const ContainerWrapper = styled.div`
display: flex;
flex-direction: column;
margin-top: 16px;
gap: 16px;
`;
@@ -1,8 +1,10 @@
import type { ReactElement } from 'react';
import { useEffect, useCallback, useReducer, useRef, useState } from 'react';
import { Input } from '@fractal-ui/composites';
import { Button, ScrollContainer } from '@fractal-ui/core';
import { ErrorIcon, OkIcon } from '@fractal-ui/library';
import { useSnackbar } from '@fractal-ui/overlays';
import { StatusModal, useSnackbar } from '@fractal-ui/overlays';
import { Text } from '@fractal-ui/styling';
import { queryClient, QUERY_KEYS_OPERATIONS_HISTORY } from '@msb/http';
import {
AUTHORITIES,
@@ -79,6 +81,10 @@ namespace PaymentOrderForm {
const toPayments = useRedirect(GLOBAL_PATHS.PAYMENTS);
const [showSuccessScreen, setShowSuccessScreen] = useState(false);
const { handleReachGoal } = useYaMetrika();
const [showStatusModal, setShowStatusModal] = useState(false);
const handleCloseStatusModal = useCallback(() => {
setShowStatusModal(false);
}, [setShowStatusModal]);
const modalShowCallback = useCallback(
(isSuccess: boolean) => {
@@ -113,7 +119,9 @@ namespace PaymentOrderForm {
);
useEffect(() => {
formFields.restore(hash);
formFields.restore(hash, () => {
setShowStatusModal(true);
});
}, [hash, formFields]);
useEffect(() => {
@@ -123,69 +131,99 @@ namespace PaymentOrderForm {
const handleSubmit = () => {};
return (
<Form
render={({ form }) => {
formRef.current = form;
<>
<Form
render={({ form }) => {
formRef.current = form;
const documentStatus = formFields.getDocumentStatus();
const documentStatus = formFields.getDocumentStatus();
const canShowByAuthorities = canSend && Boolean(currentOrg);
const canShowByAuthorities = canSend && Boolean(currentOrg);
const isStatusOk = documentStatus ? isSignAndSendAllowedStatus(documentStatus) : true;
const isStatusOk = documentStatus ? isSignAndSendAllowedStatus(documentStatus) : true;
return (
<>
{showSuccessScreen && (
<YaMetrikaReachGoal
goalType={YM_GOALS.SCREEN_VIEW}
params={{ [YM_GOALS.SCREEN_VIEW]: { screen_name: PAYMENTS_YM_EVENTS.PAYMENT_SUCCESS } }}
/>
)}
<ScrollContainer autoHeight autoHeightMax="78vh">
<PageHeader.Element title={title} />
<PayeeFormFields formInput={formFields} formRef={formRef} />
<PayerFormFields formInput={formFields} formRef={formRef} orgHandleChange={orgHandleChange} />
</ScrollContainer>
&nbsp;
<S.ButtonsWrapper>
{canShowByAuthorities && isStatusOk && (
return (
<>
{showSuccessScreen && (
<YaMetrikaReachGoal
goalType={YM_GOALS.SCREEN_VIEW}
params={{ [YM_GOALS.SCREEN_VIEW]: { screen_name: PAYMENTS_YM_EVENTS.PAYMENT_SUCCESS } }}
/>
)}
<ScrollContainer autoHeight autoHeightMax="78vh">
<PageHeader.Element title={title} />
{formFields.isFromTemplate && (
<Input
disabled
label={LOCALIZATION.TEMPLATE.TITLE}
labelPosition="top"
name="template-name"
value={formFields.templateName}
onChange={() => {}}
/>
)}
<PayeeFormFields formInput={formFields} formRef={formRef} />
<PayerFormFields formInput={formFields} formRef={formRef} orgHandleChange={orgHandleChange} />
</ScrollContainer>
&nbsp;
<S.ButtonsWrapper>
{canShowByAuthorities && isStatusOk && (
<Button
dataAction="Sign and Send"
isLoading={formFields.isSigningAndSending}
size="M"
variant="primary"
onClick={() => {
handleReachGoal(YM_GOALS.BUTTON_CLICK, {
[YM_GOALS.BUTTON_CLICK]: { payment_create: { element_name: PAYMENTS_YM_EVENTS.PAYMENT_SIGN_AND_SEND } },
});
formFields.signAndSend();
}}
>
{LOCALIZATION.BUTTON.SIGN_AND_SEND}
</Button>
)}
<Button
dataAction="Sign and Send"
isLoading={formFields.isSigningAndSending}
dataAction="Save"
disabled={!currentOrg}
isLoading={formFields.isSaving}
size="M"
variant="primary"
variant="blue"
onClick={() => {
handleReachGoal(YM_GOALS.BUTTON_CLICK, {
[YM_GOALS.BUTTON_CLICK]: { payment_create: { element_name: PAYMENTS_YM_EVENTS.PAYMENT_SIGN_AND_SEND } },
[YM_GOALS.BUTTON_CLICK]: { payment_create: { element_name: PAYMENTS_YM_EVENTS.PAYMENT_SAVE } },
});
formFields.signAndSend();
formFields.save();
}}
>
{LOCALIZATION.BUTTON.SIGN_AND_SEND}
{LOCALIZATION.BUTTON.SAVE}
</Button>
)}
<Button
dataAction="Save"
disabled={!currentOrg}
isLoading={formFields.isSaving}
size="M"
variant="blue"
onClick={() => {
handleReachGoal(YM_GOALS.BUTTON_CLICK, {
[YM_GOALS.BUTTON_CLICK]: { payment_create: { element_name: PAYMENTS_YM_EVENTS.PAYMENT_SAVE } },
});
formFields.save();
}}
>
{LOCALIZATION.BUTTON.SAVE}
</Button>
</S.ButtonsWrapper>
&nbsp;
</>
);
}}
onSubmit={handleSubmit}
/>
</S.ButtonsWrapper>
&nbsp;
</>
);
}}
onSubmit={handleSubmit}
/>
<StatusModal
preventCloseOnOutside
actions={[
{
text: LOCALIZATION.TEMPLATE.ERROR.BUTTON,
variant: 'primary',
dataAction: 'close-warning',
onClick: handleCloseStatusModal,
},
]}
header={LOCALIZATION.TEMPLATE.ERROR.TITLE}
isOpen={showStatusModal}
size="M"
type="warning"
onClose={handleCloseStatusModal}
>
<Text.P2>{LOCALIZATION.TEMPLATE.ERROR.DESCRIPTION}</Text.P2>
</StatusModal>
</>
);
};
}
@@ -9,6 +9,7 @@ const lineClamp = 3;
const Container = styled.div`
display: grid;
cursor: pointer;
grid-template-columns: 40px 10fr 1fr 40px;
grid-template-rows: 1fr 1fr;
gap: 4px 16px;
@@ -1,38 +1,59 @@
import { type ReactElement } from 'react';
import { type ReactElement, useCallback } from 'react';
import { ButtonIcon } from '@fractal-ui/core';
import { EditIcon, Icon } from '@fractal-ui/library';
import { Text } from '@fractal-ui/styling';
import { type RublePaymentTemplate } from '@msb/http';
import { formatAccountNumber, getFormattedBalance, useInfiniteScroll } from '@msb/shared';
import { formatAccountNumber, getFormattedBalance, useHashedRedirect, useInfiniteScroll } from '@msb/shared';
import { LOCALIZATION } from '../constants';
import * as S from './TemplateCard.styles';
import { PATHS } from '@/shared/constants';
namespace TemplateCard {
type VisibilityHandler = () => Promise<any>;
export type TemplateSelection = (id: string) => void;
export interface Props {
record: RublePaymentTemplate.Record;
isEditDisabled: boolean;
visibilityHandler: VisibilityHandler | null;
selectionHandler?: TemplateSelection;
}
export const Element = ({ record, visibilityHandler }: Props): ReactElement => {
export const Element = ({ record, visibilityHandler, isEditDisabled, selectionHandler }: Props): ReactElement => {
const { observerTarget, isNextLoading } = useInfiniteScroll(visibilityHandler);
const navigation = useHashedRedirect(PATHS.PAYMENT_ORDER.PATH);
const onClickCallback = useCallback(
event => {
if (event.isDefaultPrevented()) {
return;
}
if (selectionHandler) {
selectionHandler(record.id);
} else {
navigation(`T${record.id}`);
}
},
[navigation, record.id, selectionHandler]
);
return (
<S.Container ref={visibilityHandler !== null && !isNextLoading ? observerTarget : null}>
<S.Container ref={visibilityHandler !== null && !isNextLoading ? observerTarget : null} onClick={onClickCallback}>
<S.IconWrapper>
<Icon name="DocEdit" size="L" />
</S.IconWrapper>
<S.ButtonWrapper>
<ButtonIcon
dataAction="other"
icon={EditIcon}
variant="ghost"
onClick={event => {
event.stopPropagation();
// TODO: - продолжение в следующем ПР!
console.log('EDIT!');
}}
/>
{!isEditDisabled && (
<ButtonIcon
dataAction="other"
icon={EditIcon}
variant="ghost"
onClick={event => {
event.stopPropagation();
// TODO: - продолжение в следующем ПР!
console.log('EDIT!');
}}
/>
)}
</S.ButtonWrapper>
<S.TitleWrapper>
<Text.P1>{record.templateName}</Text.P1>
@@ -8,20 +8,31 @@ import { LOCALIZATION } from '../constants';
import { TemplateCard } from './TemplateCard';
namespace TemplatesList {
export type TemplateSelection = (id: string) => void;
export interface Props {
isInfiniteScroll: boolean;
organizationId: string;
disableEdit?: boolean;
selectionHandler?: TemplateSelection;
}
const debounceLength = 0;
const debounceSeconds = 1000;
const skeletonNumber = 3;
const magicSkeletonHeight = 112;
const limitTemplateCount = 25;
export const Element = ({ isInfiniteScroll, organizationId }: Props): ReactElement => {
export const Element = ({ isInfiniteScroll, organizationId, disableEdit, selectionHandler }: Props): ReactElement => {
const [searchValue, setSearchValue] = useState('');
const debounceSearchField = useDebounce(searchValue.length > debounceLength ? searchValue : undefined, debounceSeconds);
const { data, isLoading, fetchNextPage } = usePaymentTemplate(organizationId, 0, 25, !isInfiniteScroll, debounceSearchField);
const { data, isLoading, fetchNextPage } = usePaymentTemplate(
organizationId,
0,
limitTemplateCount,
!isInfiniteScroll,
true,
debounceSearchField
);
const records = useMemo(
() =>
data && Array.isArray(data.pages)
@@ -32,6 +43,7 @@ namespace TemplatesList {
[data]
);
const visibilityHandler = isInfiniteScroll && !isLoading ? fetchNextPage : null;
const isEditDisabled = disableEdit ?? false;
if (isLoading && (searchValue.length > 0 || records.length === 0)) {
return (
@@ -60,7 +72,9 @@ namespace TemplatesList {
records.map((record, index) => (
<TemplateCard.Element
key={record.id}
isEditDisabled={isEditDisabled}
record={record}
selectionHandler={selectionHandler}
visibilityHandler={records.length - 1 === index ? visibilityHandler : null}
/>
))
@@ -18,6 +18,7 @@ import { PaymentOrderFormFields, PaymentOrderForm } from '@/features/PaymentOrde
import { PATHS } from '@/shared/constants';
import { PageHeader, PageLayout } from '@/shared/ui';
import { LOCALIZATION as PAYMENTS_LIST_LOCALIZATION } from '@/widgets/PaymentsListContent';
import { PaymentTemplateFill } from '@/widgets/PaymentTemplateFill';
const PaymentOrderPage = (): ReactElement => {
const { userAuthorities } = useAppContext();
@@ -38,9 +39,12 @@ const PaymentOrderPage = (): ReactElement => {
const systemResponseDescription = isPaymentsEnabled
? LOCALIZATION.CONTACT_OWNER_ORGANIZATION
: PAYMENTS_LIST_LOCALIZATION.ERROR_DESCRIPTION;
const organizationId = PaymentTemplateFill.useAllowWidgetShow()
? PaymentTemplateFill.getOrganizationId(userAuthorities)
: undefined;
return (
<PageLayoutWithSections aside={''}>
<PageLayoutWithSections aside={organizationId && <PaymentTemplateFill.Element organizationId={organizationId} />}>
<PageLayout>
{shouldShowSystemResponse ? (
<>
@@ -26,6 +26,7 @@ import {
BlockingType,
PATHS as GLOBAL_PATHS,
BlockingService,
Flex,
} from '@msb/shared';
import { LOCALIZATION, INFORMERS_LIST_ITEMS, allTab, signedTab, draftTab, rejectedTab, completedTab } from '../constants';
import { PaymentsListSkeleton } from './PaymentsListSkeleton';
@@ -195,10 +196,10 @@ const PaymentsMainPage = (): ReactElement => {
<PageLayoutWithSections
aside={
shouldShowBlockingScreen ? undefined : (
<>
<Flex column gap={6}>
<AsideInformerList informers={INFORMERS_LIST_ITEMS} />
{organizationId && <FrequentlyUsedTemplates.Element organizationId={organizationId} />}
</>
</Flex>
)
}
asideInMobile={!shouldShowBlockingScreen}
@@ -6,6 +6,7 @@ const PATHS = {
EDIT_OR_DETAILS: { PATH: PATHS_PAYMENTS.EDIT_OR_DETAILS, TITLE: '' },
TEMPLATES: { PATH: PATHS_PAYMENTS.TEMPLATES, TITLE: '' },
CREATE_TEMPLATE: { PATH: PATHS_PAYMENTS.CREATE_TEMPLATE, TITLE: '' },
EDIT_TEMPLATE: { PATH: PATHS_PAYMENTS.EDIT_TEMPLATE, TITLE: '' },
} as const;
export { PATHS };
@@ -3,7 +3,16 @@ import { Button, ButtonLink, Skeleton } from '@fractal-ui/core';
import { Icon } from '@fractal-ui/library';
import { Title, Text } from '@fractal-ui/styling';
import { type AuthoritiesResponseDto, FEATURE_TOGGLE_NAMES, usePaymentTemplate } from '@msb/http';
import { AUTHORITIES, checkOrganizationsHavePermission, Flex, range, useAppContext, useFeatureToggles, useRedirect } from '@msb/shared';
import {
AUTHORITIES,
checkOrganizationsHavePermission,
Flex,
range,
useAppContext,
useFeatureToggles,
useHashedRedirect,
useRedirect,
} from '@msb/shared';
import { LOCALIZATION } from '../constants';
import * as S from './FrequentlyUsedTemplates.styles';
import { PATHS } from '@/shared/constants';
@@ -33,11 +42,11 @@ namespace FrequentlyUsedTemplates {
const maxRecordsCount = 3;
export const Element = ({ organizationId }: Props): ReactElement => {
const { data, isLoading, isError, refetch } = usePaymentTemplate(organizationId, 0, 3, false);
const { data, isLoading, isError, refetch } = usePaymentTemplate(organizationId, 0, 3, true);
const records = useMemo(
() =>
data && Array.isArray(data.pages)
? data?.pages
? data.pages
.reduce((accumulator, item) => accumulator.concat(item), [])
.sort((a, b) => new Date(b.usedAt ?? 0).getTime() - new Date(a.usedAt ?? 0).getTime())
.filter((record, index) => index < maxRecordsCount)
@@ -46,6 +55,7 @@ namespace FrequentlyUsedTemplates {
);
const createTemplate = useRedirect(PATHS.CREATE_TEMPLATE.PATH);
const listTemplate = useRedirect(PATHS.TEMPLATES.PATH);
const createFromTemplate = useHashedRedirect(PATHS.PAYMENT_ORDER.PATH);
return (
<Flex
@@ -69,7 +79,14 @@ namespace FrequentlyUsedTemplates {
!isLoading &&
records.length > 0 &&
records.map(record => (
<ButtonLink key={record.id} align="left" dataAction="navigate-to-template">
<ButtonLink
key={record.id}
align="left"
dataAction="navigate-to-template"
onClick={() => {
createFromTemplate(`T${record.id}`);
}}
>
<S.Container>
<S.TitleWrapper>
<Text.P1>{record.templateName}</Text.P1>
@@ -0,0 +1 @@
export * from './localization';
@@ -0,0 +1,7 @@
const LOCALIZATION = {
TITLE: 'Заполнить из шаблона',
BUTTON: 'Все шаблоны',
MODAL_TITLE: 'Заполнить из шаблона',
};
export { LOCALIZATION };
@@ -0,0 +1 @@
export * from './ui';
@@ -0,0 +1,43 @@
import styled from '@emotion/styled';
const webkitBox = '-webkit-box';
const fontSizeTitle = 18; // '18px'
const lineHeightTitle = 1.33; // '24px'
const lineClamp = 2;
const Container = styled.div`
display: grid;
grid-template-columns: 2fr 20px;
grid-template-rows: 1fr;
gap: 4px 16px;
position: relative;
grid-template-areas: 'title icon';
`;
const IconWrapper = styled.div(() => ({
gridArea: 'icon',
display: 'flex',
alignItems: 'center',
justifyContent: 'right',
justifyItems: 'right',
width: '20px',
height: '20px',
verticalAlign: 'top',
}));
const TitleWrapper = styled.div(() => ({
gridArea: 'title',
'& div': {
display: webkitBox,
maxHeight: `${fontSizeTitle * lineHeightTitle * lineClamp}px`,
margin: '0 auto',
fontSize: fontSizeTitle,
lineHeight: lineHeightTitle,
'-webkit-line-clamp': String(lineClamp),
'-webkit-box-orient': 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}));
export { Container, IconWrapper, TitleWrapper };
@@ -0,0 +1,125 @@
import { type ReactElement, useCallback, useMemo, useState } from 'react';
import { Button, ButtonLink } from '@fractal-ui/core';
import { Icon } from '@fractal-ui/library';
import { Text, Title } from '@fractal-ui/styling';
import { type AuthoritiesResponseDto, FEATURE_TOGGLE_NAMES, usePaymentTemplate } from '@msb/http';
import { AUTHORITIES, checkOrganizationsHavePermission, Flex, useAppContext, useFeatureToggles, useHashedReplace } from '@msb/shared';
import { LOCALIZATION } from '../constants';
import * as S from './PaymentTemplateFill.styles';
import { PATHS } from '@/shared/constants';
import { Modal } from '@fractal-ui/overlays';
import { TemplatesList } from '@/features/TemplatesList';
namespace PaymentTemplateFill {
export interface Props {
organizationId: string;
}
export const getOrganizationId = (userAuthorities?: AuthoritiesResponseDto): string | undefined => {
const organizations = checkOrganizationsHavePermission(userAuthorities?.data.clientAuthorities || {}, [
AUTHORITIES.PAYMENT.TEMPLATE_VIEW,
]);
return organizations.length > 0 ? organizations[0] : undefined;
};
export const useAllowWidgetShow = (): boolean => {
const { userAuthorities } = useAppContext();
const { isEnabled: isTemplatesEnabled } = useFeatureToggles(FEATURE_TOGGLE_NAMES.PAYMENT_TEMPLATES);
const organizationId = useMemo(() => getOrganizationId(userAuthorities), [userAuthorities]);
return isTemplatesEnabled && organizationId !== undefined && organizationId.length > 0;
};
const maxVisibleRecordsCount = 3;
const maxRecordsCount = 4;
export const Element = ({ organizationId }: Props): ReactElement => {
const { data, isLoading, isError } = usePaymentTemplate(organizationId, 0, maxRecordsCount, true);
const [records, isButtonVisible] = useMemo(() => {
const totalRecords =
data && Array.isArray(data.pages)
? data.pages
.reduce((accumulator, item) => accumulator.concat(item), [])
.sort((a, b) => new Date(b.usedAt ?? 0).getTime() - new Date(a.usedAt ?? 0).getTime())
: [];
const visibleRecords = totalRecords.filter((record, index) => index < maxVisibleRecordsCount);
return [visibleRecords, totalRecords.length > maxVisibleRecordsCount];
}, [data]);
const fillFromTemplate = useHashedReplace(PATHS.PAYMENT_ORDER.PATH);
const [isTemplatesOpen, setIsTemplatesOpen] = useState(false);
const modalSelectionHandler = useCallback(
(id: string) => {
setIsTemplatesOpen(false);
fillFromTemplate(`T${id}`);
},
[fillFromTemplate]
);
if ((records.length === 0 && !isLoading) || isError || isLoading) {
return <Flex column />;
}
return (
<Flex
column
backgroundColor="bg.primary"
borderRadius="16px"
boxShadow="0 0 16px 0 rgba(78, 88, 134, 0.04)"
gap={4}
padding="4"
width="100%"
>
<Title.H4>{LOCALIZATION.TITLE}</Title.H4>
{records.map(record => (
<ButtonLink
key={record.id}
align="left"
dataAction="navigate-to-fill-template"
onClick={() => {
fillFromTemplate(`T${record.id}`);
}}
>
<S.Container>
<S.TitleWrapper>
<Text.P1>{record.templateName}</Text.P1>
</S.TitleWrapper>
{!record.accountExists && (
<S.IconWrapper>
<Icon color="text.warning" name="AttentionFilled" />
</S.IconWrapper>
)}
</S.Container>
</ButtonLink>
))}
{isButtonVisible && (
<Button
dataAction="select-template"
shape="default"
size="M"
variant="secondary"
onClick={() => {
setIsTemplatesOpen(true);
}}
>
{LOCALIZATION.BUTTON}
</Button>
)}
<Modal
preventCloseOnOutside
header={LOCALIZATION.MODAL_TITLE}
isOpen={isTemplatesOpen}
size="M"
onClose={() => {
setIsTemplatesOpen(false);
}}
>
<TemplatesList.Element disableEdit isInfiniteScroll organizationId={organizationId} selectionHandler={modalSelectionHandler} />
</Modal>
</Flex>
);
};
}
export { PaymentTemplateFill };
@@ -0,0 +1 @@
export * from './PaymentTemplateFill';
@@ -1,2 +1,4 @@
export * from './AsideInformerList';
export * from './PaymentsListContent';
export * from './FrequentlyUsedTemplates';
export * from './PaymentTemplateFill';