Compare commits
6 Commits
d749bfae26
...
b82008943f
| Author | SHA1 | Date | |
|---|---|---|---|
| b82008943f | |||
| e3f4bcf9bd | |||
| 1b1a783c98 | |||
| 247f614885 | |||
| be8177e8ae | |||
| 13ba9c9e37 |
@@ -28,6 +28,7 @@
|
|||||||
## Что НЕ дублируем в карточке (лежит в других местах)
|
## Что НЕ дублируем в карточке (лежит в других местах)
|
||||||
- Колонки списков → `forms/<Форма>/provisioning.json`.
|
- Колонки списков → `forms/<Форма>/provisioning.json`.
|
||||||
- Параметры ссылок (rptype/rpmode/rpid/rpsource) → [`routing.md`](./routing.md).
|
- Параметры ссылок (rptype/rpmode/rpid/rpsource) → [`routing.md`](./routing.md).
|
||||||
|
- Особые права (кто что может сверх обычного, по email/должности/группе) → [`permissions.md`](./permissions.md).
|
||||||
- Как обращаться к спискам SP из кода → [`sp-lists-frontend.md`](./sp-lists-frontend.md).
|
- Как обращаться к спискам SP из кода → [`sp-lists-frontend.md`](./sp-lists-frontend.md).
|
||||||
- Общий жизненный цикл (черновик/upsert/блокировка/статусы), согласование, исполнение,
|
- Общий жизненный цикл (черновик/upsert/блокировка/статусы), согласование, исполнение,
|
||||||
печать — **одинаковы для всех форм** (общие примитивы `shared/ui` и `shared/approval`).
|
печать — **одинаковы для всех форм** (общие примитивы `shared/ui` и `shared/approval`).
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Особые права в портале — реестр
|
||||||
|
|
||||||
|
Здесь собраны все места, где кому-то выданы права **сверх обычных**. Обычные
|
||||||
|
права такие: автор правит свою незавершённую заявку, согласующий или исполнитель
|
||||||
|
решает на своём активном шаге.
|
||||||
|
|
||||||
|
Многие права пока выданы **по email или по должности прямо в коде**. Это
|
||||||
|
временно: план — переехать на группы SharePoint. Этот файл нужен, чтобы при
|
||||||
|
переезде, смене сотрудника или разборе «почему у него есть или нет кнопки»
|
||||||
|
ничего не потерять.
|
||||||
|
|
||||||
|
> **Правило.** Добавили, изменили или убрали особое право — поправьте таблицу
|
||||||
|
> ниже в том же коммите.
|
||||||
|
|
||||||
|
## Сводная таблица
|
||||||
|
|
||||||
|
| Право | Кто | Как определяется | Где действует | Файл с проверкой |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
||||||
|
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
||||||
|
| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
|
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||||
|
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||||
|
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||||
|
| Править парковочную карту | Начальник отдела подбора и адаптации | должность шага: «начальник/руководитель» + «подбор» + «адаптац» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditParkingField` |
|
||||||
|
| Видеть раздел «Отчёты» | Группа SP «Отчеты по заявкам» (Id 199) **и** всегда: Тришина М., Городецкий Т., Горбенко П., Сарниязова А. | группа SharePoint + email в коде | Боковое меню | `shared/reports/reportsAccess.ts` → `hasReportsAccess` |
|
||||||
|
| Видеть «Все заявки» типа (чужие тоже, только просмотр) | Список email для каждого типа | настраивается **в админке approval-route**, не в коде | Раздел «Все заявки» | `shared/approval/requestTypeAccess.ts` |
|
||||||
|
| Подавать заявки, пока действует заморозка | Группа SP «Тестирование» (Id 124) | группа SharePoint | Все замороженные формы (разморожены для всех: 1С, Подбор, Приём, Реферал, Рабочее место) | `shared/ui/SubmitActions/SubmitActions.tsx` (`SUBMISSION_FROZEN`, `UNFROZEN_FORMS`) + `testerGroupAccess.ts` |
|
||||||
|
| Открывать формы «в разработке» (`comingSoon`) | Группа SP «Тестирование» (Id 124) | группа SharePoint | Плитки и формы с `comingSoon` | `shared/useIsTester.ts`, `components/RequestGrid/RequestGrid.tsx`, `features/submit/SubmitFeature.tsx` |
|
||||||
|
| Отладочная подмена личности (портал работает от имени другого сотрудника) | Тот, кто может править свойства веб-части на странице (владельцы или редакторы сайта) | поле в свойствах веб-части | Весь портал; на экране красная плашка | `shared/impersonation.ts` |
|
||||||
|
|
||||||
|
## Как проверяются права по должности
|
||||||
|
|
||||||
|
Права «по должности» (приём, обходной лист) сравнивают **ключевые слова** в
|
||||||
|
должности шага маршрута: это колонка Title строки согласования, её подставляет
|
||||||
|
маршрут при планировании. Обязателен управленческий маркер
|
||||||
|
«начальник»/«руководитель», иначе право получили бы рядовые сотрудники того же
|
||||||
|
отдела.
|
||||||
|
|
||||||
|
Риск: опечатка или переформулировка должности в кадровом справочнике молча
|
||||||
|
отключит право. Случайное совпадение слов может выдать его не тому человеку.
|
||||||
|
|
||||||
|
## Что сделать при переезде на группы SharePoint
|
||||||
|
|
||||||
|
1. Под каждый набор прав завести группу на сайте данных (`/sites/requests`).
|
||||||
|
2. В коде проверять членство по **Id группы**, а не по Title, как уже сделано
|
||||||
|
в `reportsAccess.ts` и `testerGroupAccess.ts`.
|
||||||
|
3. Проверка должна учитывать отладочную подмену личности (`effectiveUserEmail`,
|
||||||
|
см. `reportsAccess.ts`). Иначе под подменой будут проверяться права
|
||||||
|
настоящего пользователя.
|
||||||
|
4. Обновить эту таблицу.
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- **Замена активного исполнителя (подбор).** Если прежний исполнитель держит
|
||||||
|
заявку открытой и, не обновив страницу, нажмёт «Выполнено», решение
|
||||||
|
запишется в шаг, который уже принадлежит новому исполнителю. При решении
|
||||||
|
пока не перепроверяется, что шаг всё ещё за этим человеком
|
||||||
|
(`ApproverService.decide`).
|
||||||
@@ -66,7 +66,7 @@ export const RequestGrid: React.FC<IRequestGridProps> = ({
|
|||||||
title="Все заявки этого типа"
|
title="Все заявки этого типа"
|
||||||
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
||||||
>
|
>
|
||||||
<Icon name="people" size="sm" />
|
<Icon name="menu" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
||||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||||
...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'people' } as ISidebarItem] : []),
|
...(canSeeAllRequests ? [{ id: 'allRequests', title: 'Все заявки', icon: 'menu' } as ISidebarItem] : []),
|
||||||
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
|
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
|
||||||
{ id: 'instructions', title: 'Инструкция пользователя', icon: 'info' },
|
{ id: 'instructions', title: 'Инструкция пользователя', icon: 'info' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getLinkedTypes,
|
getLinkedTypes,
|
||||||
getOwnRequests,
|
getOwnRequests,
|
||||||
getOwnRequestsByStatus,
|
getOwnRequestsByStatus,
|
||||||
|
hydrateCurrentAssignees,
|
||||||
hydrateDecisionComments,
|
hydrateDecisionComments,
|
||||||
hydratePendingAuthors,
|
hydratePendingAuthors,
|
||||||
searchOwnRequests,
|
searchOwnRequests,
|
||||||
@@ -131,11 +132,32 @@ function decisionStatusFor(view: ListView, statusFilter: string): string | undef
|
|||||||
interface ITableColumns {
|
interface ITableColumns {
|
||||||
author: boolean;
|
author: boolean;
|
||||||
status: boolean;
|
status: boolean;
|
||||||
|
/** Колонка «Исполнитель» — у кого заявка сейчас (см. showsAssignee). */
|
||||||
|
assignee: boolean;
|
||||||
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
/** Заголовок колонки с комментарием; undefined — колонки нет. */
|
||||||
commentLabel?: string;
|
commentLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Исполнитель» нужен там, где заявка может быть у кого угодно: свои поданные
|
||||||
|
// заявки, история своих решений (заявка ушла дальше по маршруту) и «Все
|
||||||
|
// заявки». В очередях согласования/исполнения и в «Доработке» держатель —
|
||||||
|
// всегда сам пользователь, в «Отклонённых мной» — никто: колонка вышла бы из
|
||||||
|
// одинаковых значений.
|
||||||
|
//
|
||||||
|
// На «Инициированных» с фильтром по статусу — только если при этом статусе у
|
||||||
|
// заявки вообще бывает держатель по маршруту. При «Возвращена на доработку»
|
||||||
|
// это всегда сам автор, при итоговых статусах — никто.
|
||||||
|
const ASSIGNEE_VIEWS: ListView[] = ['own:created', 'own:approved', 'own:executed', 'all'];
|
||||||
|
const STATUSES_WITH_ROUTE_HOLDER: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||||||
|
|
||||||
|
function showsAssignee(view: ListView, statusFilter: string): boolean {
|
||||||
|
if (ASSIGNEE_VIEWS.indexOf(view) < 0) return false;
|
||||||
|
if (view === 'own:created' && statusFilter) return STATUSES_WITH_ROUTE_HOLDER.indexOf(statusFilter) >= 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
||||||
|
const assignee = showsAssignee(view, statusFilter);
|
||||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||||
const commentLabel =
|
const commentLabel =
|
||||||
decisionStatus === REQUEST_STATUS.rejected
|
decisionStatus === REQUEST_STATUS.rejected
|
||||||
@@ -144,15 +166,15 @@ function columnsFor(view: ListView, statusFilter: string): ITableColumns {
|
|||||||
? 'Причина возврата'
|
? 'Причина возврата'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (view === 'approvals' || view === 'execution') return { author: true, status: false };
|
if (view === 'approvals' || view === 'execution') return { author: true, status: false, assignee };
|
||||||
if (view === 'rework') return { author: false, status: false, commentLabel };
|
if (view === 'rework') return { author: false, status: false, assignee, commentLabel };
|
||||||
if (view === 'own:created') return { author: false, status: !statusFilter, commentLabel };
|
if (view === 'own:created') return { author: false, status: !statusFilter, assignee, commentLabel };
|
||||||
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
// 'all' — заявки всех авторов вперемешку, поэтому нужны и автор, и статус.
|
||||||
if (view === 'all') return { author: true, status: true };
|
if (view === 'all') return { author: true, status: true, assignee };
|
||||||
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
// История решений: заявка чужая, поэтому нужен автор. Статус — только если он
|
||||||
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
// не предопределён самой вкладкой («Отклонённые мной» — это всегда «Отклонено»,
|
||||||
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
// а вот у согласованных/выполненных заявка могла уйти дальше по маршруту).
|
||||||
return { author: true, status: !decisionStatus, commentLabel };
|
return { author: true, status: !decisionStatus, assignee, commentLabel };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRequestsTableViewProps {
|
export interface IRequestsTableViewProps {
|
||||||
@@ -189,6 +211,11 @@ function formatDate(iso: string): string {
|
|||||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Человек в ячейке — «ФИО · почта»; колонки «Автор» и «Исполнитель». */
|
||||||
|
function personLabel(name: string, email: string): string {
|
||||||
|
return [name, email].filter(Boolean).join(' · ') || '—';
|
||||||
|
}
|
||||||
|
|
||||||
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
|
||||||
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
|
||||||
// (доступ с клавиатуры: Enter/Space).
|
// (доступ с клавиатуры: Enter/Space).
|
||||||
@@ -236,7 +263,7 @@ const Table: React.FC<{
|
|||||||
key: 'author',
|
key: 'author',
|
||||||
title: 'Автор',
|
title: 'Автор',
|
||||||
width: '280px',
|
width: '280px',
|
||||||
render: (r) => [r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'
|
render: (r) => personLabel(r.authorName, r.authorEmail)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (cols.status) {
|
if (cols.status) {
|
||||||
@@ -247,6 +274,22 @@ const Table: React.FC<{
|
|||||||
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
|
render: (r) => (r.status ? <StatusBadge status={r.status} size="sm" /> : '—')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (cols.assignee) {
|
||||||
|
columns.push({
|
||||||
|
key: 'assignee',
|
||||||
|
title: 'Исполнитель',
|
||||||
|
// Та же ширина, что у «Автора»: содержимое того же вида.
|
||||||
|
width: '280px',
|
||||||
|
// «ФИО · почта», как в колонке «Автор». Параллельный шаг — каждый
|
||||||
|
// человек отдельной строкой.
|
||||||
|
render: (r) =>
|
||||||
|
r.currentAssignees && r.currentAssignees.length > 0
|
||||||
|
? r.currentAssignees.map((a) => (
|
||||||
|
<div key={`${a.email}|${a.name}`}>{personLabel(a.name, a.email)}</div>
|
||||||
|
))
|
||||||
|
: '—'
|
||||||
|
});
|
||||||
|
}
|
||||||
if (cols.commentLabel) {
|
if (cols.commentLabel) {
|
||||||
columns.push({
|
columns.push({
|
||||||
key: 'comment',
|
key: 'comment',
|
||||||
@@ -522,9 +565,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
|
const fetchPage = async (onPartial?: (p: IPage) => void, reuseFull = false): Promise<IPage> => {
|
||||||
const result = await fetchRows(onPartial, reuseFull);
|
const result = await fetchRows(onPartial, reuseFull);
|
||||||
const decisionStatus = decisionStatusFor(view, statusFilter);
|
const decisionStatus = decisionStatusFor(view, statusFilter);
|
||||||
if (decisionStatus) {
|
// Оба дочитывания из строк маршрута независимы — параллельно.
|
||||||
await hydrateDecisionComments(context, types, result.rows, decisionStatus);
|
await Promise.all([
|
||||||
}
|
decisionStatus ? hydrateDecisionComments(context, types, result.rows, decisionStatus) : undefined,
|
||||||
|
showsAssignee(view, statusFilter) ? hydrateCurrentAssignees(context, types, result.rows) : undefined,
|
||||||
|
]);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/** Человек, у которого сейчас заявка (колонка «Исполнитель»). */
|
||||||
|
export interface IAssignee {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Строка заявки пользователя для боковой панели «Мои заявки».
|
// Строка заявки пользователя для боковой панели «Мои заявки».
|
||||||
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
|
||||||
export interface IMyRequest {
|
export interface IMyRequest {
|
||||||
@@ -61,4 +67,13 @@ export interface IMyRequest {
|
|||||||
* там, где показывается (см. hydrateDecisionComments).
|
* там, где показывается (см. hydrateDecisionComments).
|
||||||
*/
|
*/
|
||||||
decisionComment?: string;
|
decisionComment?: string;
|
||||||
|
/**
|
||||||
|
* Те, у кого заявка сейчас (колонка «Исполнитель»): согласующий или
|
||||||
|
* исполнитель текущего шага маршрута (несколько — параллельный шаг), при
|
||||||
|
* возврате на доработку — инициатор. Пустой массив — держателя нет (черновик,
|
||||||
|
* итоговый статус); undefined — не дочитывалось. Как и decisionComment, живёт
|
||||||
|
* в строках маршрута и дочитывается только там, где показывается (см.
|
||||||
|
* hydrateCurrentAssignees).
|
||||||
|
*/
|
||||||
|
currentAssignees?: IAssignee[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { DATA_SITE_URL } from '../../shared/config';
|
import { DATA_SITE_URL } from '../../shared/config';
|
||||||
import { IMyRequest } from './models/IMyRequest';
|
import { IAssignee, IMyRequest } from './models/IMyRequest';
|
||||||
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
|
||||||
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace';
|
||||||
import { perfScope, traced } from '../../shared/perfTrace';
|
import { perfScope, traced } from '../../shared/perfTrace';
|
||||||
@@ -101,9 +101,9 @@ const MP_CODE = 'RequestUniqueCodeOWSTEXT';
|
|||||||
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
|
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
|
||||||
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
|
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
|
||||||
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
|
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
|
||||||
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) отдаёт
|
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) — только
|
||||||
// готовое отображаемое имя — короче и надёжнее выковыривать его из
|
// запасной источник ФИО: в нём через «;» все, кто правил элемент, а не один
|
||||||
// MP_AUTHOR/parseAuthorOwsUser, там он нужен только ради почты.
|
// создатель (см. toMyRequestFromSearch); основной — MP_AUTHOR/parseAuthorOwsUser.
|
||||||
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1176,9 +1176,12 @@ function toMyRequestFromSearch(entry: ISearchEntry, kind: 'own' | 'other' = 'own
|
|||||||
title: (r.Title as string) || '',
|
title: (r.Title as string) || '',
|
||||||
status: (r[MP_REQUEST_STATUS] as string) || '',
|
status: (r[MP_REQUEST_STATUS] as string) || '',
|
||||||
authorEmail: author.email,
|
authorEmail: author.email,
|
||||||
// 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES);
|
// ФИО — из MP_AUTHOR, того же поля «Создано», откуда и почта. Встроенное
|
||||||
// из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст.
|
// свойство 'Author' НЕ годится основным источником: Search собирает в нём
|
||||||
authorName: ((r.Author as string) || author.name || '').trim(),
|
// через «;» всех, кто правил элемент, и у заявки, которую поправил
|
||||||
|
// согласующий, в колонке «Автор» оказывалось «Инициатор;Согласующий».
|
||||||
|
// 'Author' — только запасной вариант, и из него берём первое имя.
|
||||||
|
authorName: (author.name || ((r.Author as string) || '').split(';')[0] || '').trim(),
|
||||||
created: (r.Created as string) || '',
|
created: (r.Created as string) || '',
|
||||||
requestTypeId: entry.type.id,
|
requestTypeId: entry.type.id,
|
||||||
requestTypeName: entry.type.name,
|
requestTypeName: entry.type.name,
|
||||||
@@ -1578,9 +1581,10 @@ export async function getAllOwnRequests(
|
|||||||
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
|
return Array.from(byKey.values()).sort((a, b) => b.created.localeCompare(a.created));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сколько строк маршрута максимум забираем за раз при дочитывании комментариев:
|
// Сколько строк маршрута максимум забираем за раз при дочитывании по странице
|
||||||
// на странице ≤20 заявок, у каждой обычно единицы шагов — запас многократный.
|
// (комментарий к решению, текущий исполнитель): на странице ≤20 заявок, у
|
||||||
const DECISION_COMMENT_ROW_LIMIT = 2000;
|
// каждой обычно единицы шагов — запас многократный.
|
||||||
|
const PAGE_ROUTE_ROW_LIMIT = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проставляет decisionComment — комментарий согласующего к его решению по
|
* Проставляет decisionComment — комментарий согласующего к его решению по
|
||||||
@@ -1610,19 +1614,34 @@ export async function hydrateDecisionComments(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateDecisionCommentsInner(
|
/** Заявки страницы из одной партиции + список согласования этой партиции. */
|
||||||
|
interface IApprovalPartitionBatch {
|
||||||
|
approvalList: IList;
|
||||||
|
target: IListTarget;
|
||||||
|
typeName: string;
|
||||||
|
rows: IMyRequest[];
|
||||||
|
/** OData-условие «TicketId — одна из заявок пачки», уже в скобках. */
|
||||||
|
ticketFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разложить заявки по партициям и найти список согласования каждой — общая
|
||||||
|
* часть всех дочитываний из строк маршрута (комментарий к решению, текущий
|
||||||
|
* исполнитель).
|
||||||
|
*
|
||||||
|
* По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
|
||||||
|
* согласования той же партиции, что и сама заявка. Для архивной заявки
|
||||||
|
* «список согласования её типа» — это чужой список текущей партиции, где по
|
||||||
|
* её TicketId нашлись бы либо ничего, либо строки другой заявки.
|
||||||
|
*/
|
||||||
|
async function approvalPartitionsFor(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
types: IRequestType[],
|
types: IRequestType[],
|
||||||
rows: IMyRequest[],
|
rows: IMyRequest[]
|
||||||
decisionStatus: string
|
): Promise<IApprovalPartitionBatch[]> {
|
||||||
): Promise<void> {
|
|
||||||
const typeById = new Map<number, IRequestType>();
|
const typeById = new Map<number, IRequestType>();
|
||||||
types.forEach((t) => typeById.set(t.id, t));
|
types.forEach((t) => typeById.set(t.id, t));
|
||||||
|
|
||||||
// По ПАРТИЦИИ, а не по типу: строки маршрута заявки лежат в списке
|
|
||||||
// согласования той же партиции, что и сама заявка. Для архивной заявки
|
|
||||||
// «список согласования её типа» — это чужой список текущей партиции, где по
|
|
||||||
// её TicketId нашлись бы либо ничего, либо комментарий к другой заявке.
|
|
||||||
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
|
const rowsByPartition = new Map<string, { target: IListTarget; typeName: string; rows: IMyRequest[] }>();
|
||||||
rows.forEach((r) => {
|
rows.forEach((r) => {
|
||||||
const key = r.listId.toLowerCase();
|
const key = r.listId.toLowerCase();
|
||||||
@@ -1647,23 +1666,39 @@ async function hydrateDecisionCommentsInner(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
const batches: IApprovalPartitionBatch[] = [];
|
||||||
Array.from(rowsByPartition.values()).map(async ({ target, typeName, rows: typeRows }) => {
|
rowsByPartition.forEach(({ target, typeName, rows: partitionRows }) => {
|
||||||
try {
|
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
||||||
const lookup = located.get(approvalListKey(target.siteUrl, target.listId));
|
if (!lookup || lookup.kind !== 'ok') return;
|
||||||
if (!lookup || lookup.kind !== 'ok') return;
|
batches.push({
|
||||||
const approvalList = spForSite(target.siteUrl, context).web.lists.getByTitle(
|
approvalList: spForSite(target.siteUrl, context).web.lists.getByTitle(lookup.value.approvalListTitle),
|
||||||
lookup.value.approvalListTitle
|
target,
|
||||||
);
|
typeName,
|
||||||
|
rows: partitionRows,
|
||||||
|
// Скобки обязательны: у OData `and` связывает сильнее `or`, без них
|
||||||
|
// «(…) and ApprovalStatus eq …» у вызывающего разобралось бы как
|
||||||
|
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
|
||||||
|
ticketFilter: `(${partitionRows.map((r) => `TicketId eq ${r.id}`).join(' or ')})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
// TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает
|
async function hydrateDecisionCommentsInner(
|
||||||
// сильнее `or», без скобок фильтр разобрался бы как
|
context: WebPartContext,
|
||||||
// «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)».
|
types: IRequestType[],
|
||||||
const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ');
|
rows: IMyRequest[],
|
||||||
|
decisionStatus: string
|
||||||
|
): Promise<void> {
|
||||||
|
const batches = await approvalPartitionsFor(context, types, rows);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batches.map(async ({ approvalList, target, typeName, rows: typeRows, ticketFilter }) => {
|
||||||
|
try {
|
||||||
const stepRows = (await approvalList.items
|
const stepRows = (await approvalList.items
|
||||||
.filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`)
|
.filter(`${ticketFilter} and ApprovalStatus eq '${decisionStatus}'`)
|
||||||
.select('TicketId', 'Comment')
|
.select('TicketId', 'Comment')
|
||||||
.top(DECISION_COMMENT_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const commentByTicket = new Map<number, string>();
|
const commentByTicket = new Map<number, string>();
|
||||||
stepRows.forEach((r) => {
|
stepRows.forEach((r) => {
|
||||||
@@ -1691,6 +1726,88 @@ async function hydrateDecisionCommentsInner(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статусы ЗАЯВКИ, при которых она у кого-то в работе по маршруту: тогда
|
||||||
|
// держатель — человек из строки маршрута с текущей очередью.
|
||||||
|
const IN_ROUTE_REQUEST_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution];
|
||||||
|
|
||||||
|
// Статусы СТРОКИ маршрута «очередь дошла до этого человека» (см. createSteps/decide).
|
||||||
|
const CURRENT_TURN_STEP_STATUSES: string[] = [REQUEST_STATUS.onApproval, REQUEST_STATUS.onExecution];
|
||||||
|
|
||||||
|
// Внутреннее имя поля «Согласующий» строки маршрута (см. FIELDS.approver в
|
||||||
|
// shared/approval/approvalList.service.ts). При делегировании в нём уже
|
||||||
|
// делегат, поэтому отдельно смотреть DelegatedBy не нужно.
|
||||||
|
const STEP_APPROVER_FIELD = 'TansuSiteApprover';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проставляет currentAssignees — кто сейчас держит заявку:
|
||||||
|
* - на согласовании / выполнении — люди из строк маршрута с текущей очередью
|
||||||
|
* (несколько — параллельный шаг);
|
||||||
|
* - возвращена на доработку — инициатор;
|
||||||
|
* - иначе (черновик, итоговые статусы) — никого.
|
||||||
|
*
|
||||||
|
* Только для видимой страницы, как и hydrateDecisionComments: строки маршрута
|
||||||
|
* по сотням заявок сразу не запросить (предел длины URL у $filter). Мутирует
|
||||||
|
* переданные строки.
|
||||||
|
*/
|
||||||
|
export async function hydrateCurrentAssignees(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
|
rows.forEach((r) => {
|
||||||
|
const isReturned = r.status === REQUEST_STATUS.returned && (r.authorName || r.authorEmail);
|
||||||
|
r.currentAssignees = isReturned ? [{ name: r.authorName, email: r.authorEmail }] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const inRoute = rows.filter((r) => IN_ROUTE_REQUEST_STATUSES.indexOf(r.status) >= 0);
|
||||||
|
if (inRoute.length === 0) return;
|
||||||
|
return traced('текущий исполнитель (REST)', () => hydrateCurrentAssigneesInner(context, types, inRoute));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateCurrentAssigneesInner(
|
||||||
|
context: WebPartContext,
|
||||||
|
types: IRequestType[],
|
||||||
|
rows: IMyRequest[]
|
||||||
|
): Promise<void> {
|
||||||
|
const batches = await approvalPartitionsFor(context, types, rows);
|
||||||
|
const statusFilter = CURRENT_TURN_STEP_STATUSES.map((s) => `ApprovalStatus eq '${s}'`).join(' or ');
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
batches.map(async ({ approvalList, target, typeName, rows: partitionRows, ticketFilter }) => {
|
||||||
|
try {
|
||||||
|
const stepRows = (await approvalList.items
|
||||||
|
.filter(`${ticketFilter} and (${statusFilter})`)
|
||||||
|
.select('TicketId', `${STEP_APPROVER_FIELD}/Title`, `${STEP_APPROVER_FIELD}/EMail`)
|
||||||
|
.expand(STEP_APPROVER_FIELD)
|
||||||
|
.top(PAGE_ROUTE_ROW_LIMIT)()) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const assigneesByTicket = new Map<number, IAssignee[]>();
|
||||||
|
stepRows.forEach((r) => {
|
||||||
|
const ticketId = r.TicketId as number | undefined;
|
||||||
|
const user = r[STEP_APPROVER_FIELD] as { Title?: string; EMail?: string } | undefined;
|
||||||
|
const assignee: IAssignee = { name: (user?.Title || '').trim(), email: (user?.EMail || '').trim() };
|
||||||
|
if (!ticketId || (!assignee.name && !assignee.email)) return;
|
||||||
|
const assignees = assigneesByTicket.get(ticketId) ?? [];
|
||||||
|
// Один человек может стоять в двух строках одного шага (напр. сам и
|
||||||
|
// как делегат) — в ячейке он нужен один раз.
|
||||||
|
const isDuplicate = assignees.some((a) => a.name === assignee.name && a.email === assignee.email);
|
||||||
|
if (!isDuplicate) assignees.push(assignee);
|
||||||
|
assigneesByTicket.set(ticketId, assignees);
|
||||||
|
});
|
||||||
|
|
||||||
|
partitionRows.forEach((r) => {
|
||||||
|
r.currentAssignees = assigneesByTicket.get(r.id) ?? [];
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`[Текущие заявки] «${typeName}» (${target.listId}): не удалось прочитать текущего исполнителя:`,
|
||||||
|
(e as Error).message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// «На согласовании»: список заявок уже отобран по строкам согласования
|
// «На согласовании»: список заявок уже отобран по строкам согласования
|
||||||
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
// (getPendingApprovalIndex — там же определяется, чья сейчас очередь,
|
||||||
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ describe('pageSignature', () => {
|
|||||||
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('смена текущего исполнителя меняет подпись — он показан колонкой «Исполнитель»', () => {
|
||||||
|
// Заявка ушла на следующий шаг: статус тот же («На согласовании»), а держатель другой.
|
||||||
|
const before = page({ rows: [request({ currentAssignees: [{ name: 'Петров Пётр', email: 'petrov@tnsu.kz' }] })] });
|
||||||
|
const after = page({ rows: [request({ currentAssignees: [{ name: 'Сидорова Анна', email: 'sidorova@tnsu.kz' }] })] });
|
||||||
|
expect(pageSignature(before)).not.toBe(pageSignature(after));
|
||||||
|
});
|
||||||
|
|
||||||
it('пустая страница имеет стабильную подпись', () => {
|
it('пустая страница имеет стабильную подпись', () => {
|
||||||
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
expect(pageSignature({ rows: [], totalRows: 0 })).toBe(pageSignature({ rows: [], totalRows: 0 }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,17 @@ export function writePage(key: string, page: IPage): void {
|
|||||||
export function pageSignature(page: IPage): string {
|
export function pageSignature(page: IPage): string {
|
||||||
return `${page.totalRows}|${page.rows
|
return `${page.totalRows}|${page.rows
|
||||||
.map((r) =>
|
.map((r) =>
|
||||||
[r.requestTypeId, r.id, r.code, r.title, r.status, r.created, r.authorName, r.decisionComment ?? ''].join('~')
|
[
|
||||||
|
r.requestTypeId,
|
||||||
|
r.id,
|
||||||
|
r.code,
|
||||||
|
r.title,
|
||||||
|
r.status,
|
||||||
|
r.created,
|
||||||
|
r.authorName,
|
||||||
|
r.decisionComment ?? '',
|
||||||
|
(r.currentAssignees ?? []).map((a) => `${a.name}/${a.email}`).join(','),
|
||||||
|
].join('~')
|
||||||
)
|
)
|
||||||
.join(';')}`;
|
.join(';')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
|
|||||||
className={styles.allRequestsButton}
|
className={styles.allRequestsButton}
|
||||||
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
onClick={() => onOpenAllRequests(allRequestsTypeId)}
|
||||||
>
|
>
|
||||||
<Icon name="people" size="sm" />
|
<Icon name="menu" size="sm" />
|
||||||
<span>Все заявки этого типа</span>
|
<span>Все заявки этого типа</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
canEditAllFields,
|
canEditAllFields,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
IMatrixRow,
|
IMatrixRow,
|
||||||
|
ISiteUser,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
ApproverDecision
|
ApproverDecision
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -192,6 +193,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
await saveAllFields();
|
await saveAllFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||||
|
const notify = (rowId?: number): void => {
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
notifyActiveStep(
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
itemId,
|
||||||
|
{
|
||||||
|
requestTypeId,
|
||||||
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
|
},
|
||||||
|
rowId
|
||||||
|
).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||||
const handleRemoveApprover = (row: IMatrixRow): void => {
|
const handleRemoveApprover = (row: IMatrixRow): void => {
|
||||||
if (!row.id) return;
|
if (!row.id) return;
|
||||||
@@ -202,6 +219,40 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Исполнителей тот же человек (routeEditRights.ts) заменяет и удаляет даже на
|
||||||
|
// АКТИВНОМ шаге — только в этой форме. Выполненный шаг не трогаем, а
|
||||||
|
// последнего невыполненного исполнителя можно только заменить (см.
|
||||||
|
// ApproverService.deleteExecutionStep).
|
||||||
|
const isPendingExecutor = (row: IMatrixRow): boolean =>
|
||||||
|
row.status === REQUEST_STATUS.queued || row.status === REQUEST_STATUS.onExecution;
|
||||||
|
const pendingExecutorsCount = executors.rows.filter(isPendingExecutor).length;
|
||||||
|
|
||||||
|
const handleReplaceExecutor = (row: IMatrixRow, user: ISiteUser): void => {
|
||||||
|
if (!row.id) return;
|
||||||
|
const rowId = row.id;
|
||||||
|
setRouteError(undefined);
|
||||||
|
approverService
|
||||||
|
.replaceExecutionStep(itemId, rowId, user)
|
||||||
|
.then((wasActive) => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
// Шаг уже был в работе — заявка сразу у нового исполнителя, сообщаем ему.
|
||||||
|
if (wasActive) notify(rowId);
|
||||||
|
})
|
||||||
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveExecutor = (row: IMatrixRow): void => {
|
||||||
|
if (!row.id) return;
|
||||||
|
setRouteError(undefined);
|
||||||
|
approverService
|
||||||
|
.deleteExecutionStep(itemId, row.id)
|
||||||
|
.then((activatedRowIds) => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
activatedRowIds.forEach((id) => notify(id));
|
||||||
|
})
|
||||||
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -243,15 +294,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
|
|
||||||
const sectionProps = { data, set, context };
|
const sectionProps = { data, set, context };
|
||||||
|
|
||||||
const notify = (): void => {
|
|
||||||
if (!requestTypeId) return;
|
|
||||||
notifyActiveStep(context, approval, itemId, {
|
|
||||||
requestTypeId,
|
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
|
||||||
}).catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
@@ -327,6 +369,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не назначены."
|
emptyText="Исполнители не назначены."
|
||||||
|
canReplaceRow={canRemoveApprover ? isPendingExecutor : undefined}
|
||||||
|
onReplaceRow={handleReplaceExecutor}
|
||||||
|
canRemoveRow={
|
||||||
|
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
|
||||||
|
}
|
||||||
|
onRemoveRow={handleRemoveExecutor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { IApprovalRow } from './models/IApprovalRow';
|
|||||||
import { IMatrixRow } from './models/IMatrixRow';
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
|
import { userSearchVariants } from './userSearchVariants';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
@@ -208,6 +209,19 @@ export class ApprovalListService {
|
|||||||
.update({ [FIELDS.status]: status, [FIELDS.date]: new Date().toISOString() });
|
.update({ [FIELDS.status]: status, [FIELDS.date]: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Замена человека в строке маршрута (см. ApproverService.replaceExecutionStep):
|
||||||
|
// меняются только «Согласующий» и должность (Title). Статус, порядок, фаза и
|
||||||
|
// дата активации остаются — шаг тот же, другой только исполнитель.
|
||||||
|
public async replaceApprover(rowId: number, approver: { email: string; position: string }): Promise<void> {
|
||||||
|
const approverId = await this.resolveUserId(approver.email);
|
||||||
|
await this.list()
|
||||||
|
.items.getById(rowId)
|
||||||
|
.update({
|
||||||
|
Title: approver.position || approver.email,
|
||||||
|
[`${FIELDS.approver}Id`]: approverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||||
@@ -323,13 +337,18 @@ export class ApprovalListService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поиск пользователей сайта по части имени — для пикера согласующего.
|
// Поиск пользователей сайта по части имени или почты — для пикера согласующего.
|
||||||
|
// Несколько написаний сразу: substringof по Title для кириллицы чувствителен к
|
||||||
|
// регистру (см. userSearchVariants). Почта в SharePoint хранится строчными —
|
||||||
|
// по ней достаточно строчного варианта.
|
||||||
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (q.length < 2) return [];
|
if (q.length < 2) return [];
|
||||||
const escaped = q.replace(/'/g, "''");
|
const escape = (s: string): string => s.replace(/'/g, "''");
|
||||||
|
const titleClauses = userSearchVariants(q).map((v) => `substringof('${escape(v)}',Title)`);
|
||||||
|
const emailClause = `substringof('${escape(q.toLowerCase())}',Email)`;
|
||||||
const users = (await getSP()
|
const users = (await getSP()
|
||||||
.web.siteUsers.filter(`substringof('${escaped}',Title) and PrincipalType eq 1`)
|
.web.siteUsers.filter(`(${titleClauses.concat(emailClause).join(' or ')}) and PrincipalType eq 1`)
|
||||||
.select('Id', 'Title', 'Email', 'LoginName')
|
.select('Id', 'Title', 'Email', 'LoginName')
|
||||||
.top(10)()) as Array<Record<string, unknown>>;
|
.top(10)()) as Array<Record<string, unknown>>;
|
||||||
return users.map((u) => ({
|
return users.map((u) => ({
|
||||||
|
|||||||
@@ -140,6 +140,77 @@ export class ApproverService {
|
|||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||||
|
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||||
|
* не трогаем (иначе можно «переоткрыть» закрытую заявку). Бросает с текстом
|
||||||
|
* для пользователя, если шаг не подходит.
|
||||||
|
*/
|
||||||
|
private editableExecutionRow(rows: IApprovalRow[], rowId: number): IApprovalRow {
|
||||||
|
const row = rows.filter((r) => r.id === rowId)[0];
|
||||||
|
if (!row || row.phase !== APPROVAL_PHASE.execution) {
|
||||||
|
throw new Error('Шаг не найден среди исполнителей — обновите заявку.');
|
||||||
|
}
|
||||||
|
if (row.status !== REQUEST_STATUS.queued && row.status !== REQUEST_STATUS.onExecution) {
|
||||||
|
throw new Error('Шаг уже выполнен — заменить или удалить его нельзя.');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заменить исполнителя на шаге фазы «Исполнение» — в т.ч. активном. Шаг
|
||||||
|
* остаётся тем же (порядок, статус), меняется только человек. Подключено
|
||||||
|
* только в «Заявке на подбор» (см. RecruitingSelectionView), право — по
|
||||||
|
* routeEditRights.ts.
|
||||||
|
*
|
||||||
|
* Возвращает, был ли шаг активным: тогда заявка сразу у нового исполнителя,
|
||||||
|
* и ему нужно отправить оповещение (это делает вызывающая сторона).
|
||||||
|
*/
|
||||||
|
public async replaceExecutionStep(requestId: number, rowId: number, user: ISiteUser): Promise<boolean> {
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const row = this.editableExecutionRow(rows, rowId);
|
||||||
|
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
|
||||||
|
await this.approval.replaceApprover(rowId, { email: user.email, position });
|
||||||
|
emitRequestsChanged();
|
||||||
|
return row.status === REQUEST_STATUS.onExecution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить исполнителя с шага фазы «Исполнение» — в т.ч. активного. Если
|
||||||
|
* удалён активный, очередь переходит к следующему исполнителю.
|
||||||
|
*
|
||||||
|
* Последнего невыполненного исполнителя удалить нельзя — только заменить: без
|
||||||
|
* него все оставшиеся шаги окажутся выполненными (или их не останется вовсе),
|
||||||
|
* и computeRequestStatus закрыл бы заявку, хотя её никто не исполнил.
|
||||||
|
*
|
||||||
|
* Возвращает Id строк, ставших активными после удаления, — им нужно
|
||||||
|
* оповещение (отправляет вызывающая сторона).
|
||||||
|
*/
|
||||||
|
public async deleteExecutionStep(requestId: number, rowId: number): Promise<number[]> {
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
this.editableExecutionRow(rows, rowId);
|
||||||
|
|
||||||
|
const othersPending = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.id !== rowId &&
|
||||||
|
r.phase === APPROVAL_PHASE.execution &&
|
||||||
|
(r.status === REQUEST_STATUS.queued || r.status === REQUEST_STATUS.onExecution)
|
||||||
|
);
|
||||||
|
if (othersPending.length === 0) {
|
||||||
|
throw new Error('Это последний исполнитель заявки — его можно только заменить.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBefore = new Set(rows.filter((r) => r.status === REQUEST_STATUS.onExecution).map((r) => r.id));
|
||||||
|
await this.approval.deleteRow(rowId);
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
|
||||||
|
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||||
|
emitRequestsChanged();
|
||||||
|
return rowsAfter
|
||||||
|
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
|
||||||
|
.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Применяет решение: обновляет строку, продвигает очередь (на следующего
|
// Применяет решение: обновляет строку, продвигает очередь (на следующего
|
||||||
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
|
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
|
||||||
// только что завершилась целиком) и пересчитывает статус заявки.
|
// только что завершилась целиком) и пересчитывает статус заявки.
|
||||||
|
|||||||
@@ -58,16 +58,23 @@ async function isNotifyAllowed(approval: ApprovalListService, approverId: number
|
|||||||
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
|
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
|
||||||
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
|
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
|
||||||
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
|
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
|
||||||
|
//
|
||||||
|
// rowId — уведомить именно эту активную строку, а не первую попавшуюся: при
|
||||||
|
// замене/удалении исполнителя на параллельном шаге активных строк несколько, и
|
||||||
|
// письмо должно уйти новому человеку, а не тому, кто уже его получил.
|
||||||
export async function notifyActiveStep(
|
export async function notifyActiveStep(
|
||||||
context: WebPartContext,
|
context: WebPartContext,
|
||||||
approval: ApprovalListService,
|
approval: ApprovalListService,
|
||||||
requestId: number,
|
requestId: number,
|
||||||
target: INotifyTarget
|
target: INotifyTarget,
|
||||||
|
rowId?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rows = await approval.getForRequest(requestId);
|
const rows = await approval.getForRequest(requestId);
|
||||||
const active = rows.filter(
|
const active = rows.filter(
|
||||||
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
|
(r) =>
|
||||||
|
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
|
||||||
|
(rowId === undefined || r.id === rowId)
|
||||||
)[0];
|
)[0];
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ISiteUser } from '../models/ISiteUser';
|
|||||||
import { IMatrixRow } from '../models/IMatrixRow';
|
import { IMatrixRow } from '../models/IMatrixRow';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
|
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
||||||
import { useFlipRows } from './useFlipRows';
|
import { useFlipRows } from './useFlipRows';
|
||||||
|
|
||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
@@ -44,6 +45,15 @@ export interface IApprovalMatrixProps {
|
|||||||
* сервер (см. RecruitingHireView.tsx и ApproverService.deleteQueuedStep).
|
* сервер (см. RecruitingHireView.tsx и ApproverService.deleteQueuedStep).
|
||||||
*/
|
*/
|
||||||
onRemoveRow?: (row: IMatrixRow, index: number) => void;
|
onRemoveRow?: (row: IMatrixRow, index: number) => void;
|
||||||
|
/**
|
||||||
|
* Может ли ТЕКУЩИЙ пользователь заменить человека в ЭТОЙ строке — как и
|
||||||
|
* canRemoveRow, независимо от editable. Не задан — кнопки «Заменить» нет
|
||||||
|
* (так у всех форм, кроме тех, что подключили замену сами, напр.
|
||||||
|
* RecruitingSelectionView → ApproverService.replaceExecutionStep).
|
||||||
|
*/
|
||||||
|
canReplaceRow?: (row: IMatrixRow, index: number) => boolean;
|
||||||
|
/** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */
|
||||||
|
onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
||||||
@@ -58,8 +68,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
waitingText = '',
|
waitingText = '',
|
||||||
canRemoveRow,
|
canRemoveRow,
|
||||||
onRemoveRow,
|
onRemoveRow,
|
||||||
|
canReplaceRow,
|
||||||
|
onReplaceRow,
|
||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable, blocks, retry } = editor;
|
const { rows, loading, error, editable, blocks, retry } = editor;
|
||||||
|
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
|
||||||
|
const [replacingKey, setReplacingKey] = React.useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
|
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
|
||||||
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
|
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
|
||||||
@@ -129,9 +143,18 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
// в useMatrixEditor (см. resolveApproverOrgInfo); остальные формы их не видят.
|
// в useMatrixEditor (см. resolveApproverOrgInfo); остальные формы их не видят.
|
||||||
const hasDepartment = rows.some((r) => !!r.department);
|
const hasDepartment = rows.some((r) => !!r.department);
|
||||||
const hasOrganization = rows.some((r) => !!r.organization);
|
const hasOrganization = rows.some((r) => !!r.organization);
|
||||||
// Колонка действий с кнопкой удаления нужна даже вне editable, если право
|
// Колонка действий с кнопками удаления/замены нужна даже вне editable, если
|
||||||
// удалять есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
|
// такое право есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
|
||||||
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i));
|
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i) || !!canReplaceRow?.(r, i));
|
||||||
|
|
||||||
|
// Строка, которую заменяем, — по ключу, а не по индексу: пока открыт выбор,
|
||||||
|
// матрица может перечитаться, и индекс указал бы уже на другого человека.
|
||||||
|
const replacingIndex = replacingKey === undefined ? -1 : rowKeys.indexOf(replacingKey);
|
||||||
|
const replacingRow = replacingIndex >= 0 ? rows[replacingIndex] : undefined;
|
||||||
|
const pickReplacement = (user: ISiteUser): void => {
|
||||||
|
setReplacingKey(undefined);
|
||||||
|
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
|
||||||
|
};
|
||||||
|
|
||||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
||||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
||||||
@@ -273,6 +296,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<Icon name="user-check" size="sm" />
|
<Icon name="user-check" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canReplaceRow?.(r, i) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${replacingKey === rowKeys[i] ? styles.iconBtnActive : ''}`}
|
||||||
|
onClick={() => setReplacingKey(replacingKey === rowKeys[i] ? undefined : rowKeys[i])}
|
||||||
|
title="Заменить"
|
||||||
|
>
|
||||||
|
<Icon name="swap" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canRemoveRow?.(r, i) && (
|
{canRemoveRow?.(r, i) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -292,6 +325,15 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{replacingRow && !waiting && !loading && (
|
||||||
|
<ReplaceRowPanel
|
||||||
|
currentName={replacingRow.who}
|
||||||
|
roleLabel={roleLabel}
|
||||||
|
search={search}
|
||||||
|
onPick={pickReplacement}
|
||||||
|
onCancel={() => setReplacingKey(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{editable && !waiting && !loading && (
|
{editable && !waiting && !loading && (
|
||||||
<div className={styles.matrixPicker}>
|
<div className={styles.matrixPicker}>
|
||||||
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
|
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './approval.module.scss';
|
||||||
|
import { ISiteUser } from '../models/ISiteUser';
|
||||||
|
import { UserPicker } from './UserPicker';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Кого заменяем — ФИО из строки маршрута. */
|
||||||
|
currentName: string;
|
||||||
|
roleLabel: string;
|
||||||
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
|
onPick: (user: ISiteUser) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор нового человека для строки маршрута (кнопка «Заменить» в ApprovalMatrix).
|
||||||
|
// Отдельной панелью под таблицей, а не внутри ячейки: выпадающий список пикера
|
||||||
|
// в узкой ячейке не помещается, а на телефоне таблица и так идёт карточками.
|
||||||
|
export const ReplaceRowPanel: React.FC<Props> = ({ currentName, roleLabel, search, onPick, onCancel }) => (
|
||||||
|
<div className={styles.replacePanel}>
|
||||||
|
<div className={styles.replaceTitle}>
|
||||||
|
{roleLabel}: заменить «{currentName || 'не назначен'}» на
|
||||||
|
<button type="button" className={styles.retryBtn} onClick={onCancel}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<UserPicker search={search} onPick={onPick} placeholder="Начните вводить ФИО…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -316,6 +316,25 @@
|
|||||||
margin-top: $sp-3;
|
margin-top: $sp-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Замена человека в строке маршрута (ReplaceRowPanel) — под таблицей.
|
||||||
|
.replacePanel {
|
||||||
|
margin-top: $sp-3;
|
||||||
|
padding: $sp-3;
|
||||||
|
border: 1px solid $gold-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gold-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replaceTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-bottom: $sp-2;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Мобильные экраны: таблица маршрута → карточки ----
|
// ---- Мобильные экраны: таблица маршрута → карточки ----
|
||||||
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
|
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
|
||||||
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.
|
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { userSearchVariants } from './userSearchVariants';
|
||||||
|
|
||||||
|
describe('userSearchVariants', () => {
|
||||||
|
it('строчный ввод даёт и вариант с заглавной — так ФИО хранятся в SharePoint', () => {
|
||||||
|
expect(userSearchVariants('тимур')).toEqual(['тимур', 'Тимур']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заглавной делается каждое слово', () => {
|
||||||
|
expect(userSearchVariants('тимур городецкий')).toContain('Тимур Городецкий');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ввод капсом даёт и строчный, и «Как В Имени»', () => {
|
||||||
|
expect(userSearchVariants('ТИМУР')).toEqual(['ТИМУР', 'тимур', 'Тимур']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без повторов, если ввод уже в нужном виде', () => {
|
||||||
|
expect(userSearchVariants('Тимур')).toEqual(['Тимур', 'тимур']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой ввод — пусто', () => {
|
||||||
|
expect(userSearchVariants(' ')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Варианты написания запроса для поиска сотрудника (ApprovalListService.searchUsers).
|
||||||
|
*
|
||||||
|
* Фильтр SharePoint `substringof(…, Title)` по пользователям сайта для
|
||||||
|
* кириллицы чувствителен к регистру: «тимур» не находит «Тимур Городецкий».
|
||||||
|
* Поэтому ищем сразу несколько написаний — как введено, строчными и «Как В
|
||||||
|
* Имени» (с заглавной каждое слово, так ФИО и хранятся в Title). Повторы
|
||||||
|
* убираются, чтобы не раздувать фильтр.
|
||||||
|
*/
|
||||||
|
export function userSearchVariants(query: string): string[] {
|
||||||
|
const typed = query.trim();
|
||||||
|
if (!typed) return [];
|
||||||
|
const lower = typed.toLowerCase();
|
||||||
|
const capitalized = lower
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const variants: string[] = [];
|
||||||
|
[typed, lower, capitalized].forEach((v) => {
|
||||||
|
if (variants.indexOf(v) < 0) variants.push(v);
|
||||||
|
});
|
||||||
|
return variants;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user