Merge remote-tracking branch 'origin/main' into left-panel
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user