diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md index 01cd5ac..385ed6f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md @@ -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` diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 9f67c2b..766497c 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -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 = ({ }; }, [editId, editListId, notifyCode, setSubmitError]); + // Заявка на приём, на основе которой создаётся эта: из кнопки «на основе» в + // самой заявке на приём (rpsource) или из поля выбора в форме (HireRequestPicker). + const [sourceHire, setSourceHire] = useState( + 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 = ({ 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 = ({ toApprovers(matrix.rows), toApprovers(executors.rows), status, - sourceId, + sourceHire ? sourceHireLookupId(sourceHire) : undefined, writeListId ); setRequestId(id); @@ -381,6 +399,14 @@ const WorkplaceSetupForm: React.FC = ({ )} {submit.error && {submit.error}} + {!readOnly && ( + setSourceHire(undefined)} + /> + )} + diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/hireLookup.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/hireLookup.ts new file mode 100644 index 0000000..dcbdea9 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/hireLookup.ts @@ -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; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** Утверждённые заявки на приём из всех партиций серии, новые сверху. */ +export async function getApprovedHireRequests(): Promise { + 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 { + 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; + 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): number | undefined { + if (!option.listId) return option.id; + const current = currentPartitionForPageUrl(HIRE_FORM_ID); + return current && current.listId === option.listId ? option.id : undefined; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts deleted file mode 100644 index d917178..0000000 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { loadHireRequest } from '../RecruitingHire/load'; -import { IFormData, EMPTY_DATA } from './model'; - -/** - * Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания). - * Копируются согласованные поля; остальное остаётся пустым для ручного заполнения. - */ -export async function loadPrefillFromHire(hireId: number): Promise { - 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 - }; -} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.module.scss new file mode 100644 index 0000000..e71608a --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx new file mode 100644 index 0000000..84d46f2 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx @@ -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 = ({ selected, onPick, onClear }) => { + const [options, setOptions] = React.useState([]); + const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const [query, setQuery] = React.useState(''); + const [open, setOpen] = React.useState(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 ( + + + {selected ? ( +
+ {optionLabel(selected)} + +
+ ) : ( +
+ { + setQuery(e.target.value); + ensureLoaded(); + setOpen(true); + }} + onFocus={() => { + ensureLoaded(); + setOpen(true); + }} + onBlur={() => setOpen(false)} + /> + {open && ( +
+ {suggestions.length > 0 ? ( + suggestions.map((o) => ( + + )) + ) : ( +
{emptyText}
+ )} +
+ )} +
+ )} +

{HINT}

+
+
+ ); +};