Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
@@ -17,7 +17,7 @@ export interface IApproverOrgInfo {
|
||||
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
|
||||
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
|
||||
*/
|
||||
export function resolveApproverOrgInfo(context: WebPartContext) {
|
||||
export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise<IApproverOrgInfo> {
|
||||
return async (email: string): Promise<IApproverOrgInfo> => {
|
||||
const records = await getEmployeeOrganizations(context, email);
|
||||
const first = records[0];
|
||||
|
||||
@@ -63,13 +63,21 @@ export { notifyActiveStep } from './notify.service';
|
||||
export type { INotifyTarget } from './notify.service';
|
||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export {
|
||||
approvalPhaseRows,
|
||||
executionPhaseRows,
|
||||
executorsToWrite,
|
||||
useFormExecutors,
|
||||
useViewExecutors
|
||||
} from './useRouteExecutors';
|
||||
export { usePhasedRoute } from './usePhasedRoute';
|
||||
export type { IPhasedRoute } from './usePhasedRoute';
|
||||
export { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||
export { canEditRoute } from './routeEditRights';
|
||||
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
||||
export { canEditAllFields } from './fullEditRights';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||
export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed';
|
||||
export { HISTORY_TEXT } from './history/historyTexts';
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
REQUEST_STATUS,
|
||||
REQUEST_STATUS_CHOICES,
|
||||
initialSubmitStatus,
|
||||
completesApprovalPhase,
|
||||
isEditableStatus,
|
||||
isSubmittedStatus,
|
||||
} from './statuses';
|
||||
@@ -54,6 +55,32 @@ describe('initialSubmitStatus', () => {
|
||||
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
|
||||
// маршрута получают недопустимый статус, а активации не происходит вовсе —
|
||||
// именно так заявка без согласующих перестала попадать в очередь исполнения.
|
||||
describe('completesApprovalPhase', () => {
|
||||
const row = (status: string, phase: string = APPROVAL_PHASE.approval): { status: string; phase: string } => ({ status, phase });
|
||||
|
||||
it('осталась одна несогласованная строка — это решение завершит согласование', () => {
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.approved), row(REQUEST_STATUS.onApproval)])).toBe(true);
|
||||
});
|
||||
|
||||
// Исполнитель в маршруте не мешает: его строка фазы «Исполнение».
|
||||
it('строки исполнителей не считаются', () => {
|
||||
expect(
|
||||
completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди', APPROVAL_PHASE.execution)])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('впереди ещё согласующие (в том числе параллельные) — не завершит', () => {
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди')])).toBe(false);
|
||||
});
|
||||
|
||||
// Решение исполнителя не должно второй раз запускать то, что делается по итогам согласования.
|
||||
it('заявка уже не на согласовании (исполнение, черновик) — нет', () => {
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.execution, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||
expect(completesApprovalPhase(undefined, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSubmittedStatus', () => {
|
||||
it('любой результат initialSubmitStatus считается отправкой', () => {
|
||||
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
|
||||
|
||||
@@ -152,3 +152,21 @@ export const APPROVAL_PHASE_CHOICES: string[] = [
|
||||
APPROVAL_PHASE.approval,
|
||||
APPROVAL_PHASE.execution,
|
||||
];
|
||||
|
||||
/**
|
||||
* Решение «Согласовать» сейчас завершит фазу СОГЛАСОВАНИЯ: заявка «На согласовании», и среди строк
|
||||
* согласования (не исполнения) несогласованной осталась одна — та, по которой решает текущий
|
||||
* согласующий. Проверяется ДО решения: то, что должно случиться по итогам согласования (например,
|
||||
* отправка заявления в ЭДО), можно сделать заранее и при неудаче не применять решение вовсе.
|
||||
*
|
||||
* Параллельные согласующие (одинаковый Order) учтены: пока кто-то из них не согласовал, строк
|
||||
* остаётся больше одной. Исполнители не считаются — их строки фазы «Исполнение».
|
||||
*/
|
||||
export function completesApprovalPhase(
|
||||
currentStatus: string | undefined,
|
||||
rows: ReadonlyArray<{ status: string; phase?: string }>
|
||||
): boolean {
|
||||
if (currentStatus !== REQUEST_STATUS.onApproval) return false;
|
||||
const pending = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution && r.status !== REQUEST_STATUS.approved);
|
||||
return pending.length === 1;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { effectiveUserEmail } from '../impersonation';
|
||||
|
||||
/**
|
||||
* Кто может удалять согласующих из маршрута (и при составлении заявки, и на
|
||||
* своём шаге согласования) — сверх обычных прав инициатора/согласующего.
|
||||
@@ -12,10 +15,33 @@
|
||||
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
||||
*/
|
||||
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
|
||||
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
|
||||
'gorbenko.pavel@tnsu.kz' // Павел Горбенко — составляет свой маршрут при подаче (обкатка форм)
|
||||
]);
|
||||
|
||||
/** Может ли этот email удалять согласующих из маршрута (см. описание выше). */
|
||||
export function canEditRoute(email: string | undefined): boolean {
|
||||
return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило для ApprovalMatrix.canRemoveRow при СОСТАВЛЕНИИ заявки: редактор маршрута удаляет
|
||||
* любого согласующего, остальным кнопки удаления нет. Удаление тут чисто локальное — при
|
||||
* отправке маршрут целиком пишется из строк матрицы, поэтому записи на сервер не нужно.
|
||||
* В поданной заявке так нельзя: там своя реализация (см. RecruitingHireView).
|
||||
*/
|
||||
export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined {
|
||||
return canEditRoute(email) ? () => true : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Может ли текущий пользователь править состав ИСПОЛНИТЕЛЕЙ (добавлять и удалять)
|
||||
* при составлении заявки: те же редакторы маршрута, что и для согласующих.
|
||||
* Исполнителей по умолчанию назначает маршрут из админки, заявителю они только
|
||||
* показываются; редактору маршрута матрица «Исполнение» становится редактируемой.
|
||||
* Учитывает подмену личности (effectiveUserEmail). readOnly — заявка уже не
|
||||
* редактируется (отправлена), тогда править нельзя никому.
|
||||
*/
|
||||
export function canEditExecutors(context: WebPartContext, readOnly: boolean): boolean {
|
||||
return !readOnly && canEditRoute(effectiveUserEmail(context));
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ interface IDecisionPanelProps {
|
||||
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
|
||||
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
|
||||
* обработчик (у него для этого есть своя область экрана).
|
||||
*
|
||||
* Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…»
|
||||
* (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно
|
||||
* не удалось — не возвращайте ничего, сообщение останется прежним.
|
||||
*/
|
||||
onDecided?: (status: string) => void | Promise<void>;
|
||||
onDecided?: (status: string) => void | string | undefined | Promise<void | string | undefined>;
|
||||
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
|
||||
* согласующим). Если бросит — решение не применяется, показывается ошибка.
|
||||
* decision — какое решение применяется (проверки, нужные только при
|
||||
@@ -66,6 +70,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
|
||||
// Что обработчик onDecided велел дописать к сообщению об успехе.
|
||||
const [resultNote, setResultNote] = useState<string | undefined>(undefined);
|
||||
const [delegated, setDelegated] = useState<number>(0);
|
||||
const [showDelegate, setShowDelegate] = useState<boolean>(false);
|
||||
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
|
||||
@@ -119,13 +125,16 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
// отработал, кнопки остаются заблокированными (submitting), и пользователь не
|
||||
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
|
||||
// влияет — оно уже применено, поэтому сообщение показываем всё равно.
|
||||
let note: string | undefined;
|
||||
if (onDecided) {
|
||||
try {
|
||||
await onDecided(status);
|
||||
const returned = await onDecided(status);
|
||||
note = typeof returned === 'string' && returned ? returned : undefined;
|
||||
} catch (e) {
|
||||
console.error('Побочное действие после решения не выполнилось', e);
|
||||
}
|
||||
}
|
||||
setResultNote(note);
|
||||
setResultStatus(status);
|
||||
setSubmitting(false);
|
||||
} catch (e) {
|
||||
@@ -205,7 +214,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
|
||||
<div className={styles.panel}>
|
||||
<Alert variant="ok">
|
||||
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
|
||||
{resultStatus}».
|
||||
{resultStatus}».{resultNote ? ` ${resultNote}` : ''}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ApprovalMatrix } from './ApprovalMatrix';
|
||||
|
||||
interface IExecutorsMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
/** Заголовок; по умолчанию «Исполнение». */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица исполнителей — рисуется только когда в маршруте из админки они есть:
|
||||
* у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors).
|
||||
*/
|
||||
export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => {
|
||||
// Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить.
|
||||
if (editor.loading || (editor.rows.length === 0 && !editor.editable)) return null;
|
||||
return (
|
||||
<ApprovalMatrix
|
||||
editor={editor}
|
||||
search={search}
|
||||
title={title}
|
||||
roleLabel="Исполнитель"
|
||||
canRemoveRow={editor.editable ? () => true : undefined}
|
||||
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
|
||||
emptyText="Исполнители не назначены."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ type MatrixEditor = ReturnType<typeof useMatrixEditor>;
|
||||
export interface IPhasedRoute {
|
||||
/** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */
|
||||
approvers: MatrixEditor;
|
||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение. */
|
||||
/** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */
|
||||
executors: MatrixEditor;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ interface IRouteSource {
|
||||
requestTypeId: number | undefined;
|
||||
/** Можно ли править согласующих. */
|
||||
editable: boolean;
|
||||
/** Можно ли править исполнителей (только редактору маршрута, см. canEditExecutors). */
|
||||
executorsEditable?: boolean;
|
||||
/** Меняется после решения согласующего — перечитать строки. */
|
||||
reloadSeq?: number;
|
||||
}
|
||||
@@ -42,7 +44,7 @@ const byPhase =
|
||||
* approval-route, форма его только получает.
|
||||
*/
|
||||
export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
|
||||
const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source;
|
||||
const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source;
|
||||
const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId);
|
||||
const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`;
|
||||
|
||||
@@ -64,6 +66,6 @@ export function usePhasedRoute(source: IRouteSource): IPhasedRoute {
|
||||
const jobTitle = (login: string): Promise<string> => approval.getJobTitle(login);
|
||||
|
||||
const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
const executors = useMatrixEditor(loadExecutors, reloadKey, executorsEditable, canLoad, jobTitle, resolveApproverOrgInfo(context));
|
||||
return { approvers, executors };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useCallback } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { getPlannedMatrix } from './approvalSubmission.service';
|
||||
import { resolveApproverOrgInfo } from './approverOrgInfo';
|
||||
import { IResolveFieldValue } from './models/IApprovalRouteApi';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { APPROVAL_PHASE } from './models/statuses';
|
||||
import { canEditExecutors } from './routeEditRights';
|
||||
import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor';
|
||||
|
||||
/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */
|
||||
export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
|
||||
|
||||
/** Строки фазы исполнения — для таблицы исполнителей. */
|
||||
export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] =>
|
||||
rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
|
||||
|
||||
/**
|
||||
* Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага
|
||||
* нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со
|
||||
* смещением на согласующих), а writeRoute смещает фазу сам.
|
||||
*/
|
||||
export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => {
|
||||
const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER);
|
||||
return rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order - base + 1,
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
};
|
||||
|
||||
interface IFormExecutorsSource {
|
||||
context: WebPartContext;
|
||||
approval: ApprovalListService;
|
||||
requestTypeId: number | undefined;
|
||||
/** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */
|
||||
userEmail: string;
|
||||
/** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */
|
||||
editId: number | undefined;
|
||||
/** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */
|
||||
departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||
fieldValues?: IResolveFieldValue[];
|
||||
projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute
|
||||
/** Заявка не редактируется — правка состава исполнителей закрыта для всех. */
|
||||
readOnly?: boolean;
|
||||
/** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */
|
||||
reloadSuffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из
|
||||
* админки для новой заявки, из записанных строк — для черновика/доработки.
|
||||
* Заявителю — только чтение: исполнителя назначает маршрут. Редактор маршрута
|
||||
* (см. canEditExecutors) может добавлять и удалять исполнителей.
|
||||
*/
|
||||
export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor {
|
||||
const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '', readOnly = false } = source;
|
||||
const fieldValuesKey = JSON.stringify(fieldValues ?? []);
|
||||
const load = useCallback(
|
||||
() =>
|
||||
editId
|
||||
? approval.getMatrixForRequest(editId).then(executionPhaseRows)
|
||||
: getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId),
|
||||
// fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId]
|
||||
);
|
||||
return useMatrixEditor(
|
||||
load,
|
||||
`x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`,
|
||||
canEditExecutors(context, readOnly),
|
||||
!!editId || (!!userEmail && !!requestTypeId),
|
||||
(login) => approval.getJobTitle(login),
|
||||
resolveApproverOrgInfo(context)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в
|
||||
* ключе: после решения/замены строки на сервере меняются — перечитываем.
|
||||
*/
|
||||
export function useViewExecutors(
|
||||
context: WebPartContext,
|
||||
approval: ApprovalListService,
|
||||
itemId: number,
|
||||
decisionSeq: number
|
||||
): IUseMatrixEditor {
|
||||
const load = useCallback(
|
||||
() => approval.getMatrixForRequest(itemId).then(executionPhaseRows),
|
||||
[approval, itemId]
|
||||
);
|
||||
return useMatrixEditor(
|
||||
load,
|
||||
`e-${itemId}-${decisionSeq}`,
|
||||
false,
|
||||
true,
|
||||
(l) => approval.getJobTitle(l),
|
||||
resolveApproverOrgInfo(context)
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,33 @@ export function getApprovalApiBaseUrl(): string {
|
||||
return _approvalApiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
||||
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
||||
* (Auth:Entra:Audience). Если ЭДО переедет на своё приложение — поменять только эту константу
|
||||
* и объявить доступ в package-solution.json (webApiPermissionRequests).
|
||||
*/
|
||||
export const EDO_API_CLIENT_ID = APPROVAL_API_CLIENT_ID;
|
||||
|
||||
/** Локальный бэкенд ЭДО — порт из launchSettings.json (https). */
|
||||
export const EDO_API_BASE_URL_LOCAL = 'https://localhost:7016';
|
||||
/** Стейджинг ЭДО. Сертификат самоподписанный: браузер должен ему доверять, иначе запрос не уйдёт. */
|
||||
export const EDO_API_BASE_URL_STAGE = 'https://10.0.1.225:8443';
|
||||
|
||||
// Настраивается свойством веб-части edoApiBaseUrl (селект в панели свойств) — тот же
|
||||
// модульный синглтон, что и approvalApiBaseUrl выше.
|
||||
let _edoApiBaseUrl = EDO_API_BASE_URL_STAGE;
|
||||
|
||||
export function setEdoApiBaseUrl(url: string): void {
|
||||
// Хвостовые слэши отбрасываем: адрес склеивается с путём «/api/...», а в панели
|
||||
// свойств его могли вписать с «/» на конце.
|
||||
_edoApiBaseUrl = (url || EDO_API_BASE_URL_STAGE).trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function getEdoApiBaseUrl(): string {
|
||||
return _edoApiBaseUrl;
|
||||
}
|
||||
|
||||
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
||||
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
||||
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
// GET /Dictionary/Companies — справочник компаний, ответ — плоский массив
|
||||
// { Id, Name } (без обёртки, как /Dictionary/Departments).
|
||||
// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments).
|
||||
async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const response: HttpClientResponse = await client.get(
|
||||
@@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||
// промис повторным вызовам (как в departments.service).
|
||||
/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */
|
||||
function browserStorage(): IStorageLike | undefined {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
const storage = browserStorage();
|
||||
const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined;
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetchCompanies(context);
|
||||
if (storage) writeCachedCompanies(storage, fresh, Date.now());
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы
|
||||
// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не
|
||||
// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов
|
||||
// попробует ещё раз.
|
||||
let cache: Promise<ICompany[]> | undefined;
|
||||
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
|
||||
if (!cache) {
|
||||
cache = fetchCompanies(context).catch((e) => {
|
||||
cache = loadCompanies(context).catch((e) => {
|
||||
cache = undefined;
|
||||
throw e;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
COMPANIES_CACHE_KEY,
|
||||
COMPANIES_CACHE_TTL_MS,
|
||||
IStorageLike,
|
||||
readCachedCompanies,
|
||||
writeCachedCompanies
|
||||
} from './companiesCache';
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
class MemoryStorage implements IStorageLike {
|
||||
public readonly data = new Map<string, string>();
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
public getItem(key: string): string | null {
|
||||
return this.data.has(key) ? (this.data.get(key) as string) : null;
|
||||
}
|
||||
public setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
public removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANIES: ICompany[] = [
|
||||
{ Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' }
|
||||
];
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
describe('кэш справочника компаний', () => {
|
||||
it('в пустом хранилище кэша нет', () => {
|
||||
expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('записанное читается обратно, пока не прошло 4 часа', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES);
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES);
|
||||
});
|
||||
|
||||
it('через 4 часа кэш устаревает и удаляется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined();
|
||||
expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
});
|
||||
|
||||
it('срок — ровно 4 часа', () => {
|
||||
expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000);
|
||||
});
|
||||
|
||||
it('запись из будущего (часы переведены назад) не доверяется', () => {
|
||||
const storage = new MemoryStorage();
|
||||
writeCachedCompanies(storage, COMPANIES, NOW + 60_000);
|
||||
|
||||
expect(readCachedCompanies(storage, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('битый JSON и неожиданная форма — как отсутствующий кэш', () => {
|
||||
const broken = new MemoryStorage();
|
||||
broken.setItem(COMPANIES_CACHE_KEY, '{не json');
|
||||
expect(readCachedCompanies(broken, NOW)).toBeUndefined();
|
||||
expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false);
|
||||
|
||||
const wrongShape = new MemoryStorage();
|
||||
wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] }));
|
||||
expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('недоступное хранилище не ломает ни чтение, ни запись', () => {
|
||||
const throwing: IStorageLike = {
|
||||
getItem: () => {
|
||||
throw new Error('запрещено');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('переполнено');
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('запрещено');
|
||||
}
|
||||
};
|
||||
|
||||
expect(readCachedCompanies(throwing, NOW)).toBeUndefined();
|
||||
expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ICompany } from './models/ICompany';
|
||||
|
||||
/**
|
||||
* Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут
|
||||
* читаться, а не сломают разбор.
|
||||
*/
|
||||
export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1';
|
||||
|
||||
/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */
|
||||
export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */
|
||||
export interface IStorageLike {
|
||||
// Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null.
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
interface ICachedCompanies {
|
||||
savedAt: number;
|
||||
items: ICompany[];
|
||||
}
|
||||
|
||||
function isCompany(value: unknown): value is ICompany {
|
||||
const company = value as Partial<ICompany> | undefined;
|
||||
return !!company && typeof company.Id === 'string' && typeof company.Name === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим
|
||||
* и удаляется: лучше лишний раз сходить в справочник, чем показать мусор.
|
||||
*/
|
||||
export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined {
|
||||
// eslint-disable-next-line @rushstack/no-new-null
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!raw) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ICachedCompanies>;
|
||||
const fresh =
|
||||
typeof parsed.savedAt === 'number' &&
|
||||
now - parsed.savedAt >= 0 &&
|
||||
now - parsed.savedAt < COMPANIES_CACHE_TTL_MS &&
|
||||
Array.isArray(parsed.items) &&
|
||||
parsed.items.every(isCompany);
|
||||
if (fresh) return parsed.items as ICompany[];
|
||||
} catch {
|
||||
// битый JSON — как отсутствующий кэш
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(COMPANIES_CACHE_KEY);
|
||||
} catch {
|
||||
// хранилище недоступно — удалять нечего
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */
|
||||
export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void {
|
||||
try {
|
||||
storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies));
|
||||
} catch {
|
||||
// кэш — оптимизация, а не источник данных
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string
|
||||
id: (r.Id as string) || '',
|
||||
fio: (r.Fio as string) || '',
|
||||
email: (r.Email as string) || '',
|
||||
iin: (r.Iin as string) || '',
|
||||
company: (r.Company as string) || '',
|
||||
companyId: (r.CompanyId as string) || '',
|
||||
section: (r.Section as string) || '',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
import { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
|
||||
const rec = (p: Partial<IEmployeeOrganization>): IEmployeeOrganization => ({
|
||||
id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p
|
||||
});
|
||||
|
||||
describe('организации с подразделением', () => {
|
||||
it('на каждую организацию своё подразделение', () => {
|
||||
const text = formatEmployeeOrganizationsWithDepartment([
|
||||
rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }),
|
||||
rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' })
|
||||
]);
|
||||
expect(text.split('\n').map(parseOrganizationLine)).toEqual([
|
||||
{ company: 'ТОО А', position: 'Директор', department: 'Дирекция' },
|
||||
{ company: 'ТОО Б', position: 'Казначей', department: 'Финансы' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('без подразделения формат прежний «Компания — должность»', () => {
|
||||
expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор');
|
||||
});
|
||||
|
||||
it('старые строки разбираются с пустым подразделением', () => {
|
||||
expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' });
|
||||
expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' });
|
||||
});
|
||||
});
|
||||
@@ -28,3 +28,5 @@ export {
|
||||
getCompanyDirectorEmail,
|
||||
getManagerEmail
|
||||
} from './employeeLookup.service';
|
||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
export type { IOrganizationLine } from './organizationLines';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name }
|
||||
// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name, Bin }
|
||||
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||
export interface ICompany {
|
||||
/** GUID компании — стабильный идентификатор. */
|
||||
Id: string;
|
||||
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||
Name: string;
|
||||
/** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */
|
||||
Bin?: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface IEmployeeOrganization {
|
||||
id: string;
|
||||
fio: string;
|
||||
email: string;
|
||||
/** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */
|
||||
iin: string;
|
||||
company: string;
|
||||
companyId: string;
|
||||
section: string;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||
|
||||
/**
|
||||
* Организации сотрудника с подразделением: «Компания — должность — подразделение»
|
||||
* на строку. Нужен формам, что показывают организации таблицей (см.
|
||||
* OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики
|
||||
* (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения —
|
||||
* формат прежний, как у formatEmployeeOrganizations.
|
||||
*/
|
||||
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||
return records
|
||||
.map((r) => {
|
||||
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
||||
return r.position ? `${r.company} — ${r.position}` : r.company;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface IOrganizationLine {
|
||||
company: string;
|
||||
position: string;
|
||||
department: string;
|
||||
}
|
||||
|
||||
/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */
|
||||
export function parseOrganizationLine(line: string): IOrganizationLine {
|
||||
const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim());
|
||||
return { company, position, department };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config';
|
||||
import { EdoRequestError } from './edoErrors';
|
||||
import { IFioParts } from './fio';
|
||||
|
||||
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
|
||||
const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests';
|
||||
|
||||
/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */
|
||||
/** Как портал называет себя в ЭДО. */
|
||||
export const EDO_SOURCE_NAME = 'Портал заявок';
|
||||
|
||||
export interface IEdoSigningFile {
|
||||
name: string;
|
||||
content: Blob;
|
||||
/** БИН компании файла. У сотрудника-совместителя файлы одного запроса идут от разных компаний. */
|
||||
companyBin: string;
|
||||
}
|
||||
|
||||
/** Что отправляем в ЭДО: одного получателя, он же подписант (физлицо), и файлы. */
|
||||
export interface IEdoSigningRequest {
|
||||
/** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */
|
||||
recipient: { iin: string; email: string; name: IFioParts };
|
||||
/** Кому сообщать о подписи; пусто — никому. */
|
||||
initiatorEmail?: string;
|
||||
files: IEdoSigningFile[];
|
||||
}
|
||||
|
||||
/** Ответ ЭДО: то, что нужно показать пользователю. */
|
||||
export interface IEdoSigningResult {
|
||||
/** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */
|
||||
cabinetUrl: string;
|
||||
documents: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Ответ ЭДО в snake_case — только поля, которые читаем. */
|
||||
interface IEdoResponseBody {
|
||||
cabinet_url?: string;
|
||||
documents?: Array<{ id?: string; file_name?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один.
|
||||
*
|
||||
* Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО
|
||||
* запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при
|
||||
* первом обращении. Адрес ЭДО — свойство веб-части edoApiBaseUrl.
|
||||
*/
|
||||
export async function submitSigningRequest(
|
||||
context: WebPartContext,
|
||||
request: IEdoSigningRequest
|
||||
): Promise<IEdoSigningResult> {
|
||||
const baseUrl = getEdoApiBaseUrl();
|
||||
const client = await context.aadHttpClientFactory.getClient(EDO_API_CLIENT_ID);
|
||||
|
||||
// Content-Type не задаём: браузер сам допишет boundary для multipart.
|
||||
const form = new FormData();
|
||||
form.append('iin', request.recipient.iin);
|
||||
form.append('email', request.recipient.email);
|
||||
form.append('last_name', request.recipient.name.lastName);
|
||||
form.append('first_name', request.recipient.name.firstName);
|
||||
if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName);
|
||||
if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail);
|
||||
// Так ЭДО подпишет документ в колонке «Кто отправил», а не техническим client_id.
|
||||
form.append('service_initiator_name', EDO_SOURCE_NAME);
|
||||
// БИН файлов — одним полем JSON-массивом строк, по порядку файлов: общий company_bin не нужен,
|
||||
// подписант один (физлицо). Повторяющиеся поля ЭДО не принимает (400 invalid_file_company_bins_format).
|
||||
for (const file of request.files) {
|
||||
form.append('files', file.content, file.name);
|
||||
}
|
||||
form.append('file_company_bins', JSON.stringify(request.files.map((file) => file.companyBin)));
|
||||
|
||||
let response: HttpClientResponse;
|
||||
try {
|
||||
response = await client.post(`${baseUrl}${SIGNING_REQUESTS_PATH}`, AadHttpClient.configurations.v1, { body: form });
|
||||
} catch (e) {
|
||||
// Сеть/сертификат: у браузера для таких случаев одно сообщение «Failed to fetch»,
|
||||
// а причина (недоверенный сертификат, ЭДО выключен, адрес не тот) видна только в консоли.
|
||||
console.error('ЭДО недоступен', e);
|
||||
throw new Error(
|
||||
`ЭДО (${baseUrl}) недоступен. Проверьте, что сервер запущен и браузер доверяет его сертификату`
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new EdoRequestError(response.status, await response.text());
|
||||
}
|
||||
|
||||
const body = (await response.json()) as IEdoResponseBody;
|
||||
return {
|
||||
cabinetUrl: body.cabinet_url || '',
|
||||
documents: (body.documents || []).map((d) => ({
|
||||
id: d.id || '',
|
||||
fileName: d.file_name || ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ICompany } from '../dictionary/models/ICompany';
|
||||
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
|
||||
import { describeEdoError } from './edoErrors';
|
||||
import { splitFio } from './fio';
|
||||
import { resolveSigningTargets } from './signingTarget';
|
||||
|
||||
const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96';
|
||||
|
||||
function record(overrides: Partial<IEmployeeOrganization> = {}): IEmployeeOrganization {
|
||||
return {
|
||||
id: 'e1',
|
||||
fio: 'Иванов Иван Иванович',
|
||||
email: 'ivanov@tnsu.kz',
|
||||
iin: '950515123452',
|
||||
company: 'ТОО TANSU Construction',
|
||||
companyId: TANSU_ID,
|
||||
section: '',
|
||||
departmentId: '',
|
||||
position: 'Инженер',
|
||||
mobile: '',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const COMPANIES: ICompany[] = [
|
||||
{ Id: TANSU_ID, Name: 'ТОО TANSU Construction', Bin: '021040005165' },
|
||||
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО Без БИН', Bin: '' }
|
||||
];
|
||||
|
||||
describe('Ф.И.О. по частям для ЭДО', () => {
|
||||
it('три слова', () => {
|
||||
expect(splitFio('Иванов Иван Иванович')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' });
|
||||
});
|
||||
|
||||
it('без отчества', () => {
|
||||
expect(splitFio('Иванов Иван')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: '' });
|
||||
});
|
||||
|
||||
it('лишние пробелы и «оглы» не теряются', () => {
|
||||
expect(splitFio(' Алиев Тимур Расул оглы ')).toEqual({
|
||||
lastName: 'Алиев',
|
||||
firstName: 'Тимур',
|
||||
middleName: 'Расул оглы'
|
||||
});
|
||||
});
|
||||
|
||||
it('одно слово или пусто — не разобрать', () => {
|
||||
expect(splitFio('Иванов')).toBeUndefined();
|
||||
expect(splitFio('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const STROY_ID = '0a1b2c3d-0000-4000-8000-000000000003';
|
||||
const NO_BIN_ID = '0a1b2c3d-0000-4000-8000-000000000002';
|
||||
|
||||
describe('кому и по каким компаниям отправлять на подпись', () => {
|
||||
const companies: ICompany[] = [...COMPANIES, { Id: STROY_ID, Name: 'ТОО TANSU Строй', Bin: '021040005166' }];
|
||||
|
||||
it('ИИН из справочника сотрудников, БИН из справочника компаний', () => {
|
||||
const targets = resolveSigningTargets([record()], companies);
|
||||
|
||||
expect(targets.iin).toBe('950515123452');
|
||||
expect(targets.name.lastName).toBe('Иванов');
|
||||
expect(targets.companies).toEqual([{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }]);
|
||||
expect(targets.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
// Заявку заполняют одну, а подписывают по каждой компании, где человек работает.
|
||||
it('по документу на каждую компанию, со своей должностью', () => {
|
||||
const targets = resolveSigningTargets(
|
||||
[record(), record({ company: 'ТОО TANSU Строй', companyId: STROY_ID, position: 'Главный инженер' })],
|
||||
companies
|
||||
);
|
||||
|
||||
expect(targets.companies).toEqual([
|
||||
{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' },
|
||||
{ bin: '021040005166', name: 'ТОО TANSU Строй', position: 'Главный инженер' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('одна и та же компания дважды не попадает', () => {
|
||||
const targets = resolveSigningTargets([record(), record({ position: 'Совместитель' })], companies);
|
||||
|
||||
expect(targets.companies).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('идентификаторы компаний сравниваются без учёта регистра', () => {
|
||||
expect(resolveSigningTargets([record({ companyId: TANSU_ID.toUpperCase() })], companies).companies[0].bin).toBe(
|
||||
'021040005165'
|
||||
);
|
||||
});
|
||||
|
||||
it('компания без БИН не мешает остальным — уходит в пропущенные с причиной', () => {
|
||||
const targets = resolveSigningTargets(
|
||||
[record({ company: 'ТОО Без БИН', companyId: NO_BIN_ID }), record()],
|
||||
companies
|
||||
);
|
||||
|
||||
expect(targets.companies.map((c) => c.bin)).toEqual(['021040005165']);
|
||||
expect(targets.skipped).toEqual([{ name: 'ТОО Без БИН', reason: 'в справочнике компаний нет БИН' }]);
|
||||
});
|
||||
|
||||
it('компании нет в справочнике — тоже пропущена', () => {
|
||||
const targets = resolveSigningTargets(
|
||||
[record({ company: 'ТОО Неизвестная', companyId: 'ffffffff-0000-4000-8000-000000000000' })],
|
||||
companies
|
||||
);
|
||||
|
||||
expect(targets.companies).toEqual([]);
|
||||
expect(targets.skipped[0].name).toBe('ТОО Неизвестная');
|
||||
});
|
||||
|
||||
it('нет записи о сотруднике — понятная причина', () => {
|
||||
expect(() => resolveSigningTargets([], companies)).toThrow('нет в кадровом справочнике');
|
||||
});
|
||||
|
||||
it('нет ИИН в карточке', () => {
|
||||
expect(() => resolveSigningTargets([record({ iin: ' ' })], companies)).toThrow('не указан ИИН');
|
||||
});
|
||||
|
||||
it('ФИО из одного слова не годится', () => {
|
||||
expect(() => resolveSigningTargets([record({ fio: 'Иванов' })], companies)).toThrow('Ф.И.О.');
|
||||
});
|
||||
|
||||
it('ни в одной записи нет компании', () => {
|
||||
expect(() => resolveSigningTargets([record({ companyId: '' })], companies)).toThrow('не указана компания');
|
||||
});
|
||||
});
|
||||
|
||||
describe('текст ошибки ЭДО', () => {
|
||||
it('берёт русский detail из ProblemDetails', () => {
|
||||
const body = JSON.stringify({ title: 'Bad Request', detail: 'Для компании «X» не загружен сертификат Sigex.' });
|
||||
|
||||
expect(describeEdoError(400, body)).toBe('Для компании «X» не загружен сертификат Sigex.');
|
||||
});
|
||||
|
||||
it('title, если detail нет', () => {
|
||||
expect(describeEdoError(400, JSON.stringify({ title: 'Подписант не найден' }))).toBe('Подписант не найден');
|
||||
});
|
||||
|
||||
it('ошибки проверки собираются в одну строку', () => {
|
||||
const body = JSON.stringify({ errors: { Iin: ['ИИН неверный'], Email: ['Email неверный', 'Слишком длинный'] } });
|
||||
|
||||
expect(describeEdoError(400, body)).toBe('ИИН неверный; Email неверный; Слишком длинный');
|
||||
});
|
||||
|
||||
it('не JSON — по статусу', () => {
|
||||
expect(describeEdoError(502, '<html>Bad gateway</html>')).toBe('ЭДО ответил 502');
|
||||
expect(describeEdoError(401, '')).toContain('401');
|
||||
expect(describeEdoError(403, '')).toContain('403');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Ответ ЭДО с ошибкой: текст для человека и машинный код из ProblemDetails (`code`). */
|
||||
export class EdoRequestError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly code: string | undefined;
|
||||
|
||||
public constructor(status: number, bodyText: string) {
|
||||
super(describeEdoError(status, bodyText));
|
||||
this.name = 'EdoRequestError';
|
||||
this.status = status;
|
||||
this.code = edoErrorCode(bodyText);
|
||||
}
|
||||
}
|
||||
|
||||
/** Машинный код ошибки ЭДО (`code` в ProblemDetails) или undefined. */
|
||||
export function edoErrorCode(bodyText: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { code?: unknown };
|
||||
return typeof parsed.code === 'string' ? parsed.code : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** ЭДО уже принял эти файлы раньше (повтор запроса): для отправителя это успех, а не ошибка. */
|
||||
export const isAlreadyAccepted = (e: unknown): boolean => e instanceof EdoRequestError && e.code === 'duplicate_document';
|
||||
|
||||
/**
|
||||
* Текст ошибки из ответа ЭДО. Бэкенд отвечает ProblemDetails (`title`, `detail`, `errors`, `code`),
|
||||
* и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём
|
||||
* статус: лучше короткое «ЭДО ответил 502», чем кусок HTML в интерфейсе.
|
||||
*/
|
||||
export function describeEdoError(status: number, bodyText: string): string {
|
||||
let parsed: { title?: unknown; detail?: unknown; errors?: unknown } = {};
|
||||
try {
|
||||
parsed = JSON.parse(bodyText) as typeof parsed;
|
||||
} catch {
|
||||
// не JSON — разберём по статусу ниже
|
||||
}
|
||||
|
||||
const validation = flattenValidationErrors(parsed.errors);
|
||||
if (validation) return validation;
|
||||
|
||||
const text = [parsed.detail, parsed.title].find((v): v is string => typeof v === 'string' && v.trim().length > 0);
|
||||
if (text) return text.trim();
|
||||
|
||||
if (status === 401) return 'ЭДО не принял токен: вход под этой учётной записью в ЭДО невозможен (401)';
|
||||
if (status === 403) return 'ЭДО отказал в доступе (403)';
|
||||
return `ЭДО ответил ${status}`;
|
||||
}
|
||||
|
||||
/** Ошибки проверки формата `{ поле: [сообщения] }` → одна строка. */
|
||||
function flattenValidationErrors(errors: unknown): string {
|
||||
if (!errors || typeof errors !== 'object') return '';
|
||||
const messages: string[] = [];
|
||||
for (const value of Object.values(errors as Record<string, unknown>)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const message of value) {
|
||||
if (typeof message === 'string') messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Ф.И.О. по частям — так их принимает ЭДО. */
|
||||
export interface IFioParts {
|
||||
lastName: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Иванов Иван Иванович» → фамилия, имя, отчество. Справочник отдаёт ФИО одной строкой.
|
||||
* Два слова — отчества нет; больше трёх (например, «оглы», двойные фамилии) — всё после
|
||||
* имени уходит в отчество, чтобы ничего не потерять. Одно слово → ничего не определить:
|
||||
* ЭДО требует и фамилию, и имя.
|
||||
*/
|
||||
export function splitFio(fio: string): IFioParts | undefined {
|
||||
const words = (fio || '').split(/\s+/).filter((w) => w.length > 0);
|
||||
if (words.length < 2) return undefined;
|
||||
const [lastName, firstName, ...rest] = words;
|
||||
return { lastName, firstName, middleName: rest.join(' ') };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ICompany } from '../dictionary/models/ICompany';
|
||||
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
|
||||
import { IFioParts, splitFio } from './fio';
|
||||
|
||||
/** Одна компания, от имени которой подписывается документ. */
|
||||
export interface ISigningCompany {
|
||||
/** БИН компании: от её имени документ идёт в Sigex. */
|
||||
bin: string;
|
||||
name: string;
|
||||
/** Должность сотрудника именно в этой компании. */
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** Компания сотрудника, по которой документ отправить нельзя, и почему. */
|
||||
export interface ISkippedCompany {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Кто подписывает и по каким компаниям. */
|
||||
export interface ISigningTargets {
|
||||
iin: string;
|
||||
name: IFioParts;
|
||||
/** По документу на каждую компанию, где у сотрудника есть БИН в справочнике. */
|
||||
companies: ISigningCompany[];
|
||||
/** Компании, по которым документ не отправить (нет БИН в справочнике и т. п.). */
|
||||
skipped: ISkippedCompany[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет подписанта и компании по справочникам api.tnsu.kz: ИИН и ФИО — из Employee/ByEmail,
|
||||
* БИН — из Dictionary/Companies по CompanyId.
|
||||
*
|
||||
* Сотрудник может работать в нескольких компаниях (на один e-mail — несколько записей), и заявление
|
||||
* подписывается по каждой из них отдельно: у каждой компании свой документ со своей должностью.
|
||||
* Одна и та же компания дважды не попадает. Компания без БИН не роняет остальные — она уходит в
|
||||
* skipped с причиной. Ошибка бросается, только если подписать нельзя вовсе: нет человека, ИИН, ФИО
|
||||
* или ни одной компании.
|
||||
*/
|
||||
export function resolveSigningTargets(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTargets {
|
||||
if (records.length === 0) {
|
||||
throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда');
|
||||
}
|
||||
|
||||
// Человек один на все записи: ИИН и ФИО берём из первой, где они есть.
|
||||
const iin = (records.find((r) => (r.iin || '').trim())?.iin || '').trim();
|
||||
if (!iin) {
|
||||
throw new Error('в кадровом справочнике у сотрудника не указан ИИН');
|
||||
}
|
||||
|
||||
const fio = records.find((r) => (r.fio || '').trim())?.fio || '';
|
||||
const name = splitFio(fio);
|
||||
if (!name) {
|
||||
throw new Error(`не удалось разобрать Ф.И.О. «${fio}» на фамилию и имя`);
|
||||
}
|
||||
|
||||
const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c]));
|
||||
const result: ISigningCompany[] = [];
|
||||
const skipped: ISkippedCompany[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const record of records) {
|
||||
if (!record.companyId) continue;
|
||||
|
||||
const company = byId.get(record.companyId.toLowerCase());
|
||||
const bin = (company?.Bin || '').trim();
|
||||
const companyName = company?.Name || record.company;
|
||||
|
||||
if (!bin) {
|
||||
if (!seen.has(`name:${companyName}`)) {
|
||||
seen.add(`name:${companyName}`);
|
||||
skipped.push({ name: companyName, reason: 'в справочнике компаний нет БИН' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(bin)) continue;
|
||||
seen.add(bin);
|
||||
result.push({ bin, name: companyName, position: record.position });
|
||||
}
|
||||
|
||||
if (result.length === 0 && skipped.length === 0) {
|
||||
throw new Error('в карточке сотрудника не указана компания');
|
||||
}
|
||||
|
||||
return { iin, name, companies: result, skipped };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ORG_LOGOS } from './orgLogos';
|
||||
|
||||
/** Логотип для шапки документа: PNG в base64 и его размеры в пикселях (по ним считается пропорция). */
|
||||
export interface ILetterheadLogo {
|
||||
base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Ширина и высота PNG из заголовка IHDR (байты 16–23). */
|
||||
export function pngSize(base64: string): { width: number; height: number } {
|
||||
const head = atob(base64.slice(0, 44));
|
||||
const read = (offset: number): number =>
|
||||
((head.charCodeAt(offset) << 24) |
|
||||
(head.charCodeAt(offset + 1) << 16) |
|
||||
(head.charCodeAt(offset + 2) << 8) |
|
||||
head.charCodeAt(offset + 3)) >>>
|
||||
0;
|
||||
return { width: read(16), height: read(20) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Логотип компании — тот же, что в печатной шапке (shared/ui/OrgLetterhead). Логотипы вшиты в бандл,
|
||||
* поэтому у компании, для которой он есть, шапка в документе будет всегда. Нет логотипа у компании
|
||||
* вовсе — undefined, документ уходит без шапки, как и печатается.
|
||||
*/
|
||||
export function letterheadLogoFor(company: string, logos: Record<string, string> = ORG_LOGOS): ILetterheadLogo | undefined {
|
||||
const dataUri = logos[company];
|
||||
const prefix = 'data:image/png;base64,';
|
||||
if (!dataUri?.startsWith(prefix)) return undefined;
|
||||
const base64 = dataUri.slice(prefix.length);
|
||||
return { base64, ...pngSize(base64) };
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import tansuConstruction from '../assets/logos/tansu-construction.png';
|
||||
import astelK from '../assets/logos/astel-k.png';
|
||||
import ctGlobal from '../assets/logos/ctglobal.png';
|
||||
import kazpromService from '../assets/logos/kazprom-service.png';
|
||||
import qazgeomax from '../assets/logos/qazgeomax.png';
|
||||
import { tansuConstruction, astelK, ctGlobal, kazpromService, qazgeomax } from '../assets/logos/inlineLogos';
|
||||
|
||||
/**
|
||||
* Логотипы организаций для брендированной шапки печати (см. shared/ui/OrgLetterhead).
|
||||
@@ -13,6 +9,11 @@ import qazgeomax from '../assets/logos/qazgeomax.png';
|
||||
* Не для каждой компании из справочника есть логотип — тогда OrgLetterhead просто
|
||||
* не рендерится (lookup возвращает undefined). Новая компания → положить PNG в
|
||||
* ../assets/logos и добавить сюда запись.
|
||||
*
|
||||
* Логотипы вшиты в бандл data-URI (assets/logos/inlineLogos.ts), а не лежат отдельными файлами: иначе
|
||||
* при первом заходе картинка догружается по сети, и шапка то не видна на экране, то не успевает в окно
|
||||
* печати (оно открывается заново, без кэша страницы). Исходники — PNG высотой 300px рядом; после замены
|
||||
* картинки пересобрать inlineLogos.ts (base64 содержимого файлов).
|
||||
*/
|
||||
export const ORG_LOGOS: Record<string, string> = {
|
||||
'ТОО TANSU Construction': tansuConstruction,
|
||||
|
||||
@@ -19,3 +19,39 @@
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
// Блок таблицы на всю ширину сетки формы (без FormField — его подпись дублировала бы заголовок колонки).
|
||||
.tableWrap {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
// Таблица организаций: те же цвета, что у .line — «подставлено автоматически».
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
background: $gray-50;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 9px 13px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: $gray-500;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
tr + tr td {
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import styles from './OrganizationField.module.scss';
|
||||
import { parseOrganizationLine } from '../../dictionary/organizationLines';
|
||||
|
||||
interface IOrganizationTableProps {
|
||||
/** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */
|
||||
value: string;
|
||||
/** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */
|
||||
printIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Организации сотрудника таблицей «Организация · Должность · Подразделение» —
|
||||
* read-only, подтягивается из Employee/ByEmail. На каждую организацию своё
|
||||
* подразделение (сотрудник может состоять в нескольких). Без внешней подписи: её роль
|
||||
* играет заголовок колонки «Организация». Нет данных — ничего не рендерим.
|
||||
*/
|
||||
export const OrganizationTable: React.FC<IOrganizationTableProps> = ({ value, printIndex }) => {
|
||||
const rows = value ? value.split('\n').map(parseOrganizationLine) : [];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Организация</th>
|
||||
<th>Должность</th>
|
||||
<th>Подразделение</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={printIndex !== undefined && i !== printIndex ? 'rp-no-print' : undefined}>
|
||||
<td>{r.company}</td>
|
||||
<td>{r.position || '—'}</td>
|
||||
<td>{r.department || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { trimPickerQuery } from './pickerContext';
|
||||
|
||||
const body = (q: unknown): string => JSON.stringify({ queryParams: { QueryString: q, PrincipalType: 1 } });
|
||||
const query = (b: unknown): unknown => (JSON.parse(b as string) as { queryParams: { QueryString: unknown } }).queryParams.QueryString;
|
||||
|
||||
describe('trimPickerQuery', () => {
|
||||
it('убирает пробел в конце и в начале', () => {
|
||||
expect(query(trimPickerQuery(body('Иван ')))).toBe('Иван');
|
||||
expect(query(trimPickerQuery(body(' Иван Петров ')))).toBe('Иван Петров');
|
||||
});
|
||||
|
||||
it('схлопывает двойные пробелы внутри', () => {
|
||||
expect(query(trimPickerQuery(body('Иван Петров')))).toBe('Иван Петров');
|
||||
});
|
||||
|
||||
it('не трогает остальные поля запроса', () => {
|
||||
const parsed = JSON.parse(trimPickerQuery(body('a ')) as string) as { queryParams: { PrincipalType: number } };
|
||||
expect(parsed.queryParams.PrincipalType).toBe(1);
|
||||
});
|
||||
|
||||
it('чужие тела возвращает как есть', () => {
|
||||
expect(trimPickerQuery('не json QueryString')).toBe('не json QueryString');
|
||||
expect(trimPickerQuery(undefined)).toBeUndefined();
|
||||
const other = JSON.stringify({ a: 1 });
|
||||
expect(trimPickerQuery(other)).toBe(other);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,43 @@ export function pickedLogin(user: IPickedUser): string {
|
||||
return user.secondaryText || user.loginName || user.id || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает пробелы по краям (и схлопывает внутренние) у QueryString в теле запроса
|
||||
* поиска сотрудника. PeoplePicker из @pnp/spfx-controls-react шлёт введённый
|
||||
* текст как есть, а SharePoint с «Иван » (пробел в конце, обычное дело при вводе
|
||||
* «имя фамилия») ничего не находит. Тело не JSON или без QueryString — возвращается как было.
|
||||
*/
|
||||
export function trimPickerQuery(body: unknown): unknown {
|
||||
if (typeof body !== 'string' || body.indexOf('QueryString') < 0) return body;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { queryParams?: { QueryString?: unknown } };
|
||||
const query = parsed.queryParams?.QueryString;
|
||||
if (typeof query !== 'string') return body;
|
||||
parsed.queryParams!.QueryString = query.trim().replace(/\s+/g, ' ');
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* spHttpClient с подчисткой запроса поиска (см. trimPickerQuery): прокси, чтобы
|
||||
* остальные методы клиента работали как прежде, подменяется только post.
|
||||
*/
|
||||
function withTrimmedSearch(client: WebPartContext['spHttpClient']): WebPartContext['spHttpClient'] {
|
||||
return new Proxy(client, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== 'post') return Reflect.get(target, prop, receiver);
|
||||
return (url: string, config: unknown, options?: { body?: unknown }) =>
|
||||
(target.post as (u: string, c: unknown, o?: unknown) => unknown)(
|
||||
url,
|
||||
config,
|
||||
options ? { ...options, body: trimPickerQuery(options.body) } : options
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст для PeoplePicker из WebPartContext.
|
||||
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
||||
@@ -27,6 +64,6 @@ export function buildPickerContext(context: WebPartContext): IPeoplePickerContex
|
||||
return {
|
||||
absoluteUrl: context.pageContext.web.absoluteUrl,
|
||||
msGraphClientFactory: context.msGraphClientFactory,
|
||||
spHttpClient: context.spHttpClient
|
||||
spHttpClient: withTrimmedSearch(context.spHttpClient)
|
||||
} as unknown as IPeoplePickerContext;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
||||
onDelete,
|
||||
}) => {
|
||||
const { saving, kind } = submit;
|
||||
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId);
|
||||
const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId);
|
||||
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
||||
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
||||
const [isTester, setIsTester] = React.useState<boolean>(false);
|
||||
|
||||
@@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||
export { LinkField } from './LinkField/LinkField';
|
||||
|
||||
Reference in New Issue
Block a user