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
@@ -40,6 +40,7 @@ export async function getPlannedMatrix(
askOverdueReason: s.askOverdueReason,
autoApprove: s.autoApprove,
warning: s.warning,
positionCode: s.positionCode,
}))
);
}
@@ -117,4 +117,12 @@ export interface IResolvedRouteStep {
* нечего, см. соответствующее поле на бэкенде (TemplateRouteStep.AutoApprove).
*/
autoApprove: boolean;
/**
* Код типовой роли шага (напр. "Chief" — непосредственный руководитель),
* настраивается в админке маршрутов вместе с самим шагом. undefined — роль
* без кода (обычный именной/групповой шаг). Используется формами точечно,
* когда нужно сопоставить конкретный шаг маршрута с полем формы (см.
* Ticket1C/managerOverride.ts — единственный пример на сейчас).
*/
positionCode?: string;
}
@@ -38,6 +38,12 @@ export interface IMatrixRow {
* IResolvedRouteStep.autoApprove).
*/
autoApprove?: boolean;
/**
* Код типовой роли шага (напр. "Chief"), см. IResolvedRouteStep.positionCode —
* то же транзитное поле: только у ПЛАНИРУЕМОГО маршрута (getPlannedMatrix), у
* уже записанных строк списка (loadXxxRequest) его нет.
*/
positionCode?: string;
/**
* Предупреждение approval-route по ЭТОМУ шагу (см. IResolvedRouteStep.warning) —
* например, роль не разрешилась в конкретного человека, и почему именно.
@@ -77,14 +77,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
else editor.moveBlockDown(rowIndex);
};
// Остальные правки состава — не перестановка: сбрасываем «активный» шаг,
// иначе подъём и подсветка достались бы случайной строке, которая сдвинулась
// из-за перенумерации после удаления.
const removeRow = (rowIndex: number): void => {
setActiveKeys([]);
editor.remove(rowIndex);
};
const toggleParallel = (rowIndex: number): void => {
setActiveKeys([]);
editor.toggleParallel(rowIndex);
@@ -239,14 +231,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
>
<Icon name="user-check" size="sm" />
</button>
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
onClick={() => removeRow(i)}
title="Удалить"
>
<Icon name="trash" size="sm" />
</button>
</td>
)}
</tr>
@@ -0,0 +1,52 @@
@import '../../../../../styles/design-tokens';
.wrap {
display: flex;
align-items: center;
gap: $sp-1;
}
.pickerBox {
flex: 1;
min-width: 0;
}
.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;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.modeButton {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $gray-500;
cursor: pointer;
&:hover {
background: $navy-50;
color: $gray-900;
}
}
@@ -0,0 +1,110 @@
import * as React from 'react';
import { useState } from 'react';
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { Icon, useFormReadOnly } from '@tnsu/ui-kit';
import { buildPickerContext } from './pickerContext';
import { peoplePickerStyles } from './peoplePickerStyles';
import styles from './PeopleTextField.module.scss';
interface IPickedPerson {
text?: string;
secondaryText?: string;
loginName?: string;
}
interface IPeopleTextFieldProps {
context: WebPartContext;
id?: string;
/** Текущее значение (свободный текст — ФИО, «ФИО · email» и т.п.). */
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const SEPARATOR = ' · ';
const EMAIL_RE = /^\S+@\S+\.\S+$/;
/** Формат значения после выбора из PeoplePicker: «ФИО · email» — тот же, что у ручного ввода в этой форме. */
function formatPicked(person: IPickedPerson): string {
const email = person.secondaryText || person.loginName || '';
return person.text ? (email ? `${person.text}${SEPARATOR}${email}` : person.text) : email;
}
/** Email из значения — для defaultSelectedUsers пикера. Если распознать нельзя, пикер стартует пустым. */
function extractEmail(value: string): string | undefined {
const idx = value.lastIndexOf(SEPARATOR);
const email = idx !== -1 ? value.slice(idx + SEPARATOR.length).trim() : value.trim();
return EMAIL_RE.test(email) ? email : undefined;
}
/**
* Поле «сотрудник»: по умолчанию — PeoplePicker (с предзаполнением из value),
* кнопка переключает на обычный текстовый ввод для случаев, когда нужного
* человека нет в AAD или пикер не резолвит совпадение. В обоих режимах
* итоговое значение — свободная строка «ФИО · email», а не жёстко резолвленный
* пользователь (в отличие от PeopleField) — переключение между режимами саму
* строку не трогает.
*/
export const PeopleTextField: React.FC<IPeopleTextFieldProps> = ({
context,
id,
value,
onChange,
placeholder,
readOnly
}) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [manual, setManual] = useState<boolean>(false);
const handlePick = (items: IPickedPerson[]): void => {
const first = items && items.length > 0 ? items[0] : undefined;
onChange(first ? formatPicked(first) : '');
};
const defaultEmail = extractEmail(value);
const defaultSelectedUsers = defaultEmail ? [defaultEmail] : [];
return (
<div className={styles.wrap}>
{manual ? (
<input
id={id}
className={styles.input}
value={value}
placeholder={placeholder}
readOnly={effectiveReadOnly}
onChange={(e) => onChange(e.target.value)}
/>
) : (
<div className={styles.pickerBox}>
<PeoplePicker
context={buildPickerContext(context)}
personSelectionLimit={1}
principalTypes={[PrincipalType.User]}
defaultSelectedUsers={defaultSelectedUsers}
resolveDelay={300}
ensureUser
disabled={effectiveReadOnly}
placeholder={placeholder}
onChange={handlePick}
styles={peoplePickerStyles}
/>
</div>
)}
{!effectiveReadOnly && (
<button
type="button"
className={styles.modeButton}
title={manual ? 'Выбрать из списка сотрудников' : 'Ввести вручную'}
onClick={() => setManual((prev) => !prev)}
>
<Icon name={manual ? 'search' : 'edit'} size="sm" />
</button>
)}
</div>
);
};
@@ -38,6 +38,7 @@ export { EMPTY_LINE_ITEM } from './ItemsTable/ILineItem';
export { parseExcelRows } from './ItemsTable/parseExcelRows';
export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { PeopleTextField } from './PeopleField/PeopleTextField';
export { OrganizationField } from './OrganizationField/OrganizationField';
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';