Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57efa7620e | |||
| cfe97dc67e |
@@ -19,6 +19,7 @@
|
|||||||
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
|
||||||
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
|
||||||
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
|
||||||
|
| Менять местами шаги **в поданной заявке** (и согласующих, и исполнителей): только соседние шаги одной фазы, оба «Ждут очереди»; активный и пройденные шаги не двигаются | те же (`canEditRoute`) | email в коде | **Только** Подбор и Приём | `canEditRoute` + `shared/approval/queuedStepMove.ts` (правило) + `RecruitingSelectionView.tsx` / `RecruitingHireView.tsx` |
|
||||||
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
|
||||||
| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` |
|
| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` |
|
||||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
|
|||||||
@@ -154,11 +154,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
const requestTypeId = requestTypeIdState.id;
|
const requestTypeId = requestTypeIdState.id;
|
||||||
// Маршрут для этого типа заявки зависит от проекта (ByProject) — без projectId
|
// Без projectId бэкенд отвечал resolve: 404 «…и признака «по проекту» не
|
||||||
// бэкенд отвечает resolve: 404 «…и признака «по проекту» не найден» (поле
|
// найден». Сейчас в маршруте условий по проекту нет, но проверка projectId на
|
||||||
// Project изначально задумывалось справочным, но выяснилось, что маршрут
|
// бэкенде не подтверждена снятой — поэтому передаём ПЕРВЫЙ выбранный проект
|
||||||
// всё-таки требует projectId, как в RecruitingSelection/TmcTransfer).
|
// (поле — мультивыбор) и ждём его выбора, как раньше.
|
||||||
const projectId = data.ProjectId || null;
|
const projectId = data.Projects.length > 0 ? data.Projects[0].Id : null;
|
||||||
const matrixReady = !!editId || (!!projectId && !!requestTypeId);
|
const matrixReady = !!editId || (!!projectId && !!requestTypeId);
|
||||||
// Несколько шагов маршрута условны по компании (см. панель «Маршруты»:
|
// Несколько шагов маршрута условны по компании (см. панель «Маршруты»:
|
||||||
// «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с
|
// «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с
|
||||||
@@ -166,15 +166,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
// fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не
|
// fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не
|
||||||
// массив — ResolveFieldValueDto на бэкенде принимает только строку, массив
|
// массив — ResolveFieldValueDto на бэкенде принимает только строку, массив
|
||||||
// даёт 400; сериализованный JSON не совпадает с условием при сравнении).
|
// даёт 400; сериализованный JSON не совпадает с условием при сравнении).
|
||||||
// Плюс шаг «Роль: Планировщик» условен по названию проекта («Project не
|
// Условие по названию проекта («Project не содержит офис») из маршрута убрано,
|
||||||
// содержит офис») — сюда идёт ТЕКСТ (data.Project), а не projectId (GUID),
|
// но Project передаём по-прежнему — на случай, если такое условие вернут в
|
||||||
// который уже передаётся отдельным параметром resolve.
|
// админке: ТЕКСТ названий всех проектов через пробел (как у компаний), а не
|
||||||
|
// GUID — тот уходит отдельным параметром resolve.
|
||||||
const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' ');
|
const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' ');
|
||||||
|
const projectsFieldValue = data.Projects.map((p) => p.Name).join(' ');
|
||||||
const fieldValues = useMemo(() => {
|
const fieldValues = useMemo(() => {
|
||||||
const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : [];
|
const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : [];
|
||||||
return data.Project ? [...companies, { fieldName: 'Project', value: data.Project }] : companies;
|
return projectsFieldValue ? [...companies, { fieldName: 'Project', value: projectsFieldValue }] : companies;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- companiesFieldValue — стабильный слепок data.Companies
|
}, [companiesFieldValue, projectsFieldValue]);
|
||||||
}, [companiesFieldValue, data.Project]);
|
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
editId
|
editId
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import {
|
|||||||
canEditAllFields,
|
canEditAllFields,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
IMatrixRow,
|
IMatrixRow,
|
||||||
ApproverDecision
|
ApproverDecision,
|
||||||
|
queuedStepMoveRule,
|
||||||
|
useQueuedStepMove
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
@@ -267,6 +269,15 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Поменять местами ждущие шаги (согласующих или исполнителей) — то же право
|
||||||
|
// routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts.
|
||||||
|
const handleMoveStep = useQueuedStepMove(
|
||||||
|
approverService,
|
||||||
|
itemId,
|
||||||
|
() => setDecisionSeq((n) => n + 1),
|
||||||
|
setRouteError
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -385,6 +396,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
||||||
onRemoveRow={handleRemoveApprover}
|
onRemoveRow={handleRemoveApprover}
|
||||||
|
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
|
||||||
|
onMoveRow={handleMoveStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
@@ -394,6 +407,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не назначены."
|
emptyText="Исполнители не назначены."
|
||||||
|
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
|
||||||
|
onMoveRow={handleMoveStep}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ function companyName(data: IFormData, companyId: string): string {
|
|||||||
/**
|
/**
|
||||||
* Что пишем в журнал правок согласующего (shared/fieldChanges).
|
* Что пишем в журнал правок согласующего (shared/fieldChanges).
|
||||||
*
|
*
|
||||||
* Без своего поля на форме (не пишем): Id/код проекта — меняются вместе с
|
* Без своего поля на форме (не пишем): связь с подбором — меняется вместе с её
|
||||||
* «Проект»; связь с подбором — вместе с её кодом (1.1); HireOnArrival — флажок
|
* кодом (1.1); HireOnArrival — флажок внутри поля 2.2, его правку видно по
|
||||||
* внутри поля 2.2, его правку видно по самой дате.
|
* самой дате. Проекты (Projects) пишутся как есть — списком названий.
|
||||||
*/
|
*/
|
||||||
export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
|
export const HIRE_CHANGE_TRACKING: IChangeTrackingConfig<IFormData> = {
|
||||||
ignore: ['ProjectId', 'ProjectCode', 'SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'],
|
ignore: ['SelectionRequestId', 'SelectionRequestListId', 'HireOnArrival'],
|
||||||
formatters: {
|
formatters: {
|
||||||
MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE),
|
MainCompanyId: (value, data) => (value ? companyName(data, String(value)) : EMPTY_VALUE),
|
||||||
CompanyShares: (value, data) => {
|
CompanyShares: (value, data) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { spDateOnlyToIso } from '../../shared/dateUtils';
|
|||||||
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, ICompanyShare, LEGACY_HIRE_TYPES } from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
import { projectsFromItem } from './projects';
|
||||||
|
|
||||||
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
|
||||||
function parseJsonArray<T>(raw: unknown): T[] {
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
@@ -20,7 +21,7 @@ function parseJsonArray<T>(raw: unknown): T[] {
|
|||||||
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
|
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
|
||||||
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||||
'MainCompanyId',
|
'MainCompanyId',
|
||||||
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
|
'Phone', 'HireType', 'HireReplacedEmployee',
|
||||||
'HireCandidateName', 'HireHireDate', 'Position',
|
'HireCandidateName', 'HireHireDate', 'Position',
|
||||||
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
'Department', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'ProbationPeriodOther',
|
||||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||||
@@ -54,6 +55,11 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
...SCALAR_FIELDS,
|
...SCALAR_FIELDS,
|
||||||
'CompaniesJson',
|
'CompaniesJson',
|
||||||
'CompanySharesJson',
|
'CompanySharesJson',
|
||||||
|
// Старые одиночные колонки — для заявок, поданных до мультивыбора (см. projects.ts).
|
||||||
|
'ProjectsJson',
|
||||||
|
'Project',
|
||||||
|
'ProjectId',
|
||||||
|
'ProjectCode',
|
||||||
'RecruitingAgency',
|
'RecruitingAgency',
|
||||||
'HireOnArrival',
|
'HireOnArrival',
|
||||||
APPROVER_CHANGES_FIELD,
|
APPROVER_CHANGES_FIELD,
|
||||||
@@ -68,6 +74,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
|
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
|
||||||
// % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson.
|
// % ставки по компаниям (совмещение) — аналогично, Note-колонка CompanySharesJson.
|
||||||
(data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson);
|
(data as unknown as Record<string, unknown>).CompanyShares = parseJsonArray<ICompanyShare>(item.CompanySharesJson);
|
||||||
|
data.Projects = projectsFromItem(item);
|
||||||
// Флаг агентства — булево поле (Yes/No), не строка.
|
// Флаг агентства — булево поле (Yes/No), не строка.
|
||||||
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
|
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
|
||||||
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
|
(data as unknown as Record<string, unknown>).HireOnArrival = !!item.HireOnArrival;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { ISelectOption } from '../../shared/ui';
|
import { ISelectOption } from '../../shared/ui';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany, IProjectRef } from '../../shared/dictionary';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,11 +38,14 @@ export interface IFormData {
|
|||||||
* там имя осталось прежним.
|
* там имя осталось прежним.
|
||||||
*/
|
*/
|
||||||
HireReplacedEmployee: string;
|
HireReplacedEmployee: string;
|
||||||
// Проект/объект (справочник /Project) — справочное поле, на маршрут
|
/**
|
||||||
// согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer).
|
* Проекты/объекты (справочник /Project), мультивыбор — хранится Note-колонкой
|
||||||
Project: string;
|
* ProjectsJson. Старые колонки Project/ProjectId/ProjectCode тоже заполняются
|
||||||
ProjectId: string; // oid проекта ('' — если введён вручную без выбора из списка)
|
* (см. projects.ts) — по ним заявки видны в списке SP, отчётах и прежнем коде.
|
||||||
ProjectCode: string;
|
* На состав маршрута проект больше не влияет (шаг с условием по Project убран
|
||||||
|
* из маршрута в админке), но первый проект по-прежнему уходит в resolve.
|
||||||
|
*/
|
||||||
|
Projects: IProjectRef[];
|
||||||
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
|
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
|
||||||
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
|
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
|
||||||
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
|
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
|
||||||
@@ -95,9 +98,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Phone: '',
|
Phone: '',
|
||||||
HireType: '',
|
HireType: '',
|
||||||
HireReplacedEmployee: '',
|
HireReplacedEmployee: '',
|
||||||
Project: '',
|
Projects: [],
|
||||||
ProjectId: '',
|
|
||||||
ProjectCode: '',
|
|
||||||
SelectionRequestId: '',
|
SelectionRequestId: '',
|
||||||
SelectionRequestListId: '',
|
SelectionRequestListId: '',
|
||||||
SelectionRequestCode: '',
|
SelectionRequestCode: '',
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { projectColumns, projectNames, projectsFromItem } from './projects';
|
||||||
|
|
||||||
|
const KES = { Id: 'g-kes', Name: 'КЭС Курчатов', Code: 'БК0000068' };
|
||||||
|
const OFFICE = { Id: 'g-office', Name: 'Офис TANSU', Code: '' };
|
||||||
|
|
||||||
|
describe('RecruitingHire projects', () => {
|
||||||
|
it('несколько проектов: JSON целиком, старые колонки — названия/коды через запятую, Id первого', () => {
|
||||||
|
expect(projectColumns([KES, OFFICE])).toEqual({
|
||||||
|
ProjectsJson: JSON.stringify([KES, OFFICE]),
|
||||||
|
Project: 'КЭС Курчатов, Офис TANSU',
|
||||||
|
ProjectId: 'g-kes',
|
||||||
|
ProjectCode: 'БК0000068'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без проектов все колонки очищаются', () => {
|
||||||
|
expect(projectColumns([])).toEqual({ ProjectsJson: null, Project: null, ProjectId: null, ProjectCode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('длинный список названий обрезается под 255 символов текстовой колонки', () => {
|
||||||
|
const long = Array.from({ length: 5 }, (_, i) => ({ Id: `g${i}`, Name: 'Строительство объекта '.repeat(4), Code: '' }));
|
||||||
|
const columns = projectColumns(long);
|
||||||
|
expect((columns.Project as string).length).toBe(255);
|
||||||
|
expect(columns.ProjectsJson).toBe(JSON.stringify(long));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('чтение: ProjectsJson приоритетнее старых колонок', () => {
|
||||||
|
expect(projectsFromItem({ ProjectsJson: JSON.stringify([KES, OFFICE]), Project: 'старое', ProjectId: 'x' })).toEqual([
|
||||||
|
KES,
|
||||||
|
OFFICE
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('чтение заявки до мультивыбора: один проект из старых колонок', () => {
|
||||||
|
expect(projectsFromItem({ Project: 'КЭС Курчатов', ProjectId: 'g-kes', ProjectCode: 'БК0000068' })).toEqual([KES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старая заявка с проектом, введённым вручную (без Id), — проектов нет', () => {
|
||||||
|
expect(projectsFromItem({ Project: 'что-то вручную', ProjectId: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('битый JSON не роняет загрузку', () => {
|
||||||
|
expect(projectsFromItem({ ProjectsJson: '{не json', ProjectId: 'g-kes', Project: 'КЭС Курчатов' })).toEqual([
|
||||||
|
{ Id: 'g-kes', Name: 'КЭС Курчатов', Code: '' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('названия одной строкой', () => {
|
||||||
|
expect(projectNames([KES, OFFICE])).toBe('КЭС Курчатов, Офис TANSU');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { IProjectRef } from '../../shared/dictionary';
|
||||||
|
import { truncateSpText } from '../../shared/requestTitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проекты заявки на приём: источник правды — массив Projects (колонка
|
||||||
|
* ProjectsJson). Старые одиночные колонки Project/ProjectId/ProjectCode
|
||||||
|
* выводятся из него при записи — их читают представления списка SP, отчёты и
|
||||||
|
* заявки, поданные до мультивыбора.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Разделитель нескольких значений в старых текстовых колонках. */
|
||||||
|
const LEGACY_SEPARATOR = ', ';
|
||||||
|
|
||||||
|
/** Колонки ProjectsJson + старые одиночные колонки — для записи в список данных. */
|
||||||
|
export interface IProjectColumns {
|
||||||
|
ProjectsJson: string | null; // eslint-disable-line @rushstack/no-new-null -- null очищает колонку SP
|
||||||
|
Project: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
|
||||||
|
/** Только первый проект: колонка задумана под один GUID, склейка GUID-ов бесполезна для поиска. */
|
||||||
|
ProjectId: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
|
||||||
|
ProjectCode: string | null; // eslint-disable-line @rushstack/no-new-null -- см. выше
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectColumns(projects: IProjectRef[]): IProjectColumns {
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return { ProjectsJson: null, Project: null, ProjectId: null, ProjectCode: null };
|
||||||
|
}
|
||||||
|
const codes = projects.map((p) => p.Code).filter(Boolean);
|
||||||
|
// Текстовые колонки — до 255 символов: несколько длинных названий иначе
|
||||||
|
// уронили бы сохранение. Полный список всегда есть в ProjectsJson.
|
||||||
|
return {
|
||||||
|
ProjectsJson: JSON.stringify(projects),
|
||||||
|
Project: truncateSpText(projectNames(projects)),
|
||||||
|
ProjectId: projects[0].Id,
|
||||||
|
ProjectCode: codes.length > 0 ? truncateSpText(codes.join(LEGACY_SEPARATOR)) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Названия проектов одной строкой — для заголовка заявки и старой колонки Project. */
|
||||||
|
export function projectNames(projects: IProjectRef[]): string {
|
||||||
|
return projects.map((p) => p.Name).join(LEGACY_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проекты из элемента списка. Заявки, поданные до мультивыбора, ProjectsJson не
|
||||||
|
* имеют — тогда собираем один проект из старых колонок (если он был выбран из
|
||||||
|
* справочника, т.е. есть ProjectId).
|
||||||
|
*/
|
||||||
|
export function projectsFromItem(item: Record<string, unknown>): IProjectRef[] {
|
||||||
|
const fromJson = parseProjectsJson(item.ProjectsJson);
|
||||||
|
if (fromJson.length > 0) return fromJson;
|
||||||
|
|
||||||
|
const id = typeof item.ProjectId === 'string' ? item.ProjectId : '';
|
||||||
|
if (!id) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
Id: id,
|
||||||
|
Name: typeof item.Project === 'string' ? item.Project : '',
|
||||||
|
Code: typeof item.ProjectCode === 'string' ? item.ProjectCode : ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProjectsJson(raw: unknown): IProjectRef[] {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? (parsed as IProjectRef[]).filter((p) => !!p && !!p.Id) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] },
|
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного сотрудника", "Вместо переведенного сотрудника", "Декрет"] },
|
||||||
{ "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
|
{ "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "ProjectsJson", "title": "Проекты (JSON)", "type": "Note" },
|
||||||
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
||||||
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
|
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
|
||||||
{ "internalName": "ProjectCode", "title": "Проект — код", "type": "Text" },
|
{ "internalName": "ProjectCode", "title": "Проект — код", "type": "Text" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
|
||||||
import { CompanyPickerMulti, ProjectPicker, ICompany } from '../../../shared/dictionary';
|
import { CompanyPickerMulti, ProjectPickerMulti, ICompany } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model';
|
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT, HIRE_TYPE_TRANSFER, HIRE_TYPE_MATERNITY } from '../model';
|
||||||
import { SelectionRequestField } from './SelectionRequestField';
|
import { SelectionRequestField } from './SelectionRequestField';
|
||||||
import { CompanySharesField } from './CompanySharesField';
|
import { CompanySharesField } from './CompanySharesField';
|
||||||
@@ -59,16 +59,12 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
shares={data.CompanyShares}
|
shares={data.CompanyShares}
|
||||||
onSharesChange={(v) => set('CompanyShares', v)}
|
onSharesChange={(v) => set('CompanyShares', v)}
|
||||||
/>
|
/>
|
||||||
<FormField label="1.3 Проект" htmlFor="Project" required full>
|
<FormField label="1.3 Проект" htmlFor="Projects" required full>
|
||||||
<ProjectPicker
|
<ProjectPickerMulti
|
||||||
context={context}
|
context={context}
|
||||||
id="Project"
|
id="Projects"
|
||||||
value={data.Project}
|
value={data.Projects}
|
||||||
onChange={(name, id, project) => {
|
onChange={(projects) => set('Projects', projects)}
|
||||||
set('Project', name);
|
|
||||||
set('ProjectId', id);
|
|
||||||
set('ProjectCode', project ? project.code : '');
|
|
||||||
}}
|
|
||||||
placeholder="Начните вводить название или код проекта…"
|
placeholder="Начните вводить название или код проекта…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
@@ -99,9 +99,13 @@ export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ d
|
|||||||
set('Companies', selection.Companies);
|
set('Companies', selection.Companies);
|
||||||
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
|
set('MainCompanyId', ''); // компании заменены целиком — зависимые поля пересчитывать не от чего
|
||||||
set('CompanyShares', []);
|
set('CompanyShares', []);
|
||||||
set('Project', selection.ObjectProject);
|
// В подборе проект один — он становится единственным выбранным проектом приёма.
|
||||||
set('ProjectId', selection.ObjectProjectId);
|
set(
|
||||||
set('ProjectCode', selection.ObjectProjectCode);
|
'Projects',
|
||||||
|
selection.ObjectProjectId
|
||||||
|
? [{ Id: selection.ObjectProjectId, Name: selection.ObjectProject, Code: selection.ObjectProjectCode }]
|
||||||
|
: []
|
||||||
|
);
|
||||||
set('Position', selection.Position);
|
set('Position', selection.Position);
|
||||||
set('Department', selection.Department);
|
set('Department', selection.Department);
|
||||||
set('WorkSchedule', selection.WorkSchedule);
|
set('WorkSchedule', selection.WorkSchedule);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/f
|
|||||||
import { buildRequestTitle } from '../../shared/requestTitle';
|
import { buildRequestTitle } from '../../shared/requestTitle';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
import { projectColumns, projectNames } from './projects';
|
||||||
|
|
||||||
// Числовые поля: пустая строка → null, иначе Number().
|
// Числовые поля: пустая строка → null, иначе Number().
|
||||||
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
|
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
|
||||||
@@ -17,9 +18,9 @@ export function hireApprovalService(listId?: string): ApprovalListService {
|
|||||||
return approvalServiceFor(FORM_ID, listId);
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Название заявки (Title): «ФИО кандидата — Должность — Проект». */
|
/** Название заявки (Title): «ФИО кандидата — Должность — Проекты». */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return buildRequestTitle([data.HireCandidateName, data.Position, data.Project]);
|
return buildRequestTitle([data.HireCandidateName, data.Position, projectNames(data.Projects)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */
|
||||||
@@ -37,6 +38,11 @@ function buildFields(data: IFormData): Record<string, unknown> {
|
|||||||
item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null;
|
item.CompanySharesJson = data.CompanyShares.length > 0 ? JSON.stringify(data.CompanyShares) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'Projects') {
|
||||||
|
// Мультивыбор проектов — Note ProjectsJson + старые одиночные колонки.
|
||||||
|
Object.assign(item, projectColumns(data.Projects));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const value = data[key];
|
const value = data[key];
|
||||||
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||||
item[key] = value === '' ? null : Number(value);
|
item[key] = value === '' ? null : Number(value);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const DRAFT_READY: IFormData = {
|
|||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
HireCandidateName: 'Иванов Иван Иванович',
|
HireCandidateName: 'Иванов Иван Иванович',
|
||||||
Position: 'Инженер',
|
Position: 'Инженер',
|
||||||
ProjectId: '42'
|
Projects: [{ Id: '42', Name: 'КЭС Курчатов', Code: 'БК0000068' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label);
|
const labels = (issues: { label: string }[]): string[] => issues.map((i) => i.label);
|
||||||
@@ -32,9 +32,9 @@ describe('RecruitingHire validation', () => {
|
|||||||
it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => {
|
it('ключ поля совпадает с htmlFor у FormField — по нему подсветка и прокрутка', () => {
|
||||||
expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined });
|
expect(submitIssues(DRAFT_READY)[0]).toEqual({ field: 'Companies', label: '1.2 Организация', message: undefined });
|
||||||
expect(draftIssues(EMPTY_DATA)[0]).toEqual({
|
expect(draftIssues(EMPTY_DATA)[0]).toEqual({
|
||||||
field: 'Project',
|
field: 'Projects',
|
||||||
label: '1.3 Проект',
|
label: '1.3 Проект',
|
||||||
message: 'выберите вариант из списка'
|
message: 'выберите хотя бы один проект из списка'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ interface IRequiredRule extends IFieldIssue {
|
|||||||
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
|
* 2.2 «Дата приёма» не нужна, если отмечено «По приезду на объект»
|
||||||
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
|
* (HireOnArrival) — тогда дату проставляет исполнитель на своём шаге.
|
||||||
*
|
*
|
||||||
* 1.3 «Проект» проверяется по ProjectId, а не по тексту: маршрут резолвится
|
* 1.3 «Проект» — хотя бы один проект из справочника: первый уходит в resolve
|
||||||
* ПО ПРОЕКТУ (ByProject), и без выбранного из списка варианта approval-route
|
* маршрута (projectId), а без него approval-route отвечал 404 и заявка ушла бы
|
||||||
* вернёт 404 — заявка ушла бы на согласование с пустой матрицей.
|
* на согласование с пустой матрицей. Ручной ввод мультивыбор не сохраняет.
|
||||||
*/
|
*/
|
||||||
const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
|
const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
|
||||||
{ field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 },
|
{ field: 'Companies', label: '1.2 Организация', ok: (d) => d.Companies.length > 0 },
|
||||||
@@ -34,10 +34,10 @@ const REQUIRED_RULES: ReadonlyArray<IRequiredRule> = [
|
|||||||
draft: true
|
draft: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'Project',
|
field: 'Projects',
|
||||||
label: '1.3 Проект',
|
label: '1.3 Проект',
|
||||||
message: 'выберите вариант из списка',
|
message: 'выберите хотя бы один проект из списка',
|
||||||
ok: (d) => !!d.ProjectId,
|
ok: (d) => d.Projects.length > 0,
|
||||||
draft: true
|
draft: true
|
||||||
},
|
},
|
||||||
{ field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() },
|
{ field: 'Phone', label: '1.4 Телефон', ok: (d) => !!d.Phone.trim() },
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
IMatrixRow,
|
IMatrixRow,
|
||||||
ISiteUser,
|
ISiteUser,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
ApproverDecision
|
ApproverDecision,
|
||||||
|
queuedStepMoveRule,
|
||||||
|
useQueuedStepMove
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
@@ -256,6 +258,15 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
.catch((e) => setRouteError(friendlyErrorMessage(e)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Поменять местами ждущие шаги (согласующих или исполнителей) — то же право
|
||||||
|
// routeEditRights.ts; правило «только среди ждущих» — см. queuedStepMove.ts.
|
||||||
|
const handleMoveStep = useQueuedStepMove(
|
||||||
|
approverService,
|
||||||
|
itemId,
|
||||||
|
() => setDecisionSeq((n) => n + 1),
|
||||||
|
setRouteError
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -358,6 +369,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
canRemoveRow={canRemoveApprover ? (row) => row.status === REQUEST_STATUS.queued : undefined}
|
||||||
onRemoveRow={handleRemoveApprover}
|
onRemoveRow={handleRemoveApprover}
|
||||||
|
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
|
||||||
|
onMoveRow={handleMoveStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
@@ -373,6 +386,8 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
|
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
|
||||||
}
|
}
|
||||||
onRemoveRow={handleRemoveExecutor}
|
onRemoveRow={handleRemoveExecutor}
|
||||||
|
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
|
||||||
|
onMoveRow={handleMoveStep}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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';
|
import { userSearchVariants } from './userSearchVariants';
|
||||||
import { planRoutePatch } from './routePatchPlan';
|
import { planRoutePatch } from './routePatchPlan';
|
||||||
|
import { IStepOrderChange } from './queuedStepMove';
|
||||||
|
|
||||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||||
const FIELDS = {
|
const FIELDS = {
|
||||||
@@ -304,6 +305,21 @@ export class ApprovalListService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новые StepOrder для нескольких строк — ОДНИМ батчем (см. createSteps): при
|
||||||
|
// обмене двух шагов местами половинчатая запись дала бы два шага с одним
|
||||||
|
// order, то есть внезапный параллельный шаг. См. ApproverService.moveQueuedStep.
|
||||||
|
public async setStepOrders(changes: IStepOrderChange[]): Promise<void> {
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
const [batchedSP, execute] = getSP().batched();
|
||||||
|
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
|
||||||
|
/* eslint-disable no-void -- промисы батча разрешатся вместе после execute(), см. createSteps */
|
||||||
|
changes.forEach(({ rowId, order }) => {
|
||||||
|
void batchedList.items.getById(rowId).update({ [FIELDS.order]: order });
|
||||||
|
});
|
||||||
|
/* eslint-enable no-void */
|
||||||
|
await execute();
|
||||||
|
}
|
||||||
|
|
||||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
|||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { emitRequestsChanged } from '../requestEvents';
|
import { emitRequestsChanged } from '../requestEvents';
|
||||||
|
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
||||||
|
|
||||||
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
|
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
|
||||||
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
|
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
|
||||||
@@ -141,6 +142,29 @@ export class ApproverService {
|
|||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поменять местами ещё не начатый шаг маршрута с соседним ждущим шагом той же
|
||||||
|
* фазы (согласование или исполнение) — право см. routeEditRights.ts, правило
|
||||||
|
* см. queuedStepMove.ts.
|
||||||
|
*
|
||||||
|
* Строки перечитываются с сервера прямо перед записью: пока на экране открыт
|
||||||
|
* маршрут, кто-то мог уже согласовать, и сосед стал активным, — тогда
|
||||||
|
* перестановка отменяется, а не ломает очередь.
|
||||||
|
*
|
||||||
|
* Участники не меняются, поэтому заявку не «трогаем» (см. touchRequest): права
|
||||||
|
* на элемент данных пересчитывать незачем, оповещать тоже некого — активный
|
||||||
|
* шаг остаётся прежним.
|
||||||
|
*/
|
||||||
|
public async moveQueuedStep(requestId: number, rowId: number, direction: StepMoveDirection): Promise<void> {
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const changes = planQueuedStepMove(rows, rowId, direction);
|
||||||
|
if (!changes) {
|
||||||
|
throw new Error('Маршрут уже изменился (шаг взят в работу или решён) — обновите заявку.');
|
||||||
|
}
|
||||||
|
await this.approval.setStepOrders(changes);
|
||||||
|
emitRequestsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||||
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export { resolveApproverOrgInfo } from './approverOrgInfo';
|
|||||||
export type { IApproverOrgInfo } from './approverOrgInfo';
|
export type { IApproverOrgInfo } from './approverOrgInfo';
|
||||||
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights';
|
||||||
export { canEditAllFields } from './fullEditRights';
|
export { canEditAllFields } from './fullEditRights';
|
||||||
|
export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
||||||
|
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
|
||||||
|
export { useQueuedStepMove } from './useQueuedStepMove';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { IStepMoveRow, planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
||||||
|
|
||||||
|
const APPROVAL = 'Согласование';
|
||||||
|
const EXECUTION = 'Исполнение';
|
||||||
|
const QUEUED = 'Ждёт очереди';
|
||||||
|
const ON_APPROVAL = 'На согласовании';
|
||||||
|
const APPROVED = 'Согласовано';
|
||||||
|
|
||||||
|
function row(id: number, order: number, status = QUEUED, phase = APPROVAL): IStepMoveRow {
|
||||||
|
return { id, order, status, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('planQueuedStepMove', () => {
|
||||||
|
it('два ждущих соседа меняются значениями StepOrder', () => {
|
||||||
|
const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL), row(3, 3), row(4, 4)];
|
||||||
|
expect(planQueuedStepMove(rows, 4, 'up')).toEqual([
|
||||||
|
{ rowId: 4, order: 3 },
|
||||||
|
{ rowId: 3, order: 4 },
|
||||||
|
]);
|
||||||
|
expect(planQueuedStepMove(rows, 3, 'down')).toEqual([
|
||||||
|
{ rowId: 3, order: 4 },
|
||||||
|
{ rowId: 4, order: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нельзя поставить ждущий шаг перед активным', () => {
|
||||||
|
const rows = [row(1, 1, ON_APPROVAL), row(2, 2)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('активный и пройденный шаги не двигаются', () => {
|
||||||
|
const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL), row(3, 3)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined();
|
||||||
|
expect(planQueuedStepMove(rows, 1, 'down')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('крайний шаг фазы дальше не двигается', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 2)];
|
||||||
|
expect(planQueuedStepMove(rows, 1, 'up')).toBeUndefined();
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('граница фаз не пересекается: последний согласующий не уходит к исполнителям', () => {
|
||||||
|
const rows = [row(1, 1), row(2, 2), row(3, 3, QUEUED, EXECUTION), row(4, 4, QUEUED, EXECUTION)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'down')).toBeUndefined();
|
||||||
|
expect(planQueuedStepMove(rows, 3, 'up')).toBeUndefined();
|
||||||
|
expect(planQueuedStepMove(rows, 3, 'down')).toEqual([
|
||||||
|
{ rowId: 3, order: 4 },
|
||||||
|
{ rowId: 4, order: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('параллельный шаг (общий order) переезжает целиком', () => {
|
||||||
|
const rows = [row(1, 1, ON_APPROVAL), row(2, 2), row(3, 2), row(4, 3)];
|
||||||
|
expect(planQueuedStepMove(rows, 3, 'down')).toEqual([
|
||||||
|
{ rowId: 2, order: 3 },
|
||||||
|
{ rowId: 3, order: 3 },
|
||||||
|
{ rowId: 4, order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дробный order делегата сохраняет относительный порядок', () => {
|
||||||
|
// Делегат (1.5) активен, исходный согласующий (2) ждёт после него.
|
||||||
|
const rows = [row(1, 1.5, ON_APPROVAL), row(2, 2), row(3, 3)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined();
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'down')).toEqual([
|
||||||
|
{ rowId: 2, order: 3 },
|
||||||
|
{ rowId: 3, order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка без id (ещё не записана) не двигается', () => {
|
||||||
|
const rows: IStepMoveRow[] = [{ order: 1, status: QUEUED, phase: APPROVAL }, row(2, 2)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'up')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая фаза у старых строк считается согласованием', () => {
|
||||||
|
const rows: IStepMoveRow[] = [{ id: 1, order: 1, status: QUEUED }, row(2, 2)];
|
||||||
|
expect(planQueuedStepMove(rows, 2, 'up')).toEqual([
|
||||||
|
{ rowId: 2, order: 1 },
|
||||||
|
{ rowId: 1, order: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестная строка — undefined', () => {
|
||||||
|
expect(planQueuedStepMove([row(1, 1)], 99, 'up')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queuedStepMoveRule', () => {
|
||||||
|
it('разрешает только допустимые направления', () => {
|
||||||
|
const rows = [row(1, 1, ON_APPROVAL), row(2, 2), row(3, 3)];
|
||||||
|
const canMove = queuedStepMoveRule(rows);
|
||||||
|
expect(canMove(rows[1], 'up')).toBe(false);
|
||||||
|
expect(canMove(rows[1], 'down')).toBe(true);
|
||||||
|
expect(canMove(rows[2], 'up')).toBe(true);
|
||||||
|
expect(canMove(rows[2], 'down')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перестановка ЕЩЁ НЕ НАЧАТЫХ шагов уже поданного маршрута (право — см.
|
||||||
|
* routeEditRights.ts). Чистые функции без SharePoint: решают, можно ли
|
||||||
|
* сдвинуть шаг, и какие StepOrder записать. Выполняет ApproverService.moveQueuedStep.
|
||||||
|
*
|
||||||
|
* Безопасное правило (согласовано с владельцем): меняются местами только два
|
||||||
|
* СОСЕДНИХ шага одной фазы, и ОБА целиком «Ждут очереди». Активный и пройденные
|
||||||
|
* шаги не двигаются и не перепрыгиваются — тот, у кого заявка сейчас, её не
|
||||||
|
* теряет, история решений остаётся верной, оповещать никого не нужно.
|
||||||
|
*
|
||||||
|
* Шаг — это все строки с одинаковым StepOrder (параллельный шаг двигается
|
||||||
|
* целиком). Обмен — значениями StepOrder, а не перенумерацией: относительный
|
||||||
|
* порядок со всеми остальными строками (в т.ч. с дробными order делегатов)
|
||||||
|
* сохраняется сам собой.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StepMoveDirection = 'up' | 'down';
|
||||||
|
|
||||||
|
/** То, что нужно о строке маршрута: подходит и IApprovalRow, и IMatrixRow. */
|
||||||
|
export interface IStepMoveRow {
|
||||||
|
/** Id строки списка согласования; у планируемых (ещё не записанных) строк нет. */
|
||||||
|
id?: number;
|
||||||
|
status: string;
|
||||||
|
order: number;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Новый StepOrder для строки. */
|
||||||
|
export interface IStepOrderChange {
|
||||||
|
rowId: number;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустая фаза у старых строк трактуется как «Согласование» (см. getForRequest).
|
||||||
|
function phaseOf(row: IStepMoveRow): string {
|
||||||
|
return row.phase || APPROVAL_PHASE.approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки фазы → шаги (группы с одинаковым order), по возрастанию order.
|
||||||
|
function stepsOf(rows: IStepMoveRow[]): IStepMoveRow[][] {
|
||||||
|
const sorted = [...rows].sort((a, b) => a.order - b.order);
|
||||||
|
const steps: IStepMoveRow[][] = [];
|
||||||
|
sorted.forEach((row) => {
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
if (last && last[0].order === row.order) last.push(row);
|
||||||
|
else steps.push([row]);
|
||||||
|
});
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMovableStep(step: IStepMoveRow[]): boolean {
|
||||||
|
return step.every((r) => r.id !== undefined && r.status === REQUEST_STATUS.queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Какие StepOrder записать, чтобы сдвинуть шаг строки rowId на одну позицию.
|
||||||
|
* undefined — так сдвинуть нельзя (строки нет, шаг или сосед не «Ждут очереди»,
|
||||||
|
* шаг уже крайний в своей фазе).
|
||||||
|
*/
|
||||||
|
export function planQueuedStepMove(
|
||||||
|
rows: IStepMoveRow[],
|
||||||
|
rowId: number,
|
||||||
|
direction: StepMoveDirection
|
||||||
|
): IStepOrderChange[] | undefined {
|
||||||
|
const target = rows.filter((r) => r.id === rowId)[0];
|
||||||
|
if (!target) return undefined;
|
||||||
|
|
||||||
|
const phase = phaseOf(target);
|
||||||
|
const steps = stepsOf(rows.filter((r) => phaseOf(r) === phase));
|
||||||
|
const index = steps.findIndex((step) => step.some((r) => r.id === rowId));
|
||||||
|
const neighbor = steps[direction === 'up' ? index - 1 : index + 1];
|
||||||
|
const current = steps[index];
|
||||||
|
if (!neighbor || !isMovableStep(current) || !isMovableStep(neighbor)) return undefined;
|
||||||
|
|
||||||
|
const currentOrder = current[0].order;
|
||||||
|
const neighborOrder = neighbor[0].order;
|
||||||
|
return [
|
||||||
|
...current.map((r) => ({ rowId: r.id as number, order: neighborOrder })),
|
||||||
|
...neighbor.map((r) => ({ rowId: r.id as number, order: currentOrder })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Правило для ApprovalMatrix.canMoveRow по строкам этой матрицы (одной фазы). */
|
||||||
|
export function queuedStepMoveRule(
|
||||||
|
rows: IStepMoveRow[]
|
||||||
|
): (row: IStepMoveRow, direction: StepMoveDirection) => boolean {
|
||||||
|
return (row, direction) => row.id !== undefined && planQueuedStepMove(rows, row.id, direction) !== undefined;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { effectiveUserEmail } from '../impersonation';
|
|||||||
* Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием,
|
* Подключено только в 3 формах рекрутинга (Заявка на подбор, Заявка на прием,
|
||||||
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
|
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
|
||||||
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
|
||||||
|
* В поданных подборе и приёме это же право даёт менять местами ждущие шаги
|
||||||
|
* (canMoveRow, правило — queuedStepMove.ts).
|
||||||
*/
|
*/
|
||||||
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
|
||||||
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
|
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { StatusBadge } from './StatusBadge';
|
|||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
||||||
import { useFlipRows } from './useFlipRows';
|
import { useFlipRows } from './useFlipRows';
|
||||||
|
import { StepMoveDirection } from '../queuedStepMove';
|
||||||
|
|
||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
editor: IUseMatrixEditor;
|
editor: IUseMatrixEditor;
|
||||||
@@ -54,6 +55,14 @@ export interface IApprovalMatrixProps {
|
|||||||
canReplaceRow?: (row: IMatrixRow, index: number) => boolean;
|
canReplaceRow?: (row: IMatrixRow, index: number) => boolean;
|
||||||
/** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */
|
/** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */
|
||||||
onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void;
|
onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void;
|
||||||
|
/**
|
||||||
|
* Можно ли сдвинуть шаг ЭТОЙ строки выше/ниже в УЖЕ ПОДАННОЙ заявке — как и
|
||||||
|
* canRemoveRow, независимо от editable (см. queuedStepMoveRule). В черновике
|
||||||
|
* (editable) не нужен: там стрелки двигают шаги локально через editor.
|
||||||
|
*/
|
||||||
|
canMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => boolean;
|
||||||
|
/** Сдвинуть шаг на сервере (задаётся вместе с canMoveRow, см. useQueuedStepMove). */
|
||||||
|
onMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
|
||||||
@@ -70,6 +79,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
onRemoveRow,
|
onRemoveRow,
|
||||||
canReplaceRow,
|
canReplaceRow,
|
||||||
onReplaceRow,
|
onReplaceRow,
|
||||||
|
canMoveRow,
|
||||||
|
onMoveRow,
|
||||||
}) => {
|
}) => {
|
||||||
const { rows, loading, error, editable, blocks, retry } = editor;
|
const { rows, loading, error, editable, blocks, retry } = editor;
|
||||||
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
|
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
|
||||||
@@ -103,7 +114,23 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const moveBlock = (rowIndex: number, direction: 'up' | 'down'): void => {
|
// Стрелки шагов: в черновике (editable) двигают строки локально, в поданной
|
||||||
|
// заявке — только там, где разрешил canMoveRow, и с записью на сервер (onMoveRow).
|
||||||
|
const hasServerMovable =
|
||||||
|
!editable && !!canMoveRow && rows.some((r) => canMoveRow(r, 'up') || canMoveRow(r, 'down'));
|
||||||
|
const showMoveCol = editable || hasServerMovable;
|
||||||
|
|
||||||
|
const canMoveBlock = (rowIndex: number, direction: StepMoveDirection): boolean => {
|
||||||
|
const b = blockByRow[rowIndex];
|
||||||
|
if (editable) return direction === 'up' ? b.index > 0 : b.index < blocks.length - 1;
|
||||||
|
return !!canMoveRow?.(rows[rowIndex], direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveBlock = (rowIndex: number, direction: StepMoveDirection): void => {
|
||||||
|
if (!editable) {
|
||||||
|
onMoveRow?.(rows[rowIndex], direction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const b = blockByRow[rowIndex];
|
const b = blockByRow[rowIndex];
|
||||||
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
||||||
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||||
@@ -188,7 +215,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
{hasStatuses && <th>Статус</th>}
|
{hasStatuses && <th>Статус</th>}
|
||||||
{hasDates && <th>Дата</th>}
|
{hasDates && <th>Дата</th>}
|
||||||
{hasSla && <th>SLA</th>}
|
{hasSla && <th>SLA</th>}
|
||||||
{editable && <th />}
|
{showMoveCol && <th />}
|
||||||
{(editable || hasRemovable) && <th />}
|
{(editable || hasRemovable) && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -257,26 +284,33 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||||
{editable && isBlockStart && (
|
{showMoveCol && isBlockStart && (
|
||||||
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
||||||
<button
|
{/* В поданной заявке у шагов, которые двигать нельзя совсем
|
||||||
type="button"
|
(активный, пройденный), стрелок нет — иначе вся колонка
|
||||||
className={styles.iconBtn}
|
из неактивных кнопок только шумит. */}
|
||||||
disabled={block.index === 0}
|
{(editable || canMoveBlock(i, 'up') || canMoveBlock(i, 'down')) && (
|
||||||
onClick={() => moveBlock(i, 'up')}
|
<>
|
||||||
title="Шаг выше"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
className={styles.iconBtn}
|
||||||
</button>
|
disabled={!canMoveBlock(i, 'up')}
|
||||||
<button
|
onClick={() => moveBlock(i, 'up')}
|
||||||
type="button"
|
title="Шаг выше"
|
||||||
className={styles.iconBtn}
|
>
|
||||||
disabled={block.index === blocks.length - 1}
|
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||||
onClick={() => moveBlock(i, 'down')}
|
</button>
|
||||||
title="Шаг ниже"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Icon name="chevron-down" size="sm" />
|
className={styles.iconBtn}
|
||||||
</button>
|
disabled={!canMoveBlock(i, 'down')}
|
||||||
|
onClick={() => moveBlock(i, 'down')}
|
||||||
|
title="Шаг ниже"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-down" size="sm" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{(editable || hasRemovable) && (
|
{(editable || hasRemovable) && (
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { friendlyErrorMessage } from '../diagnostics';
|
||||||
|
import { ApproverService } from './approverDecision.service';
|
||||||
|
import { IMatrixRow } from './models/IMatrixRow';
|
||||||
|
import { StepMoveDirection } from './queuedStepMove';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перестановка ждущих шагов поданной заявки из ApprovalMatrix (onMoveRow) —
|
||||||
|
* общая обвязка для форм просмотра (RecruitingHireView, RecruitingSelectionView).
|
||||||
|
*
|
||||||
|
* Пока одна перестановка пишется на сервер, следующие клики игнорируются:
|
||||||
|
* каждая перестановка заново читает маршрут, и быстрый двойной клик сдвинул бы
|
||||||
|
* шаг на две позиции вместо одной.
|
||||||
|
*/
|
||||||
|
export function useQueuedStepMove(
|
||||||
|
service: ApproverService,
|
||||||
|
requestId: number,
|
||||||
|
onMoved: () => void,
|
||||||
|
onError: (message: string | undefined) => void
|
||||||
|
): (row: IMatrixRow, direction: StepMoveDirection) => void {
|
||||||
|
const isMovingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
return (row, direction) => {
|
||||||
|
if (!row.id || isMovingRef.current) return;
|
||||||
|
isMovingRef.current = true;
|
||||||
|
onError(undefined);
|
||||||
|
service
|
||||||
|
.moveQueuedStep(requestId, row.id, direction)
|
||||||
|
.then(onMoved)
|
||||||
|
.catch((e) => onError(friendlyErrorMessage(e)))
|
||||||
|
.finally(() => {
|
||||||
|
isMovingRef.current = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export { getProjects } from './projects.service';
|
|||||||
export type { IProject } from './models/IProject';
|
export type { IProject } from './models/IProject';
|
||||||
export { ProjectPicker } from './ui/ProjectPicker';
|
export { ProjectPicker } from './ui/ProjectPicker';
|
||||||
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
||||||
|
export type { IProjectRef } from './models/IProjectRef';
|
||||||
|
export { ProjectPickerMulti } from './ui/ProjectPickerMulti';
|
||||||
|
export type { IProjectPickerMultiProps } from './ui/ProjectPickerMulti';
|
||||||
export { getCompanies } from './companies.service';
|
export { getCompanies } from './companies.service';
|
||||||
export type { ICompany } from './models/ICompany';
|
export type { ICompany } from './models/ICompany';
|
||||||
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
export { CompanyPickerMulti } from './ui/CompanyPickerMulti';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Выбранный проект в мультивыборе (ProjectPickerMulti) — то, что хранится в
|
||||||
|
* заявке (Note-колонка *Json), а не вся запись справочника IProject.
|
||||||
|
* Ключи в PascalCase, как у ICompany: так одинаково читаются оба мультивыбора
|
||||||
|
* и журнал правок показывает их по Name (см. shared/fieldChanges).
|
||||||
|
*/
|
||||||
|
export interface IProjectRef {
|
||||||
|
/** GUID проекта (IProject.oid). */
|
||||||
|
Id: string;
|
||||||
|
/** Наименование проекта. */
|
||||||
|
Name: string;
|
||||||
|
/** Код проекта (IProject.code), '' — если в справочнике не задан. */
|
||||||
|
Code: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Чипы и поле — как у CompanyPickerMulti, строка подсказки — как у ProjectPicker
|
||||||
|
// (название + мелкая строка «код · заказчик»).
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-1;
|
||||||
|
margin-bottom: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 6px 5px 10px;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $navy-50;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRemove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-200;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-900;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
@include input-focus-gold;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
box-shadow: $sh-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './ProjectPickerMulti.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { getProjects } from '../projects.service';
|
||||||
|
import { IProject } from '../models/IProject';
|
||||||
|
import { IProjectRef } from '../models/IProjectRef';
|
||||||
|
|
||||||
|
export interface IProjectPickerMultiProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Выбранные проекты (Id + Name + Code). */
|
||||||
|
value: IProjectRef[];
|
||||||
|
onChange: (projects: IProjectRef[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 30;
|
||||||
|
|
||||||
|
function toRef(project: IProject): IProjectRef {
|
||||||
|
return { Id: project.oid, Name: project.name, Code: project.code || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор проектов из справочника (GET /Project): ввод текста фильтрует
|
||||||
|
* список по названию и коду, выбранные показываются чипами. Ручной ввод без
|
||||||
|
* выбора из списка не сохраняется — в отличие от одиночного ProjectPicker.
|
||||||
|
*/
|
||||||
|
export const ProjectPickerMulti: React.FC<IProjectPickerMultiProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [projects, setProjects] = React.useState<IProject[]>([]);
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getProjects(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setProjects(list);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
const selectedIds = React.useMemo(() => new Set(value.map((p) => p.Id)), [value]);
|
||||||
|
|
||||||
|
const suggestions = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return projects
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
!selectedIds.has(p.oid) &&
|
||||||
|
(p.name.toLowerCase().indexOf(q) !== -1 || (p.code && p.code.toLowerCase().indexOf(q) !== -1))
|
||||||
|
)
|
||||||
|
.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [projects, query, selectedIds]);
|
||||||
|
|
||||||
|
const add = (project: IProject): void => {
|
||||||
|
onChange([...value, toRef(project)]);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (projectId: string): void => {
|
||||||
|
onChange(value.filter((p) => p.Id !== projectId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<div className={styles.chips}>
|
||||||
|
{value.map((p) => (
|
||||||
|
<span key={p.Id} className={styles.chip}>
|
||||||
|
{p.Name}
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.chipRemove}
|
||||||
|
aria-label={`Убрать ${p.Name}`}
|
||||||
|
title="Убрать"
|
||||||
|
onClick={() => remove(p.Id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!effectiveReadOnly && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={query}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{suggestions.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.oid}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => add(p)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
<span className={styles.meta}>{[p.code, p.customerName].filter(Boolean).join(' · ')}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
/** Разделитель частей названия заявки. */
|
/** Разделитель частей названия заявки. */
|
||||||
const TITLE_SEPARATOR = ' — ';
|
const TITLE_SEPARATOR = ' — ';
|
||||||
|
|
||||||
|
/** Предел однострочной текстовой колонки SharePoint (в т.ч. Title) — длиннее запись падает с ошибкой. */
|
||||||
|
export const SP_TEXT_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
const ELLIPSIS = '…';
|
||||||
|
|
||||||
|
/** Обрезать значение под однострочную текстовую колонку SharePoint (с многоточием). */
|
||||||
|
export function truncateSpText(value: string): string {
|
||||||
|
return value.length <= SP_TEXT_MAX_LENGTH ? value : `${value.slice(0, SP_TEXT_MAX_LENGTH - ELLIPSIS.length)}${ELLIPSIS}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Название заявки (системная колонка Title списка данных): ключевые поля через
|
* Название заявки (системная колонка Title списка данных): ключевые поля через
|
||||||
* тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования
|
* тире, пустые части пропускаются — «Инженер — Проект X». По нему заявку узнают в списке SP, в столбце «Заявка» списка согласования
|
||||||
* и в колонке «Название» таблиц портала.
|
* и в колонке «Название» таблиц портала. Обрезается до предела колонки: длинные
|
||||||
|
* названия проектов/объектов иначе роняли бы сохранение заявки.
|
||||||
*/
|
*/
|
||||||
export function buildRequestTitle(parts: string[]): string {
|
export function buildRequestTitle(parts: string[]): string {
|
||||||
return parts.map((p) => p.trim()).filter(Boolean).join(TITLE_SEPARATOR);
|
return truncateSpText(parts.map((p) => p.trim()).filter(Boolean).join(TITLE_SEPARATOR));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user