feat: выбор заявки на приём прямо в форме организации рабочего места
Tests / frontend-test (push) Successful in 2m1s
Tests / frontend-test (push) Successful in 2m1s
Рабочее место оформляют на основе заявки на приём, но кнопку «на основе» видит только тот, кому видна сама заявка на приём. Теперь в форме есть поиск по утверждённым заявкам на приём (все партиции), выбор предзаполняет ФИО, телефон, должность, подразделение, местоположение и дату приёма. Из заявки на приём читаются только эти поля — оклады в браузер не уходят (старая предзагрузка читала заявку целиком). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
+107
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user