feat: выбор заявки на приём прямо в форме организации рабочего места
Tests / frontend-test (push) Successful in 2m1s

Рабочее место оформляют на основе заявки на приём, но кнопку «на основе»
видит только тот, кому видна сама заявка на приём. Теперь в форме есть
поиск по утверждённым заявкам на приём (все партиции), выбор предзаполняет
ФИО, телефон, должность, подразделение, местоположение и дату приёма.
Из заявки на приём читаются только эти поля — оклады в браузер не уходят
(старая предзагрузка читала заявку целиком).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-07 12:08:36 +05:00
parent cfbca8c2b2
commit 690a692c07
6 changed files with 354 additions and 30 deletions
@@ -11,10 +11,15 @@
- **Шаг 1 согласования — непосредственный руководитель** (роль в маршруте).
## Особое поведение
- **Префилл «на основе» заявки на прием** (открывается по `rpsource`): копируются
ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма
(`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник
сохраняется в колонке-lookup `SourceHire`.
- **Префилл из заявки на прием** — два входа: кнопка «на основе» в самой заявке на
приём (`rpsource`) или поле «На основе заявки на приём» вверху формы
(`HireRequestPicker`: поиск по утверждённым заявкам на приём во всех партициях —
для тех, кто чужую заявку на приём в портале не видит). Копируются ФИО, телефон,
должность, подразделение, местоположение, дата приёма (`hireLookup.ts` →
`loadHirePrefill`); из заявки на приём читаются только эти поля, оклады в браузер
не уходят. Это снимок на момент создания; ссылка на источник — в колонке-lookup
`SourceHire` (только для заявок из текущей партиции, см. `sourceHireLookupId`).
Нужно право чтения списка «Прием на работу — данные» в SharePoint.
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
согласовании»/«Выполнение» (`useApproverRights.ts`).
@@ -27,7 +32,7 @@
- Печать — общий механизм (без «Вложений»).
## Секции формы
EmployeeSection · EquipmentSection
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
## Точки входа
- rptype: `it-workplace-setup`
@@ -54,7 +54,8 @@ import {
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveWorkplaceRequest, workplaceApprovalService } from './submit';
import { loadPrefillFromHire } from './prefill';
import { IHireRequestOption, loadHirePrefill, sourceHireLookupId } from './hireLookup';
import { HireRequestPicker } from './sections/HireRequestPicker';
import { loadWorkplaceRequest } from './load';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { EmployeeSection } from './sections/EmployeeSection';
@@ -125,19 +126,25 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
};
}, [editId, editListId, notifyCode, setSubmitError]);
// Заявка на приём, на основе которой создаётся эта: из кнопки «на основе» в
// самой заявке на приём (rpsource) или из поля выбора в форме (HireRequestPicker).
const [sourceHire, setSourceHire] = useState<IHireRequestOption | undefined>(
sourceId ? { id: sourceId, listId: '', code: '', candidateName: '', position: '' } : undefined
);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => {
if (!sourceId) {
return;
}
let alive = true;
loadPrefillFromHire(sourceId)
loadHirePrefill(sourceId)
.then((prefilled) => {
if (alive) {
// Merge, а не replace: организация заявителя (ниже) резолвится отдельным
// эффектом и могла успеть записаться раньше — не затираем её пустышкой
// из prefilled (в loadPrefillFromHire её нет).
setData((prev) => ({ ...prefilled, Companies: prev.Companies }));
// эффектом и могла успеть записаться раньше — не затираем её.
setData((prev) => ({ ...prev, ...prefilled }));
setSourceHire((prev) => (prev ? { ...prev, candidateName: prefilled.FullName } : prev));
}
})
.catch((e) => {
@@ -249,6 +256,17 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
autoApprove: r.autoApprove
}));
// Выбор заявки на приём в форме: поля из неё перезаписывают уже введённые —
// пользователь осознанно выбрал источник. Id отдела сбрасываем вместе с
// названием: в заявке на приём отдел без Id справочника, а старый Id от
// другого отдела увёл бы маршрут не туда.
const handlePickHire = (option: IHireRequestOption): void => {
setSourceHire(option);
loadHirePrefill(option.id, option.listId || undefined)
.then((prefilled) => setData((prev) => ({ ...prev, ...prefilled, DepartmentId: '' })))
.catch((e) => setSubmitError(`Не удалось заполнить из заявки на приём: ${friendlyErrorMessage(e)}`));
};
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
@@ -282,7 +300,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
toApprovers(matrix.rows),
toApprovers(executors.rows),
status,
sourceId,
sourceHire ? sourceHireLookupId(sourceHire) : undefined,
writeListId
);
setRequestId(id);
@@ -381,6 +399,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
{!readOnly && (
<HireRequestPicker
selected={sourceHire}
onPick={handlePickHire}
onClear={() => setSourceHire(undefined)}
/>
)}
<FieldChangesBanner changes={approverChanges} />
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={readOnly}>
@@ -0,0 +1,86 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { REQUEST_STATUS, currentPartitionForPageUrl } from '../../shared/approval';
import { RequestTypeId } from '../../models/types';
import { queryAllPartitions, statusFilter } from '../RecruitingHire/partitionQuery';
import { IFormData } from './model';
/**
* Выбор «Заявки на прием», на основе которой создаётся «Организация рабочего
* места». Нужен тем, кто не видит чужую заявку на приём в портале и не может
* нажать «на основе» из неё самой.
*
* Из заявки на приём читаем ТОЛЬКО поля для предзаполнения (и для подписи в
* списке) — оклады и прочие данные кандидата в браузер не уходят.
*/
/** Слаг формы приёма — ключ реестра форм и pageUrl типа заявки в approval-route. */
const HIRE_FORM_ID: RequestTypeId = 'recruiting-hire';
// Рабочее место готовят под уже утверждённый приём: «Согласовано», затем
// исполнение и закрытие — это тот же утверждённый приём (как isApprovedStatus).
const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed];
/** Поля заявки на приём, которые переносятся в форму рабочего места. */
const PREFILL_FIELDS = ['HireCandidateName', 'Phone', 'Position', 'Department', 'SiteLocation', 'HireHireDate'];
export interface IHireRequestOption {
id: number;
/** Серверный url партиции, где лежит заявка. '' — партиция неизвестна (текущий список по имени). */
listId: string;
/** Уникальный код заявки (напр. «5-2610-12»). '' — запись до появления поля кода. */
code: string;
candidateName: string;
position: string;
}
/** Поля формы рабочего места, которые заполняются из заявки на приём. */
export type HirePrefill = Pick<IFormData, 'FullName' | 'Phone' | 'Position' | 'Department' | 'Location' | 'StartDate'>;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Утверждённые заявки на приём из всех партиций серии, новые сверху. */
export async function getApprovedHireRequests(): Promise<IHireRequestOption[]> {
const rows = await queryAllPartitions(HIRE_FORM_ID, statusFilter(APPROVED_STATUSES), [
'Id',
'RequestUniqueCode',
'HireCandidateName',
'Position'
]);
return rows.map(({ listId, row }) => ({
id: Number(row.Id),
listId,
code: text(row.RequestUniqueCode),
candidateName: text(row.HireCandidateName),
position: text(row.Position)
}));
}
/** Прочитать из заявки на приём только поля для предзаполнения. */
export async function loadHirePrefill(hireId: number, listId?: string): Promise<HirePrefill> {
const sp = getSP();
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(HIRE_FORM_ID));
const item = (await list.items.getById(hireId).select(...PREFILL_FIELDS)()) as Record<string, unknown>;
return {
FullName: text(item.HireCandidateName),
Phone: text(item.Phone),
Position: text(item.Position),
Department: text(item.Department),
Location: text(item.SiteLocation),
StartDate: spDateOnlyToIso(item.HireHireDate)
};
}
/**
* Id для колонки-lookup SourceHire. Lookup смотрит только в ТЕКУЩУЮ партицию
* «Прием на работу — данные»: заявка из архивной партиции туда не попадёт, а
* тот же Id в текущем списке — это другая заявка. Поэтому для архивной связь не пишем.
*/
export function sourceHireLookupId(option: Pick<IHireRequestOption, 'id' | 'listId'>): number | undefined {
if (!option.listId) return option.id;
const current = currentPartitionForPageUrl(HIRE_FORM_ID);
return current && current.listId === option.listId ? option.id : undefined;
}
@@ -1,19 +0,0 @@
import { loadHireRequest } from '../RecruitingHire/load';
import { IFormData, EMPTY_DATA } from './model';
/**
* Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания).
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
*/
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
const { data: hire } = await loadHireRequest(hireId);
return {
...EMPTY_DATA,
FullName: hire.HireCandidateName,
Phone: hire.Phone,
Position: hire.Position,
Department: hire.Department,
Location: hire.SiteLocation,
StartDate: hire.HireHireDate
};
}
@@ -0,0 +1,107 @@
@import '../../../../../styles/design-tokens';
.wrap {
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;
}
.note {
padding: 10px 12px;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
.selected {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2 $sp-3;
}
.selectedLabel {
min-width: 0;
font-family: $font-sans;
font-size: 13.5px;
font-weight: 600;
color: $navy-700;
overflow-wrap: anywhere;
}
.textBtn {
padding: 0;
border: 0;
background: transparent;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-700;
cursor: pointer;
&:hover {
color: $navy-700;
text-decoration: underline;
}
}
.hint {
margin: $sp-2 0 0;
font-family: $font-sans;
font-size: $fs-caption;
color: $gray-500;
}
@@ -0,0 +1,119 @@
import * as React from 'react';
import styles from './HireRequestPicker.module.scss';
import { CollapseSection, FormField } from '../../../shared/ui';
import { getApprovedHireRequests, IHireRequestOption } from '../hireLookup';
const MAX_SUGGESTIONS = 30;
const HINT =
'ФИО, телефон, должность, подразделение, местоположение и дата приёма заполнятся из выбранной заявки — их можно поправить.';
interface Props {
/** Выбранная заявка на приём; undefined — не выбрана. */
selected: IHireRequestOption | undefined;
onPick: (option: IHireRequestOption) => void;
onClear: () => void;
}
/** Читаемая подпись: «№ код — ФИО кандидата». */
function optionLabel(o: IHireRequestOption): string {
const head = `№ ${o.code || o.id}`;
return o.candidateName ? `${head} — ${o.candidateName}` : head;
}
function matches(o: IHireRequestOption, query: string): boolean {
return [o.code, o.candidateName, o.position].some((v) => v.toLowerCase().indexOf(query) !== -1);
}
/**
* «На основе заявки на приём»: поиск утверждённой заявки на приём по номеру,
* ФИО кандидата или должности. Выбор предзаполняет форму (делает родитель в onPick).
* Показывается только в редактируемой форме.
*/
export const HireRequestPicker: React.FC<Props> = ({ selected, onPick, onClear }) => {
const [options, setOptions] = React.useState<IHireRequestOption[]>([]);
const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
// Список грузим лениво — только когда пользователь начинает выбирать.
const ensureLoaded = (): void => {
if (loadState !== 'idle') return;
setLoadState('loading');
getApprovedHireRequests()
.then((loaded) => {
setOptions(loaded);
setLoadState('loaded');
})
.catch(() => setLoadState('error'));
};
const q = query.trim().toLowerCase();
const suggestions = (q ? options.filter((o) => matches(o, q)) : options).slice(0, MAX_SUGGESTIONS);
const handlePick = (o: IHireRequestOption): void => {
setOpen(false);
setQuery('');
onPick(o);
};
let emptyText = 'Загрузка…';
if (loadState === 'error') emptyText = 'Не удалось загрузить заявки на приём.';
if (loadState === 'loaded') emptyText = 'Утверждённых заявок на приём не найдено.';
return (
<CollapseSection title="На основе заявки на приём" icon="copy">
<FormField label="Заявка на приём" htmlFor="SourceHire" full>
{selected ? (
<div className={styles.selected}>
<span className={styles.selectedLabel}>{optionLabel(selected)}</span>
<button type="button" className={styles.textBtn} onClick={onClear}>
Очистить
</button>
</div>
) : (
<div className={styles.wrap}>
<input
id="SourceHire"
className={styles.input}
value={query}
placeholder="Начните вводить номер, ФИО кандидата или должность…"
onChange={(e) => {
setQuery(e.target.value);
ensureLoaded();
setOpen(true);
}}
onFocus={() => {
ensureLoaded();
setOpen(true);
}}
onBlur={() => setOpen(false)}
/>
{open && (
<div className={styles.list}>
{suggestions.length > 0 ? (
suggestions.map((o) => (
<button
key={`${o.listId}:${o.id}`}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur)
// раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(o)}
>
{optionLabel(o)}
{o.position && <span className={styles.meta}>{o.position}</span>}
</button>
))
) : (
<div className={styles.note}>{emptyText}</div>
)}
</div>
)}
</div>
)}
<p className={styles.hint}>{HINT}</p>
</FormField>
</CollapseSection>
);
};