уведомления отправляются
This commit is contained in:
@@ -37,6 +37,10 @@
|
|||||||
{
|
{
|
||||||
"resource": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
"resource": "api://9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
"scope": "user_impersonation"
|
"scope": "user_impersonation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resource": "Microsoft Flow Service",
|
||||||
|
"scope": "Flows.Read.All"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+20
-1
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import tableStyles from '../ui/DataTable.module.scss';
|
import tableStyles from '../ui/DataTable.module.scss';
|
||||||
import styles from './ManageRequestsScreen.module.scss';
|
import styles from './ManageRequestsScreen.module.scss';
|
||||||
import { Employee, ManagedApprovalRow, isEditableRowStatus, isMovableRowStatus } from '../../models';
|
import { Employee, ManagedApprovalRow, REQUEST_STATUS, isEditableRowStatus, isMovableRowStatus } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { toEmployeeOption } from '../../utils/employee';
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||||
@@ -27,6 +27,9 @@ export interface IManageRequestsPhaseTableProps {
|
|||||||
statusVariant: (status: string) => BadgeVariant;
|
statusVariant: (status: string) => BadgeVariant;
|
||||||
onReassign: (row: ManagedApprovalRow) => void;
|
onReassign: (row: ManagedApprovalRow) => void;
|
||||||
onRemove: (row: ManagedApprovalRow) => void;
|
onRemove: (row: ManagedApprovalRow) => void;
|
||||||
|
onNotify: (row: ManagedApprovalRow) => void;
|
||||||
|
/** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */
|
||||||
|
notifyingRowId?: number;
|
||||||
employees: Employee[];
|
employees: Employee[];
|
||||||
employeesLoading: boolean;
|
employeesLoading: boolean;
|
||||||
/** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */
|
/** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */
|
||||||
@@ -77,6 +80,8 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
statusVariant,
|
statusVariant,
|
||||||
onReassign,
|
onReassign,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onNotify,
|
||||||
|
notifyingRowId,
|
||||||
employees,
|
employees,
|
||||||
employeesLoading,
|
employeesLoading,
|
||||||
addPlaceholder,
|
addPlaceholder,
|
||||||
@@ -186,6 +191,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
const isBlockStart = block.start === i;
|
const isBlockStart = block.start === i;
|
||||||
const movable = blockIndex > lastLockedBlockIndex;
|
const movable = blockIndex > lastLockedBlockIndex;
|
||||||
const rowEditable = isEditableRowStatus(r.status);
|
const rowEditable = isEditableRowStatus(r.status);
|
||||||
|
// Уведомлять можно только активный шаг (флоу шлёт письмо тому,
|
||||||
|
// чей RowID мы передали, независимо от статуса — сам он не
|
||||||
|
// проверяет очередь). «Ждёт очереди» — шаг ещё не наступил,
|
||||||
|
// слать по нему нечего: согласующий/исполнитель пока не
|
||||||
|
// должен действовать (см. request-portal/notify.service.ts,
|
||||||
|
// где по той же причине уведомляется только активный шаг).
|
||||||
|
const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution;
|
||||||
// Переключение параллельности — тоже перестановка (меняет order
|
// Переключение параллельности — тоже перестановка (меняет order
|
||||||
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
// относительно соседней строки), поэтому смотрим на isMovableRowStatus,
|
||||||
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
// а не на rowEditable: у «Возвращена на доработку» кнопка
|
||||||
@@ -261,6 +273,13 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
disabled={!canToggle}
|
disabled={!canToggle}
|
||||||
onClick={() => toggleParallel(i)}
|
onClick={() => toggleParallel(i)}
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="mail"
|
||||||
|
className={styles.iconBtnVisible}
|
||||||
|
label={notifiable ? texts.manageRequests.notify : texts.manageRequests.notifyNotActive}
|
||||||
|
disabled={!notifiable || notifyingRowId === r.id}
|
||||||
|
onClick={() => onNotify(r)}
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="edit"
|
icon="edit"
|
||||||
className={styles.iconBtnVisible}
|
className={styles.iconBtnVisible}
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
|
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
|
||||||
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
|
||||||
const [saveError, setSaveError] = React.useState<string | undefined>();
|
const [saveError, setSaveError] = React.useState<string | undefined>();
|
||||||
|
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
|
||||||
|
const [notifyingRowId, setNotifyingRowId] = React.useState<number | undefined>();
|
||||||
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
// Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера
|
||||||
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
// (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда
|
||||||
// положительные, поэтому пересечься эти диапазоны не могут.
|
// положительные, поэтому пересечься эти диапазоны не могут.
|
||||||
@@ -459,6 +461,19 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Отправка уведомления — по явному клику (в отличие от request-portal, где
|
||||||
|
// это происходит автоматически после решения), поэтому здесь показываем
|
||||||
|
// пользователю результат, а не глушим ошибку молча.
|
||||||
|
const handleNotify = (row: ManagedApprovalRow): void => {
|
||||||
|
if (!matrix || !requestType) return;
|
||||||
|
setNotifyingRowId(row.id);
|
||||||
|
requestManage
|
||||||
|
.notifyRow(requestType.id, matrix, row.id)
|
||||||
|
.then(() => setNotifyResult({ message: texts.manageRequests.notifySuccess, kind: 'ok' }))
|
||||||
|
.catch((e: Error) => setNotifyResult({ message: reportError(e, 'handleNotify: notifyRow'), kind: 'err' }))
|
||||||
|
.finally(() => setNotifyingRowId(undefined));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
|
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.manageRequests.breadcrumb }]} />
|
||||||
@@ -522,6 +537,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
onReassign={openReassign}
|
onReassign={openReassign}
|
||||||
onRemove={askDelete}
|
onRemove={askDelete}
|
||||||
|
onNotify={handleNotify}
|
||||||
|
notifyingRowId={notifyingRowId}
|
||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
|
addPlaceholder={texts.manageRequests.addApproverPlaceholder}
|
||||||
@@ -536,6 +553,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
onReassign={openReassign}
|
onReassign={openReassign}
|
||||||
onRemove={askDelete}
|
onRemove={askDelete}
|
||||||
|
onNotify={handleNotify}
|
||||||
|
notifyingRowId={notifyingRowId}
|
||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
|
addPlaceholder={texts.manageRequests.addExecutorPlaceholder}
|
||||||
@@ -571,6 +590,14 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
{saveError && (
|
{saveError && (
|
||||||
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
<Toast message={`${texts.manageRequests.saveError}: ${saveError}`} onClose={() => setSaveError(undefined)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{notifyResult && (
|
||||||
|
<Toast
|
||||||
|
message={notifyResult.kind === 'ok' ? notifyResult.message : `${texts.manageRequests.notifyError}: ${notifyResult.message}`}
|
||||||
|
kind={notifyResult.kind}
|
||||||
|
onClose={() => setNotifyResult(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,12 @@
|
|||||||
animation: toastIn .15s ease-out;
|
animation: toastIn .15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
background: $green-50;
|
||||||
|
color: $green-600;
|
||||||
|
border-color: #BBF7D0;
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,30 @@ import { Icon } from './Icon';
|
|||||||
|
|
||||||
export interface IToastProps {
|
export interface IToastProps {
|
||||||
message: string;
|
message: string;
|
||||||
|
/** 'err' (по умолчанию) — красный, для ошибок; 'ok' — зелёный, для успешных действий (см. handleNotify в ManageRequestsScreen). */
|
||||||
|
kind?: 'err' | 'ok';
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_HIDE_MS = 6000;
|
const AUTO_HIDE_MS = 6000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Всплывающее сообщение об ошибке в углу экрана — для действий, которые не
|
* Всплывающее сообщение в углу экрана — для действий, которые не ломают уже
|
||||||
* ломают уже отрисованный экран (переназначение/перестановка/добавление шага):
|
* отрисованный экран (переназначение/перестановка/добавление шага/отправка
|
||||||
* пользователь и так видит, что изменение не применилось (см. reloadMatrix
|
* уведомления): пользователь и так видит, что изменение не применилось (см.
|
||||||
* после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху
|
* reloadMatrix после ошибки в ManageRequestsScreen), поэтому постоянный
|
||||||
* страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо
|
* Alert-блок сверху страницы был бы избыточен. Автоматически прячется через
|
||||||
* закрывается вручную.
|
* AUTO_HIDE_MS, либо закрывается вручную.
|
||||||
*/
|
*/
|
||||||
export const Toast: React.FC<IToastProps> = ({ message, onClose }) => {
|
export const Toast: React.FC<IToastProps> = ({ message, kind = 'err', onClose }) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [message, onClose]);
|
}, [message, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.toast} role="alert">
|
<div className={`${styles.toast} ${kind === 'ok' ? styles.ok : ''}`} role="alert">
|
||||||
<Icon name="warning" size="sm" />
|
<Icon name={kind === 'ok' ? 'check' : 'warning'} size="sm" />
|
||||||
<span className={styles.message}>{message}</span>
|
<span className={styles.message}>{message}</span>
|
||||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
|
<button type="button" className={styles.close} onClick={onClose} aria-label="Закрыть">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ export interface ManagedRequestMatrix {
|
|||||||
request: ManagedRequestSummary;
|
request: ManagedRequestSummary;
|
||||||
/** Абсолютный URL сайта, где лежат списки данных/согласования этого типа заявки. */
|
/** Абсолютный URL сайта, где лежат списки данных/согласования этого типа заявки. */
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
|
/** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */
|
||||||
|
dataListTitle: string;
|
||||||
/** Title списка «… — согласование» (вычислен из Title списка данных). */
|
/** Title списка «… — согласование» (вычислен из Title списка данных). */
|
||||||
approvalListTitle: string;
|
approvalListTitle: string;
|
||||||
/** Обе фазы вместе, упорядочены по StepOrder. */
|
/** Обе фазы вместе, упорядочены по StepOrder. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '@pnp/sp/lists';
|
|||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import '@pnp/sp/site-users/web';
|
import '@pnp/sp/site-users/web';
|
||||||
import '@pnp/sp/search';
|
import '@pnp/sp/search';
|
||||||
|
import { AadHttpClient } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import {
|
import {
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
@@ -20,6 +21,15 @@ import {
|
|||||||
// списки SharePoint, только с другого клиента).
|
// списки SharePoint, только с другого клиента).
|
||||||
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
|
||||||
|
|
||||||
|
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
|
||||||
|
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
|
||||||
|
// согласования пишут оба веб-парта, поток сам находит по RowID согласующего,
|
||||||
|
// статус и заявку и уведомляет письмом. Авторизация — AadHttpClient с
|
||||||
|
// ресурсом Flow Service (обычный fetch → 401 из-за Entra-триггера).
|
||||||
|
const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
|
||||||
|
const FLOW_NOTIFY_URL =
|
||||||
|
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
|
||||||
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
|
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
|
||||||
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
|
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
|
||||||
@@ -102,10 +112,12 @@ export class RequestManageService {
|
|||||||
// Title списка данных → Title списка согласования. undefined, если Title не
|
// Title списка данных → Title списка согласования. undefined, если Title не
|
||||||
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
|
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
|
||||||
// управлять маршрутом для него отсюда нельзя).
|
// управлять маршрутом для него отсюда нельзя).
|
||||||
private async approvalListTitle(sp: SPFI, listId: string): Promise<string | undefined> {
|
private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
|
||||||
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
|
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
|
||||||
if (!DATA_LIST_SUFFIX.test(info.Title)) return undefined;
|
const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
|
||||||
return `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`;
|
? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
|
||||||
|
: undefined;
|
||||||
|
return { dataListTitle: info.Title, approvalListTitle };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,16 +180,16 @@ export class RequestManageService {
|
|||||||
const sp = this.clientFor(requestType.siteUrl);
|
const sp = this.clientFor(requestType.siteUrl);
|
||||||
const dataList = sp.web.getList(requestType.listId);
|
const dataList = sp.web.getList(requestType.listId);
|
||||||
|
|
||||||
const [requestRow, approvalListTitle] = await Promise.all([
|
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
|
||||||
dataList.items
|
dataList.items
|
||||||
.getById(requestId)
|
.getById(requestId)
|
||||||
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
.select('Id', 'Title', 'Status', 'Created', 'Author/Title')
|
||||||
.expand('Author')() as Promise<IRawDataRow>,
|
.expand('Author')() as Promise<IRawDataRow>,
|
||||||
this.approvalListTitle(sp, requestType.listId)
|
this.listTitles(sp, requestType.listId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!approvalListTitle) {
|
if (!approvalListTitle) {
|
||||||
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle: '', rows: [] };
|
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle: '', rows: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
|
||||||
@@ -217,7 +229,7 @@ export class RequestManageService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle, rows };
|
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
|
||||||
@@ -305,4 +317,33 @@ export class RequestManageService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
|
||||||
|
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
|
||||||
|
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
|
||||||
|
* письмо. В отличие от request-portal (там вызывается автоматически после
|
||||||
|
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
|
||||||
|
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
|
||||||
|
*/
|
||||||
|
public async notifyRow(
|
||||||
|
requestTypeId: number,
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'request' | 'dataListTitle' | 'approvalListTitle'>,
|
||||||
|
rowId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||||
|
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
RequestTypeId: requestTypeId,
|
||||||
|
RowID: rowId,
|
||||||
|
RequestID: matrix.request.id,
|
||||||
|
ListName: matrix.dataListTitle,
|
||||||
|
ApproveListName: matrix.approvalListTitle
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ export const texts = {
|
|||||||
emptyMatrix: 'У этой заявки маршрут ещё не записан.',
|
emptyMatrix: 'У этой заявки маршрут ещё не записан.',
|
||||||
reassign: 'Переназначить',
|
reassign: 'Переназначить',
|
||||||
remove: 'Удалить шаг',
|
remove: 'Удалить шаг',
|
||||||
|
notify: 'Отправить уведомление',
|
||||||
|
notifyNotActive: 'Уведомить можно только текущий активный шаг (не «Ждёт очереди»)',
|
||||||
|
notifySuccess: 'Уведомление отправлено',
|
||||||
|
notifyError: 'Не удалось отправить уведомление',
|
||||||
addApproverPlaceholder: 'Добавить согласующего: начните вводить ФИО…',
|
addApproverPlaceholder: 'Добавить согласующего: начните вводить ФИО…',
|
||||||
addExecutorPlaceholder: 'Добавить исполнителя: начните вводить ФИО…',
|
addExecutorPlaceholder: 'Добавить исполнителя: начните вводить ФИО…',
|
||||||
lockedHint: 'Решение уже принято — недоступно для правки.',
|
lockedHint: 'Решение уже принято — недоступно для правки.',
|
||||||
|
|||||||
Reference in New Issue
Block a user