куча разных нюансов сделано

This commit is contained in:
gorbenko.pavel
2026-08-06 17:04:41 +05:00
parent 13833217d0
commit d5832179fb
11 changed files with 343 additions and 64 deletions
@@ -68,11 +68,23 @@ export class ApprovalListService {
// сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у
// параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве
// нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет.
const withOrder = approvers
.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }))
.filter((x) => !!x.a.email);
const withOrder = approvers.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) }));
for (const { a, order } of withOrder) {
// Строку без email в SharePoint не записать (Person-поле обязательного
// значения) — раньше такие шаги пропадали отсюда молча, и согласующие в
// середине маршрута просто не получали заявку (см. findUnassignedStep —
// это должно было быть отловлено валидацией ДО вызова createSteps; лог
// здесь — на случай, если её обошли, например прямым вызовом сервиса).
const missing = withOrder.filter((x) => !x.a.email);
if (missing.length > 0) {
console.error(
`[Согласование] createSteps: пропущены шаги без назначенного человека (order ${missing
.map((x) => x.order)
.join(', ')}) — заявка №${requestId}`
);
}
for (const { a, order } of withOrder.filter((x) => !!x.a.email)) {
const approverId = await this.resolveUserId(a.email);
await this.list().items.add({
Title: a.position || a.email,
@@ -170,6 +182,12 @@ export class ApprovalListService {
}
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
//
// Approver/EMail нужен не только для показа: при редактировании черновика
// (см. useMatrixEditor с editable=true в WorkplaceSetupForm/RecruitingHireForm)
// это ЕДИНСТВЕННЫЙ источник email для уже существующих строк — без него
// findUnassignedStep решает, что назначенного человека нет, хотя он есть
// (баг: пропущенный email тут выглядел так же, как реально пустой шаг).
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
const rows = (await this.list()
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
@@ -182,17 +200,18 @@ export class ApprovalListService {
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
`${FIELDS.approver}/Title`
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
return rows.map((r, i) => {
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined;
return {
order: (r[FIELDS.order] as number) ?? i + 1,
who: approver?.Title || '',
email: '',
email: approver?.EMail || '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: this.formatDate((r[FIELDS.date] as string) || ''),
@@ -24,7 +24,7 @@ export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
export type { ApproverDecision } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { useMatrixEditor } from './useMatrixEditor';
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix';
export { UserPicker } from './ui/UserPicker';
@@ -115,6 +115,7 @@
.matrixActions {
display: flex;
align-items: center;
gap: $sp-1;
justify-content: flex-end;
}
@@ -181,15 +182,16 @@
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
// кнопки центрируем относительно всего блока.
// Та же flex-раскладка, что и у .matrixActions — иначе кнопки здесь (обычные
// инлайновые элементы) выравниваются по baseline и «прыгают» относительно
// кнопок соседней колонки, которая уже flex-контейнер.
.matrixBlockMove {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $sp-1;
width: 64px;
white-space: nowrap;
vertical-align: middle;
text-align: right;
.iconBtn + .iconBtn {
margin-left: $sp-1;
}
}
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
@@ -78,6 +78,16 @@ function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
}));
}
// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута,
// который внешний approval-route API вернул как роль без привязанного
// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует
// значение) — approvalList.service.ts/createSteps молча пропускает её при
// записи, поэтому перед отправкой это нужно поймать здесь и не дать заявке уйти
// на согласование с пропущенным шагом (см. RecruitingHireForm/WorkplaceSetupForm).
export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined {
return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0];
}
/**
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
+11 -5
View File
@@ -20,7 +20,9 @@ import * as React from 'react';
// перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»),
// и на её собственное событие, поэтому реагирует на оба способа смены URL.
export type PortalMode = 'submit' | 'approve' | 'create';
// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую /
// отклонённую) на редактирование в той же выезжающей панели, что и просмотр.
export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
/** Раздел бокового меню. */
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
@@ -29,9 +31,9 @@ export interface IRoute {
mode: PortalMode;
/** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */
nav: NavSection;
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
/** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */
formKey?: string;
/** Id заявки в списке данных — для 'approve'. */
/** Id заявки в списке данных — для 'approve' и 'edit'. */
itemId?: number;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
@@ -59,6 +61,10 @@ export function parseRoute(search: string = window.location.search): IRoute {
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', nav, formKey, itemId };
}
// Редактирование собственной незавершённой заявки — та же панель, но с формой.
if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'edit', nav, formKey, itemId };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', nav: 'new', formKey, sourceId };
@@ -78,8 +84,8 @@ function buildQuery(route: IRoute): string {
if (route.nav !== 'new') {
params.set('rpnav', route.nav);
}
if (route.mode === 'approve' && route.formKey && route.itemId) {
params.set('rpmode', 'approve');
if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) {
params.set('rpmode', route.mode);
params.set('rptype', route.formKey);
params.set('rpid', String(route.itemId));
} else if (route.mode === 'create' && route.formKey && route.sourceId) {