fix: внес изменения в заявках рекрутинга от 16.09.26
Tests / frontend-test (push) Successful in 1m20s

This commit is contained in:
Тимур Городецкий
2026-09-16 17:59:45 +05:00
parent 428122d32a
commit 6f8bea33a2
31 changed files with 595 additions and 101 deletions
@@ -25,6 +25,15 @@
`editable=false` даже в форме подачи, не только в просмотре. Если шаг маршрута не
разрешился в конкретного человека — отправка блокируется с просьбой обратиться к
администратору маршрутов (сам заявитель это не чинит).
- **Шаг «Руководитель» переопределяется полем формы.** Роль с `positionCode: "Chief"`
в ответе `resolve` approval-route резолвит сам по своей оргструктуре — это не всегда
тот, кого заявитель вписал/выбрал в поле «Руководитель» (ContactSection,
`PeopleTextField`). `managerOverride.ts` после загрузки планируемой матрицы находит
строку с этим `positionCode` и подставляет туда email/ФИО из `data.Manager` вместо
того, что вернул resolve. Пустое/нераспознанное значение поля → шаг остаётся без
исполнителя, это ловит тот же `unassignedStepMessage`, что и обычный незаполненный
шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого
маршрута, не у уже записанных строк списка согласования).
## Особое поведение
- **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email,
@@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service';
import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
import { applyManagerOverride, managerEmail } from './managerOverride';
const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType,
@@ -182,6 +183,43 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
!!editId || (!!data.WorkType && !!requestTypeId),
(login) => approval.getJobTitle(login)
);
// Должность нового «Руководителя» — approval-route её не знает (резолвил шаг
// на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по
// e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих,
// см. useMatrixEditor.getJobTitle выше). undefined, пока запрос не отработал —
// applyManagerOverride в этом случае не трогает должность строки, чтобы не
// мигать пустым значением на каждое изменение поля.
const [managerPosition, setManagerPosition] = useState<string | undefined>(undefined);
useEffect(() => {
const email = managerEmail(data.Manager);
if (!email) {
setManagerPosition(undefined);
return;
}
let alive = true;
setManagerPosition(''); // сбрасываем сразу — иначе на экране на миг видна должность ПРЕЖНЕГО руководителя
approval
.getJobTitle(email)
.then((title) => {
if (alive) setManagerPosition(title);
})
.catch(() => {
if (alive) setManagerPosition('');
});
return () => {
alive = false;
};
}, [data.Manager, approval]);
// Шаг с ролью «Руководитель» (positionCode Chief) переопределяем полем формы,
// а не тем, кого approval-route резолвит сам по оргструктуре — см.
// managerOverride.ts. Пересчёт локальный (без похода в resolve), поэтому
// безопасно зависеть от data.Manager, который меняется на каждое нажатие клавиши.
const matrixRows = useMemo(
() => applyManagerOverride(matrix.rows, data.Manager, managerPosition),
[matrix.rows, data.Manager, managerPosition]
);
const matrixEditor = useMemo(() => ({ ...matrix, rows: matrixRows }), [matrix, matrixRows]);
const isValid = !!(
data.TaskName.trim() &&
@@ -248,7 +286,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
const { id, code } = await saveTicketRequest(
requestId,
data,
toApprovers(matrix.rows),
toApprovers(matrixRows),
finalStatus,
writeListId
);
@@ -297,7 +335,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
// незаполненный шаг заявитель исправить сам не может, только администратор
// маршрутов (роль в шаге не разрешилась в конкретного человека).
const unassignedError = unassignedStepMessage(
matrix.rows,
matrixRows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.`
);
if (unassignedError) return unassignedError;
@@ -360,7 +398,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
)}
<ApprovalMatrix
editor={matrix}
editor={matrixEditor}
search={(q) => approval.searchUsers(q)}
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
/>
@@ -0,0 +1,55 @@
import { IMatrixRow } from '../../shared/approval';
// Формат значения поля «Руководитель» — тот же, что у автоподстановки
// (Ticket1CForm.tsx) и у PeopleTextField/ContactSection: «ФИО · email».
const MANAGER_SEPARATOR = ' · ';
const CHIEF_POSITION_CODE = 'Chief';
const EMAIL_RE = /^\S+@\S+\.\S+$/;
/** Email из значения поля «Руководитель» — используется для запроса должности (см. Ticket1CForm.tsx). */
export function managerEmail(value: string): string {
return parseManager(value).email;
}
function parseManager(value: string): { name: string; email: string } {
const trimmed = value.trim();
const sepIndex = trimmed.lastIndexOf(MANAGER_SEPARATOR);
if (sepIndex !== -1) {
return {
name: trimmed.slice(0, sepIndex).trim(),
email: trimmed.slice(sepIndex + MANAGER_SEPARATOR.length).trim()
};
}
// Ввели/вставили просто e-mail без ФИО (напр. руками, без пикера).
return { name: '', email: EMAIL_RE.test(trimmed) ? trimmed : '' };
}
/**
* Шаг маршрута с ролью «Руководитель» (positionCode Chief в админке маршрутов)
* approval-route резолвит сам по оргструктуре — этот источник не всегда
* совпадает с тем, что заявитель вписал в поле «Руководитель» (ContactSection),
* а заявитель мог осознанно указать другого человека через PeopleTextField или
* вручную. Поле формы должно быть главным источником для ЭТОГО шага.
*
* Пустой/нераспознанный e-mail в поле → шаг остаётся без назначенного
* исполнителя (email ''), это поймает unassignedStepMessage при отправке —
* отдельной проверки здесь не нужно.
*
* position — должность НОВОГО исполнителя (SPS-JobTitle по managerEmail, см.
* Ticket1CForm.tsx), которую approval-route вернуть не может — он резолвил
* шаг на другого человека и отдал ЕГО должность. Без этого параметра в
* колонке «Должность» осталась бы должность прежнего (авторезолвленного)
* исполнителя рядом с ФИО нового. undefined (запрос ещё не отработал/упал) →
* должность строки временно не меняем, чтобы не мигать пустым значением на
* каждое изменение поля.
*
* Только для ПЛАНИРУЕМОГО маршрута (positionCode есть только у него, см.
* IMatrixRow.positionCode) — для уже записанных строк заявки (editId) это
* no-op, что и нужно: решение согласующего задним числом не переписываем.
*/
export function applyManagerOverride(rows: IMatrixRow[], manager: string, position?: string): IMatrixRow[] {
const { name, email } = parseManager(manager);
return rows.map((r) =>
r.positionCode === CHIEF_POSITION_CODE ? { ...r, email, who: name || email, position: position ?? r.position } : r
);
}
@@ -22,7 +22,11 @@ export interface IFormData {
Phone: string;
Department: string;
Position: string;
/** Непосредственный руководитель заявителя — предзаполняется через getManagerEmail, редактируем. */
/**
* Непосредственный руководитель заявителя — предзаполняется через
* getManagerEmail, редактируем: вручную текстом или через PeopleTextField
* (кнопка «выбрать из списка сотрудников»), см. ContactSection.
*/
Manager: string;
Question: string;
}
@@ -1,8 +1,8 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui';
import { CollapseSection, FormField, TextInput, PhoneInput, PeopleTextField } from '../../../shared/ui';
import { ISectionProps } from '../model';
export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
export const ContactSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Контакты заявителя" icon="people">
<FormField label="Ф.И.О." htmlFor="FIO">
<TextInput id="FIO" value={data.FIO} onChange={(v) => set('FIO', v)} />
@@ -20,7 +20,13 @@ export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Руководитель" htmlFor="Manager">
<TextInput id="Manager" value={data.Manager} onChange={(v) => set('Manager', v)} />
<PeopleTextField
id="Manager"
context={context}
value={data.Manager}
onChange={(v) => set('Manager', v)}
placeholder="ФИО · email или выберите из списка"
/>
</FormField>
</CollapseSection>
);