Merge remote-tracking branch 'origin/main' into left-panel

This commit is contained in:
gorbenko.pavel
2026-08-04 12:37:17 +05:00
17 changed files with 249 additions and 61 deletions
@@ -39,18 +39,27 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const approval = useMemo(() => hireApprovalService(), []);
const userEmail = context.pageContext.user.email;
const loadMatrix = useCallback(
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval),
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval]
);
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
approval.getJobTitle(login)
);
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval]
);
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
approval.getJobTitle(login)
);
const saving = actionStatus === 'saving';
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
const matrixApprovers = (): IApproverToWrite[] =>
matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
if (!isValid) {
@@ -61,7 +70,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
setActionKind(kind);
setActionStatus('saving');
try {
const id = await saveHireRequest(data, matrixApprovers(), requestStatus);
const id = await saveHireRequest(
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
requestStatus
);
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
setStatus(requestStatus);
setActionStatus('success');
@@ -115,6 +129,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
@@ -6,7 +6,8 @@
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "Order", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
]
}
@@ -1,5 +1,5 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { IFormData } from './model';
export const DATA_LIST_TITLE = 'Прием на работу — данные';
@@ -40,16 +40,18 @@ async function writeContent(data: IFormData, status: string): Promise<number> {
}
/**
* Сохранить заявку «на прием»: контент + маршрут согласующих со статусом.
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
* Возвращает Id заявки.
*/
export async function saveHireRequest(
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string
): Promise<number> {
const id = await writeContent(data, status);
await writeApprovers(hireApprovalService(), id, approvers, status);
await writeRoute(hireApprovalService(), id, approvers, executors, status);
return id;
}
@@ -62,16 +62,25 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const approval = useMemo(() => workplaceApprovalService(), []);
const userEmail = context.pageContext.user.email;
const loadMatrix = useCallback(
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval),
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval]
);
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login));
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval]
);
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
approval.getJobTitle(login)
);
const saving = actionStatus === 'saving';
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
const matrixApprovers = (): IApproverToWrite[] =>
matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
if (!isValid) {
@@ -82,7 +91,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
setActionKind(kind);
setActionStatus('saving');
try {
const id = await saveWorkplaceRequest(data, matrixApprovers(), requestStatus, sourceId);
const id = await saveWorkplaceRequest(
data,
toApprovers(matrix.rows),
toApprovers(executors.rows),
requestStatus,
sourceId
);
await uploadAttachments(DATA_LIST_TITLE, id, files);
setActionStatus('success');
if (onSaved) {
@@ -125,6 +140,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)"
roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту."
/>
)}
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
@@ -6,7 +6,8 @@
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "Order", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }
]
}
@@ -1,5 +1,5 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval';
import { IFormData } from './model';
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
@@ -42,16 +42,18 @@ async function writeContent(data: IFormData, status: string, sourceHireId?: numb
}
/**
* Сохранить заявку «орг. рабочего места»: контент + маршрут согласующих.
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
* sourceHireId — если заявка создана на основе «прием».
*/
export async function saveWorkplaceRequest(
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
sourceHireId?: number
): Promise<number> {
const id = await writeContent(data, status, sourceHireId);
await writeApprovers(workplaceApprovalService(), id, approvers, status);
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
return id;
}
@@ -8,6 +8,7 @@ import '@pnp/sp/profiles';
import { IApprovalRow } from './models/IApprovalRow';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
import { APPROVAL_PHASE } from './models/statuses';
// Внутренние имена колонок списка согласования (создаются провижинингом).
const FIELDS = {
@@ -16,8 +17,10 @@ const FIELDS = {
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
date: 'Date', // «Дата» (DateTime)
comment: 'Comment', // «Комментарий» (Text)
order: 'Order', // «Порядок» (Number)
order: 'StepOrder', // «Порядок» (Number). НЕ 'Order' — это зарезервированное
// скрытое системное поле SharePoint (не видно в UI списка).
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
phase: 'Phase', // «Фаза» (Choice): Согласование | Исполнение
};
// Согласующий для записи в список.
@@ -35,12 +38,15 @@ export class ApprovalListService {
return getSP().web.lists.getByTitle(this.listTitle);
}
// Пишет по строке на каждого согласующего с указанным статусом.
// Порядок (Order) = позиция в массиве.
// Пишет по строке на каждого участника фазы с указанным статусом.
// Порядок (Order) = orderStart + позиция в массиве (фазы идут подряд в одном списке:
// сначала согласующие 1..N, затем исполнители N+1..N+M).
public async createSteps(
requestId: number,
approvers: IApproverToWrite[],
status: string
status: string,
phase: string = APPROVAL_PHASE.approval,
orderStart: number = 1
): Promise<void> {
for (let i = 0; i < approvers.length; i++) {
const a = approvers[i];
@@ -51,8 +57,9 @@ export class ApprovalListService {
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: i + 1,
[FIELDS.order]: orderStart + i,
[FIELDS.allowReassign]: a.allowReassign,
[FIELDS.phase]: phase,
});
}
}
@@ -62,7 +69,8 @@ export class ApprovalListService {
requestId: number,
approver: IApproverToWrite,
order: number,
status: string
status: string,
phase: string = APPROVAL_PHASE.approval
): Promise<void> {
const approverId = await this.resolveUserId(approver.email);
await this.list().items.add({
@@ -72,6 +80,7 @@ export class ApprovalListService {
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: approver.allowReassign,
[FIELDS.phase]: phase,
});
}
@@ -79,7 +88,7 @@ export class ApprovalListService {
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
const rows = (await this.list()
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, `${FIELDS.approver}/Id`)
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, FIELDS.phase, `${FIELDS.approver}/Id`)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
@@ -91,6 +100,8 @@ export class ApprovalListService {
approverId: approver?.Id ?? 0,
order: (r[FIELDS.order] as number) ?? 0,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
};
});
}
@@ -107,6 +118,7 @@ export class ApprovalListService {
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
`${FIELDS.approver}/Title`
)
.expand(FIELDS.approver)
@@ -123,6 +135,7 @@ export class ApprovalListService {
date: this.formatDate((r[FIELDS.date] as string) || ''),
comment: (r[FIELDS.comment] as string) || '',
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
};
});
}
@@ -1,7 +1,7 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
import { IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
import { ApprovalStatus, IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
// Все обращения к внешнему approval-route API — только здесь.
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
@@ -24,13 +24,17 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
return body.data ?? [];
}
// GET /api/template-routes/resolve — планируемый маршрут согласования для типа
// заявки и пользователя. Бэкенд сам подставляет согласующих (executorEmail/position)
// по ролям исходя из userEmail.
// POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
// 'Approval' — согласующие (кто одобряет),
// 'Execution' — исполнители (кто выполняет после согласования).
// Один и тот же маршрут, разные наборы шагов.
export async function resolveRoute(
context: WebPartContext,
requestTypeId: number,
userEmail: string
userEmail: string,
status: ApprovalStatus = 'Approval'
): Promise<IResolvedRoute> {
const c = await client(context);
const url = `${APPROVAL_API_BASE_URL}/api/template-routes/resolve`;
@@ -45,7 +49,7 @@ export async function resolveRoute(
requestTypeId,
userEmail,
projectId: null,
status: 'Approval',
status,
fieldValues: []
})
});
@@ -2,17 +2,22 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
import { resolveRoute } from './approvalRoute.service';
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
import { IMatrixRow } from './models/IMatrixRow';
import { ApprovalStatus } from './models/IApprovalRouteApi';
import { APPROVAL_PHASE } from './models/statuses';
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
// Имена согласующих подтягиваются по executorEmail (бэкенд отдаёт email + должность).
// status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution'
// (исполнители). Имена подтягиваются по executorEmail (бэкенд отдаёт email + должность).
export async function getPlannedMatrix(
context: WebPartContext,
requestTypeId: number,
userEmail: string,
approval: ApprovalListService
approval: ApprovalListService,
status: ApprovalStatus = 'Approval'
): Promise<IMatrixRow[]> {
if (!userEmail) return [];
const route = await resolveRoute(context, requestTypeId, userEmail);
const route = await resolveRoute(context, requestTypeId, userEmail, status);
const phase = status === 'Execution' ? APPROVAL_PHASE.execution : APPROVAL_PHASE.approval;
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
return Promise.all(
ordered.map(async (s) => ({
@@ -24,11 +29,12 @@ export async function getPlannedMatrix(
date: '',
comment: '',
allowReassign: s.allowReassignToOtherApprovers,
phase,
}))
);
}
// Записать строки согласующих для заявки с указанным статусом.
// Записать строки согласующих для заявки с указанным статусом (только фаза согласования).
// Матрица перезаписывается целиком (для повторной отправки/черновика).
export async function writeApprovers(
approval: ApprovalListService,
@@ -37,5 +43,27 @@ export async function writeApprovers(
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, status);
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
}
// Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей
// (Order N+1..N+M) в один список. Исполнители пишутся сразу при отправке —
// стороне согласующего внешний API уже не нужен, переход в фазу исполнения
// делает computeRequestStatus, когда все согласующие одобрили.
export async function writeRoute(
approval: ApprovalListService,
requestId: number,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string
): Promise<void> {
await approval.deleteForRequest(requestId);
await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1);
await approval.createSteps(
requestId,
executors,
status,
APPROVAL_PHASE.execution,
approvers.length + 1
);
}
@@ -3,7 +3,7 @@ import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
@@ -52,11 +52,13 @@ export class ApproverService {
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const newOrder = (prevOrder + current.order) / 2;
const position = await this.approval.getJobTitle(user.loginName);
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
newOrder,
REQUEST_STATUS.onApproval
REQUEST_STATUS.onApproval,
current.phase
);
}
@@ -75,13 +77,28 @@ export class ApproverService {
return requestStatus;
}
// Статус заявки из строк маршрута с учётом фазы:
// 1) фаза согласования: отклонение/возврат → соответствующий статус;
// не все согласовали → «На согласовании».
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
// исполнители есть и не все выполнили → «Выполнение»;
// все выполнили → «Закрыта».
// Строка «выполнена» исполнителем помечается тем же статусом «Согласовано»
// (действие «Выполнено» в DecisionPanel), фазы различаются колонкой Phase.
private computeRequestStatus(rows: IApprovalRow[]): string {
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
if (rows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
return REQUEST_STATUS.approved;
}
return REQUEST_STATUS.onApproval;
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
if (approvalRows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
if (approvalRows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
const approvalDone =
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
if (!approvalDone) return REQUEST_STATUS.onApproval;
if (execRows.length === 0) return REQUEST_STATUS.approved;
if (execRows.every((r) => r.status === REQUEST_STATUS.approved)) return REQUEST_STATUS.closed;
return REQUEST_STATUS.execution;
}
private async updateRequestStatus(id: number, status: string): Promise<void> {
@@ -1,9 +1,15 @@
// Единая точка импорта механизма согласования.
export { REQUEST_STATUS, REQUEST_STATUS_CHOICES } from './models/statuses';
export {
REQUEST_STATUS,
REQUEST_STATUS_CHOICES,
APPROVAL_PHASE,
APPROVAL_PHASE_CHOICES,
} from './models/statuses';
export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow';
export type { IApprovalRow } from './models/IApprovalRow';
export type {
ApprovalStatus,
IRequestType,
IResolvedRoute,
IResolvedRouteStep,
@@ -14,7 +20,7 @@ export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers } from './approvalSubmission.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { useMatrixEditor } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
@@ -34,4 +34,7 @@ export interface IResolvedRouteStep {
executorEmail: string | null;
position: string | null;
allowReassignToOtherApprovers: boolean;
// Предупреждение маршрута по этому шагу (например, роль не разрешилась в
// конкретного человека) — приходит от API, показываем автору при желании.
warning: string | null;
}
@@ -5,4 +5,5 @@ export interface IApprovalRow {
approverId: number; // Id пользователя сайта в поле «Согласующий»
order: number; // колонка Order (очередь согласования)
allowReassign: boolean; // AllowReassignToOtherApprovers
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
}
@@ -8,4 +8,5 @@ export interface IMatrixRow {
date: string; // дата решения (дд.мм.гггг) или ''
comment: string;
allowReassign: boolean; // может ли этот согласующий добавить делеганта
phase?: string; // фаза строки: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
}
@@ -23,3 +23,18 @@ export const REQUEST_STATUS_CHOICES: string[] = [
REQUEST_STATUS.execution,
REQUEST_STATUS.closed,
];
// Фаза строки в списке маршрута: согласование (кто одобряет) и исполнение
// (кто выполняет после согласования). Обе фазы живут в одном списке «— согласование»,
// строки различаются колонкой Phase. Значения приходят из внешнего API как
// Approval/Execution, но в списке храним по-русски для читаемости колонки.
export const APPROVAL_PHASE = {
approval: 'Согласование',
execution: 'Исполнение',
} as const;
// Значения Choice-колонки Phase при провижининге.
export const APPROVAL_PHASE_CHOICES: string[] = [
APPROVAL_PHASE.approval,
APPROVAL_PHASE.execution,
];
@@ -8,25 +8,40 @@ import { UserPicker } from './UserPicker';
export interface IApprovalMatrixProps {
editor: IUseMatrixEditor;
search: (query: string) => Promise<ISiteUser[]>;
/** Заголовок блока. По умолчанию — «Маршрут согласования». */
title?: string;
/** Подпись роли в шапке таблицы. По умолчанию — «Согласующий». */
roleLabel?: string;
/** Текст, когда участников нет. По умолчанию — «Согласующие не заданы.». */
emptyText?: string;
/** Плейсхолдер пикера добавления (только если editor.editable). */
pickerPlaceholder?: string;
}
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
editor,
search,
title = 'Маршрут согласования',
roleLabel = 'Согласующий',
emptyText = 'Согласующие не заданы.',
pickerPlaceholder = 'Добавить согласующего: начните вводить ФИО…',
}) => {
const { rows, loading, error, editable } = editor;
return (
<div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>Маршрут согласования</div>
<div className={styles.matrixTitle}>{title}</div>
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
{!loading && !error && rows.length === 0 && (
<div className={styles.matrixNote}>Согласующие не заданы.</div>
<div className={styles.matrixNote}>{emptyText}</div>
)}
{!loading && !error && rows.length > 0 && (
<table className={styles.matrix}>
<thead>
<tr>
<th>Согласующий</th>
<th>{roleLabel}</th>
<th>Должность</th>
<th>Статус</th>
<th>Дата</th>
@@ -77,11 +92,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search
)}
{editable && !loading && (
<div className={styles.matrixPicker}>
<UserPicker
search={search}
onPick={editor.addApprover}
placeholder="Добавить согласующего: начните вводить ФИО…"
/>
<UserPicker search={search} onPick={editor.addApprover} placeholder={pickerPlaceholder} />
</div>
)}
</div>
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
import styles from './DecisionPanel.module.scss';
import { Button, Alert, FormField, TextArea, Icon } from '../../ui';
import { ApproverService, ApproverDecision } from '../approverDecision.service';
import { APPROVAL_PHASE } from '../models/statuses';
interface IDecisionPanelProps {
service: ApproverService;
@@ -17,6 +18,7 @@ interface IDecisionPanelProps {
export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestId, onDecided }) => {
const [checking, setChecking] = useState<boolean>(true);
const [rowId, setRowId] = useState<number | undefined>(undefined);
const [phase, setPhase] = useState<string>(APPROVAL_PHASE.approval);
const [comment, setComment] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
@@ -30,6 +32,9 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
.then((row) => {
if (alive) {
setRowId(row?.id);
if (row) {
setPhase(row.phase);
}
setChecking(false);
}
})
@@ -67,14 +72,19 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
}
}
const isExecution = phase === APPROVAL_PHASE.execution;
if (checking) {
return <div className={styles.state}>Проверяем вашу очередь на согласование…</div>;
return <div className={styles.state}>Проверяем вашу очередь…</div>;
}
if (resultStatus) {
return (
<div className={styles.panel}>
<Alert variant="ok">Решение сохранено. Статус заявки: «{resultStatus}».</Alert>
<Alert variant="ok">
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
{resultStatus}».
</Alert>
</div>
);
}
@@ -82,9 +92,33 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({ service, requestI
if (rowId === undefined) {
return (
<div className={styles.panel}>
<Alert variant="warn">
Сейчас не ваша очередь на согласование, либо заявка уже обработана.
</Alert>
<Alert variant="warn">Сейчас не ваша очередь, либо заявка уже обработана.</Alert>
</div>
);
}
// Фаза исполнения: одно действие «Выполнено» (без обязательного комментария),
// строка помечается как «Согласовано» → computeRequestStatus переведёт заявку в
// «Выполнение»/«Закрыта».
if (isExecution) {
return (
<div className={styles.panel}>
<div className={styles.title}>Исполнение</div>
{error && <Alert variant="err">{error}</Alert>}
<FormField label="Комментарий" htmlFor="decisionComment">
<TextArea
id="decisionComment"
value={comment}
onChange={setComment}
placeholder="Комментарий (необязательно)"
/>
</FormField>
<div className={styles.actions}>
<Button variant="accent" disabled={submitting} onClick={() => decide('approve')}>
<Icon name="check" size="sm" />
Выполнено
</Button>
</div>
</div>
);
}