fix: изменил заявку на подбор и заявку на прием согласно требованиям от 24.08.2026
Tests / frontend-test (push) Successful in 1m12s

This commit is contained in:
Тимур Городецкий
2026-08-24 15:12:42 +05:00
parent fd19ddc41a
commit 0b2961a741
23 changed files with 1016 additions and 79 deletions
+55
View File
@@ -0,0 +1,55 @@
<#
.SYNOPSIS
Пересоздаёт колонку с нужным типом, когда фактический тип на сайте разошёлся с
манифестом (провижинер сам тип существующей колонки не меняет). Колонка сначала
снимается со списка и удаляется как столбец сайта, затем создаётся заново нужного
типа и подключается к списку.
ВНИМАНИЕ: значения этой колонки в существующих элементах при удалении теряются.
.EXAMPLE
# Починить BusinessTrips: сделать Boolean вместо Note
./Fix-ColumnType.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Заявка на подбор 1 — данные" `
-InternalName "BusinessTrips" -DisplayName "Командировки" -NewType Boolean
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle,
[Parameter(Mandatory = $true)][string]$InternalName,
[Parameter(Mandatory = $true)][string]$DisplayName,
[Parameter(Mandatory = $true)][ValidateSet('Boolean', 'Number', 'Text', 'Note', 'DateTime')][string]$NewType
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$group = 'Портал заявок'
# 1) Снять колонку со списка (если подключена).
try {
Remove-PnPField -List $ListTitle -Identity $InternalName -Force -ErrorAction Stop
Write-Host " [~] снята со списка: $InternalName" -ForegroundColor Yellow
} catch { Write-Host " [ ] на списке не было отдельной ссылки: $InternalName" -ForegroundColor DarkGray }
# 2) Удалить столбец сайта (каскадно снимает и оставшиеся ссылки).
try {
Remove-PnPField -Identity $InternalName -Force -ErrorAction Stop
Write-Host " [~] удалён столбец сайта: $InternalName" -ForegroundColor Yellow
} catch { Write-Host " [ ] столбца сайта не было: $InternalName" -ForegroundColor DarkGray }
# 3) Создать заново нужного типа как столбец сайта.
Add-PnPField -InternalName $InternalName -DisplayName $DisplayName -Type $NewType -Group $group | Out-Null
Write-Host " [+] создан заново: $InternalName [$NewType]" -ForegroundColor Green
# 4) Подключить к списку.
Add-PnPField -List $ListTitle -Field $InternalName | Out-Null
Write-Host " [+] подключён к списку «$ListTitle»" -ForegroundColor Green
Write-Host "`nГотово." -ForegroundColor Cyan
Disconnect-PnPOnline
+31
View File
@@ -0,0 +1,31 @@
<#
.SYNOPSIS
Диагностика: печатает фактический тип колонок списка данных в SharePoint —
чтобы поймать расхождение с манифестом (напр. Number-поле реально Text).
Только чтение, ничего не меняет.
.EXAMPLE
./Inspect-Fields.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Заявка на подбор 1 — данные"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
Write-Host "`nКолонки списка «$ListTitle»:`n" -ForegroundColor Cyan
Get-PnPField -List $ListTitle |
Where-Object { -not $_.Hidden -and $_.Group -ne '_Hidden' } |
Select-Object InternalName, TypeAsString, @{N='Mult';E={$_.TypeAsString -like '*Multi*'}} |
Sort-Object InternalName |
Format-Table -AutoSize
Disconnect-PnPOnline
@@ -13,16 +13,37 @@
## Особое поведение ## Особое поведение
- **Маска телефона** (PhoneInput) на поле «Телефон». - **Маска телефона** (PhoneInput) на поле «Телефон».
- **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после). - **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после).
- **Мультивыбор компаний (Companies)** — справочник `GET /Dictionary/Companies`
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
и «Условия» (`AgencyTerms`), при выключении оба поля очищаются.
- **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда - **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда
тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели). тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели).
- Секция **СИЗ** (рост, размеры одежды/обуви). - Секция **СИЗ** (рост, размеры одежды/обуви).
- **Является источником для «Организация рабочего места»** — из просмотра этой - **Является источником для «Организация рабочего места»** — из просмотра этой
заявки кнопка «Организация рабочего места на основе этой» открывает форму заявки кнопка «Организация рабочего места на основе этой» открывает форму
it-workplace-setup с префиллом (rpsource = id этой заявки). it-workplace-setup с префиллом (rpsource = id этой заявки).
- **Связь с «Заявкой на подбор» (SelectionRequest\*)** — необязательное поле в
секции «Заявка»: поиск-выбор согласованной заявки на подбор (`SelectionRequestField`
+ `selectionLookup.ts`, только статус «Согласовано», ищет по всем партициям серии).
Связь хранится тройкой полей `SelectionRequestId` / `SelectionRequestListId` /
`SelectionRequestCode`, а НЕ нативным Lookup — списки партиционированы. В форме
и в просмотре показывается ссылкой «№ код ↗», которая открывает
`RecruitingSelectionView` в **новой вкладке** (deep-link через `routeUrl`,
rptype=recruiting-selection, rpid/rplist из сохранённых полей).
- **Вложения по категориям** — две области: «Удостоверение» и «Резюме» (в каждую
можно несколько файлов). Вложения SP плоские, поэтому категория кодируется
префиксом в имени файла «<Категория> — <имя>» (`attachmentCategories.ts`); при
показе группируются и префикс срезается. Старые файлы без префикса показываются
блоком «Прочие вложения», чтобы не исчезали.
- Печать — общий механизм (без «Вложений»). - Печать — общий механизм (без «Вложений»).
## Секции формы ## Секции формы
ApplicationSection · CandidateSection · PpeSection · ConditionsSection ApplicationSection · CandidateSection · PpeSection · ConditionsSection · AgencySection
## Точки входа ## Точки входа
- rptype: `recruiting-hire` - rptype: `recruiting-hire`
@@ -47,10 +47,19 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService } from './submit'; import { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import {
ATT_CATEGORY_ID,
ATT_CATEGORY_RESUME,
categoryPrefix,
prefixFiles,
attachmentsOfCategory,
uncategorizedAttachments
} from './attachmentCategories';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -61,7 +70,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
editListId editListId
}) => { }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); // Вложения — две категории: удостоверение и резюме (см. attachmentCategories.ts).
const [idFiles, setIdFiles] = useState<File[]>([]);
const [resumeFiles, setResumeFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// При открытии на редактирование (editId) — сразу известен, иначе появится // При открытии на редактирование (editId) — сразу известен, иначе появится
// после первого сохранения. // после первого сохранения.
@@ -210,9 +221,15 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
if (code) { if (code) {
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { // Категорию кодируем префиксом в имени файла (SP-вложения плоские).
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента const filesToUpload = [
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало ...prefixFiles(idFiles, ATT_CATEGORY_ID),
...prefixFiles(resumeFiles, ATT_CATEGORY_RESUME)
];
if (filesToUpload.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, filesToUpload); // файлы — после создания элемента
setIdFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setResumeFiles([]);
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
@@ -258,6 +275,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const sectionProps = { data, set, context }; const sectionProps = { data, set, context };
// Уже прикреплённые файлы, разложенные по категориям (+ прочие — старые без префикса).
const idAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_ID);
const resumeAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_RESUME);
const otherAttachments = uncategorizedAttachments(existingAttachments);
return ( return (
<div> <div>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
@@ -295,18 +317,34 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
<CandidateSection {...sectionProps} /> <CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} /> <PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} /> <ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
{!readOnly && ( {!readOnly && (
<CollapseSection title="Вложения" icon="upload"> <CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && ( <FormField label="Удостоверение" full>
<FormField label="Прикреплённые файлы" full> {idAttachments.length > 0 && (
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} /> <AttachmentList
items={idAttachments}
onRemove={(name) => handleRemoveAttachment(categoryPrefix(ATT_CATEGORY_ID) + name)}
/>
)}
<FileDropzone files={idFiles} onChange={setIdFiles} />
</FormField>
<FormField label="Резюме" full>
{resumeAttachments.length > 0 && (
<AttachmentList
items={resumeAttachments}
onRemove={(name) => handleRemoveAttachment(categoryPrefix(ATT_CATEGORY_RESUME) + name)}
/>
)}
<FileDropzone files={resumeFiles} onChange={setResumeFiles} />
</FormField>
{otherAttachments.length > 0 && (
<FormField label="Прочие вложения" full>
<AttachmentList items={otherAttachments} onRemove={handleRemoveAttachment} />
</FormField> </FormField>
)} )}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection> </CollapseSection>
)} )}
@@ -12,3 +12,9 @@
justify-content: flex-end; justify-content: flex-end;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
.emptyAtt {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
@@ -24,12 +24,14 @@ import {
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService } from './submit'; import { hireApprovalService } from './submit';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
interface IRecruitingHireViewProps { interface IRecruitingHireViewProps {
context: WebPartContext; context: WebPartContext;
@@ -187,13 +189,30 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<CandidateSection {...sectionProps} /> <CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} /> <PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} /> <ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
<div className="rp-no-print"> <div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload"> <CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full> <FormField label="Удостоверение" full>
<AttachmentList items={attachments} /> {attachmentsOfCategory(attachments, ATT_CATEGORY_ID).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_ID)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField> </FormField>
<FormField label="Резюме" full>
{attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField>
{uncategorizedAttachments(attachments).length > 0 && (
<FormField label="Прочие вложения" full>
<AttachmentList items={uncategorizedAttachments(attachments)} />
</FormField>
)}
</CollapseSection> </CollapseSection>
</div> </div>
</FormCard> </FormCard>
@@ -0,0 +1,50 @@
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
/**
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
*/
export const ATT_CATEGORY_ID = 'Удостоверение';
export const ATT_CATEGORY_RESUME = 'Резюме';
const SEP = ' — ';
/** Префикс имени файла для категории: «Удостоверение — ». */
export function categoryPrefix(category: string): string {
return `${category}${SEP}`;
}
/**
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
*/
export function prefixFiles(files: File[], category: string): File[] {
const prefix = categoryPrefix(category);
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
}
/**
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
* обратно (см. categoryPrefix в обработчике удаления формы).
*/
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
const prefix = categoryPrefix(category);
return items
.filter((a) => a.fileName.indexOf(prefix) === 0)
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
}
/**
* Вложения без известной категории (старые заявки, загруженные до разделения).
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
*/
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
return items.filter(
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
);
}
@@ -1,12 +1,26 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind', 'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind',
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'SalaryProbation', 'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm' 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
]; ];
export interface ILoadedRequest { export interface ILoadedRequest {
@@ -29,11 +43,16 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID)); const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'CompaniesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
// Мультивыбор компаний хранится Note-колонкой CompaniesJson (не одноимённой колонкой).
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
SCALAR_FIELDS.forEach((key) => { SCALAR_FIELDS.forEach((key) => {
const value = item[key]; const value = item[key];
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
/** /**
@@ -14,9 +15,15 @@ export const FORM_ID: RequestTypeId = 'recruiting-hire';
*/ */
export interface IFormData { export interface IFormData {
// Заявка (шапка) // Заявка (шапка)
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
Phone: string; Phone: string;
HireType: string; HireType: string;
ReplacedEmployee: string; ReplacedEmployee: string;
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
SelectionRequestCode: string; // уникальный код для подписи/ссылки
// Кандидат // Кандидат
CandidateName: string; CandidateName: string;
HireDate: string; HireDate: string;
@@ -29,10 +36,15 @@ export interface IFormData {
PpeClothingSize: string; PpeClothingSize: string;
PpeShoeSize: string; PpeShoeSize: string;
// Условия оплаты и оформления // Условия оплаты и оформления
ProbationPeriod: string;
SalaryProbation: string; SalaryProbation: string;
SalaryAfterProbation: string; SalaryAfterProbation: string;
WorkSchedule: string; WorkSchedule: string;
EmploymentForm: string; WorkScheduleOther: string;
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
RecruitingAgency: boolean;
AgencyName: string;
AgencyTerms: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -46,9 +58,13 @@ export interface ISectionProps {
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Companies: [],
Phone: '', Phone: '',
HireType: '', HireType: '',
ReplacedEmployee: '', ReplacedEmployee: '',
SelectionRequestId: '',
SelectionRequestListId: '',
SelectionRequestCode: '',
CandidateName: '', CandidateName: '',
HireDate: '', HireDate: '',
Position: '', Position: '',
@@ -58,10 +74,14 @@ export const EMPTY_DATA: IFormData = {
PpeHeight: '', PpeHeight: '',
PpeClothingSize: '', PpeClothingSize: '',
PpeShoeSize: '', PpeShoeSize: '',
ProbationPeriod: '',
SalaryProbation: '', SalaryProbation: '',
SalaryAfterProbation: '', SalaryAfterProbation: '',
WorkSchedule: '', WorkSchedule: '',
EmploymentForm: '' WorkScheduleOther: '',
RecruitingAgency: false,
AgencyName: '',
AgencyTerms: ''
}; };
/** Значение «Тип приёма», при котором показывается поле «Взамен кого». */ /** Значение «Тип приёма», при котором показывается поле «Взамен кого». */
@@ -73,10 +93,33 @@ export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT } { value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }
]; ];
/** Варианты «Вид оформления» — совпадают с choices колонки EmploymentForm. */ /** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */
export const EMPLOYMENT_FORM_OPTIONS: ISelectOption[] = [ export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [
{ value: 'ГПХ', label: 'ГПХ' }, { value: 'Без испытательного срока', label: 'Без испытательного срока' },
{ value: 'Штат', label: 'Штат' } { value: '1 месяц', label: '1 месяц' },
{ value: '2 месяца', label: '2 месяца' },
{ value: '3 месяца', label: '3 месяца' },
{ value: '6 месяцев', label: '6 месяцев' }
];
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода графика. */
export const OTHER_VALUE = 'Другое';
/**
* Варианты «График работы» — совпадают с набором в форме «Заявка на подбор»
* (WORK_SCHEDULE_OPTIONS): формы независимы, значения продублированы намеренно.
*/
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
{ value: '5/2', label: '5/2' },
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
{ value: '20/10', label: '20/10' },
{ value: '30/15', label: '30/15' },
{ value: '22/8', label: '22/8' },
{ value: '15/15', label: '15/15' },
{ value: '6/1', label: '6/1' },
{ value: '1/2', label: '1/2' },
{ value: '2/2', label: '2/2' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
]; ];
/** Плейсхолдер для полей-людей. */ /** Плейсхолдер для полей-людей. */
@@ -4,10 +4,16 @@
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
{ "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
{ "internalName": "SelectionRequestId", "title": "Заявка на подбор — Id", "type": "Number" },
{ "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" },
{ "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" },
{ "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, { "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
@@ -19,9 +25,14 @@
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" }, { "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" }, { "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
{ "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["Без испытательного срока", "1 месяц", "2 месяца", "3 месяца", "6 месяцев"] },
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 }, { "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
{ "internalName": "EmploymentForm", "title": "Вид оформления", "type": "Choice", "choices": ["ГПХ", "Штат"] } { "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" }
] ]
} }
@@ -0,0 +1,36 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Toggle } from '../../../shared/ui';
import { ISectionProps } from '../model';
/**
* Рекрутинговое агентство: флаг «привлекается ли агентство» и — при включённом
* флаге — название агентства и условия сотрудничества. При выключении поля
* очищаются, чтобы в заявку не попали данные скрытого блока.
*/
export const AgencySection: React.FC<ISectionProps> = ({ data, set }) => {
const handleToggle = (value: boolean): void => {
set('RecruitingAgency', value);
if (!value) {
set('AgencyName', '');
set('AgencyTerms', '');
}
};
return (
<CollapseSection title="Рекрутинговое агентство" icon="building">
<FormField label="Привлекается рекрутинговое агентство">
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
</FormField>
{data.RecruitingAgency && (
<>
<FormField label="Название компании" htmlFor="AgencyName" full>
<TextInput id="AgencyName" value={data.AgencyName} onChange={(v) => set('AgencyName', v)} />
</FormField>
<FormField label="Условия" htmlFor="AgencyTerms" full>
<TextArea id="AgencyTerms" value={data.AgencyTerms} onChange={(v) => set('AgencyTerms', v)} />
</FormField>
</>
)}
</CollapseSection>
);
};
@@ -1,8 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { CompanyPickerMulti } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model'; import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model';
import { SelectionRequestField } from './SelectionRequestField';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => { export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT; const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT;
const handleHireTypeChange = (value: string): void => { const handleHireTypeChange = (value: string): void => {
@@ -14,6 +16,18 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => {
return ( return (
<CollapseSection title="Заявка" icon="clipboard"> <CollapseSection title="Заявка" icon="clipboard">
<FormField label="Компания" htmlFor="Companies" full>
<CompanyPickerMulti
id="Companies"
context={context}
value={data.Companies}
onChange={(companies) => set('Companies', companies)}
placeholder="Начните вводить название компании…"
/>
</FormField>
<FormField label="Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="Телефон" htmlFor="Phone"> <FormField label="Телефон" htmlFor="Phone">
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} /> <PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField> </FormField>
@@ -1,9 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYMENT_FORM_OPTIONS } from '../model'; import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => ( export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Условия оплаты и оформления" icon="calculator"> <CollapseSection title="Условия оплаты и оформления" icon="calculator">
<FormField label="Испытательный срок" htmlFor="ProbationPeriod">
<Select
id="ProbationPeriod"
value={data.ProbationPeriod}
onChange={(v) => set('ProbationPeriod', v)}
options={PROBATION_PERIOD_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation"> <FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
<MoneyInput <MoneyInput
id="SalaryProbation" id="SalaryProbation"
@@ -19,16 +28,25 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
/> />
</FormField> </FormField>
<FormField label="График работы" htmlFor="WorkSchedule"> <FormField label="График работы" htmlFor="WorkSchedule">
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
</FormField>
<FormField label="Вид оформления" htmlFor="EmploymentForm">
<Select <Select
id="EmploymentForm" id="WorkSchedule"
value={data.EmploymentForm} value={data.WorkSchedule}
onChange={(v) => set('EmploymentForm', v)} onChange={(v) => {
options={EMPLOYMENT_FORM_OPTIONS} set('WorkSchedule', v);
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
}}
options={WORK_SCHEDULE_OPTIONS}
placeholder="— выберите —" placeholder="— выберите —"
/> />
</FormField> </FormField>
{data.WorkSchedule === OTHER_VALUE && (
<FormField label="График работы (другое)" htmlFor="WorkScheduleOther">
<TextInput
id="WorkScheduleOther"
value={data.WorkScheduleOther}
onChange={(v) => set('WorkScheduleOther', v)}
/>
</FormField>
)}
</CollapseSection> </CollapseSection>
); );
@@ -0,0 +1,123 @@
@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;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: $font-sans;
font-size: 13.5px;
font-weight: 600;
color: $navy-700;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.arrow {
font-size: 12px;
}
.actions {
display: inline-flex;
align-items: center;
gap: $sp-3;
}
.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;
}
}
.empty {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
@@ -0,0 +1,185 @@
import * as React from 'react';
import styles from './SelectionRequestField.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { routeUrl } from '../../../shared/routing';
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
import { IFormData, SetField } from '../model';
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
const SELECTION_FORM_KEY = 'recruiting-selection';
const MAX_SUGGESTIONS = 30;
interface ISelectionRequestFieldProps {
data: IFormData;
set: SetField;
}
/** Читаемая подпись варианта: «№ код — должность». */
function optionLabel(o: ISelectionRequestOption): string {
const head = `№ ${o.code || o.id}`;
return o.position ? `${head} — ${o.position}` : head;
}
/**
* Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на
* подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView).
*
* В режиме чтения (просмотр согласующего) — только ссылка, без поиска (то же
* поле переиспользуется во View через FormModeProvider). Связь необязательна:
* пустое поле в режиме чтения показывается прочерком.
*/
export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
const [options, setOptions] = React.useState<ISelectionRequestOption[]>([]);
const [loaded, setLoaded] = React.useState<boolean>(false);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
const [editing, setEditing] = React.useState<boolean>(false);
const selected = data.SelectionRequestId !== '';
// Справочник согласованных подборов грузим лениво — только когда пользователь
// начинает выбирать: в режиме чтения он не нужен, а на старте формы приёма —
// тем более. loaded ставим до резолва, чтобы фокус/ввод не плодили запросы.
const ensureLoaded = React.useCallback((): void => {
if (loaded) return;
setLoaded(true);
getApprovedSelectionRequests()
.then(setOptions)
.catch(() => undefined);
}, [loaded]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
const base = q
? options.filter(
(o) =>
o.code.toLowerCase().indexOf(q) !== -1 ||
o.position.toLowerCase().indexOf(q) !== -1 ||
o.title.toLowerCase().indexOf(q) !== -1
)
: options;
return base.slice(0, MAX_SUGGESTIONS);
}, [options, query]);
const href = selected
? routeUrl({
mode: 'approve',
nav: 'new',
formKey: SELECTION_FORM_KEY,
itemId: Number(data.SelectionRequestId),
// Партиция неизвестна (реестр не загрузился) → ссылка без rplist,
// просмотр откроется в текущей партиции (см. routing.ts).
listId: data.SelectionRequestListId || undefined
})
: '';
const selectedLabel = `№ ${data.SelectionRequestCode || data.SelectionRequestId}`;
const handlePick = (o: ISelectionRequestOption): void => {
set('SelectionRequestId', String(o.id));
set('SelectionRequestListId', o.listId);
set('SelectionRequestCode', o.code);
setEditing(false);
setOpen(false);
setQuery('');
};
const handleClear = (): void => {
set('SelectionRequestId', '');
set('SelectionRequestListId', '');
set('SelectionRequestCode', '');
setEditing(false);
setQuery('');
};
// Ссылка на выбранную заявку — общий кусок для режима чтения и выбранного состояния.
const link = (
<a className={styles.link} href={href} target="_blank" rel="noopener noreferrer">
{selectedLabel}
<span className={styles.arrow} aria-hidden="true">
↗
</span>
</a>
);
// Выбрана и не редактируем: показываем ссылку + управление (вне режима чтения).
if (selected && !editing) {
return (
<div className={styles.selected}>
{link}
{!readOnly && (
<div className={styles.actions}>
<button
type="button"
className={styles.textBtn}
onClick={() => {
ensureLoaded();
setEditing(true);
setOpen(true);
}}
>
Изменить
</button>
<button
type="button"
className={styles.textBtn}
onClick={handleClear}
>
Очистить
</button>
</div>
)}
</div>
);
}
// Режим чтения без выбранной заявки — прочерк (связи нет).
if (readOnly) {
return <span className={styles.empty}>—</span>;
}
return (
<div className={styles.wrap}>
<input
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.title && <span className={styles.meta}>{o.title}</span>}
</button>
))
) : (
<div className={styles.note}>
{loaded ? 'Нет согласованных заявок на подбор.' : 'Загрузка…'}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,101 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { REQUEST_STATUS, partitionsForPageUrl } from '../../shared/approval';
import { RequestTypeId } from '../../models/types';
/**
* Выборка «Заявок на подбор» для связывания из «Заявки на прием».
*
* Связь между заявками хранится НЕ нативной колонкой Lookup, а тройкой обычных
* полей (id + url партиции + код) — списки портала партиционированы (см.
* shared/approval/listPartitions.ts), а Lookup умеет ссылаться ровно на один
* список и через границу партиции ломается. Поэтому здесь мы читаем список(и)
* подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist).
*/
/** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */
const SELECTION_FORM_ID: RequestTypeId = 'recruiting-selection';
export interface ISelectionRequestOption {
id: number;
/** Серверный url партиции, где лежит заявка (→ rplist в ссылке). '' — партиция неизвестна. */
listId: string;
/** Уникальный код заявки (напр. «4-2608-12»). '' — запись до появления поля кода. */
code: string;
/** Должность из заявки на подбор — для читаемой подписи в подсказках. */
position: string;
/** Системный Title элемента — фолбэк подписи, если код/должность пусты. */
title: string;
}
// Только согласованные заявки на подбор: приём оформляют по уже утверждённому подбору.
const APPROVED_FILTER = `RequestStatus eq '${REQUEST_STATUS.approved}'`;
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
const MAX_ITEMS = 500;
interface IListLike {
items: {
filter: (f: string) => {
select: (...fields: string[]) => {
orderBy: (field: string, ascending: boolean) => {
top: (n: number) => () => Promise<Record<string, unknown>[]>;
};
};
};
};
}
async function queryPartition(list: IListLike, listId: string): Promise<ISelectionRequestOption[]> {
const rows = await list.items
.filter(APPROVED_FILTER)
.select('Id', 'Title', 'RequestUniqueCode', 'Position')
.orderBy('Id', false)
.top(MAX_ITEMS)();
return rows.map((row) => ({
id: Number(row.Id),
listId,
code: (row.RequestUniqueCode as string) || '',
position: (row.Position as string) || '',
title: (row.Title as string) || ''
}));
}
/**
* Все согласованные заявки на подбор из всех партиций серии (архив + текущая).
*
* Реестр партиций знает серию → читаем каждую партицию по её listId (url),
* заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route
* молчал при старте) → откатываемся на текущий список по имени; ссылка тогда
* пойдёт без rplist и просмотр откроется в текущей партиции (см. routing.ts).
* Сбой чтения одной партиции не роняет остальные.
*/
export async function getApprovedSelectionRequests(): Promise<ISelectionRequestOption[]> {
const sp = getSP();
const partitions = partitionsForPageUrl(SELECTION_FORM_ID);
if (partitions.length === 0) {
return queryPartition(
sp.web.lists.getByTitle(dataListTitle(SELECTION_FORM_ID)) as unknown as IListLike,
''
);
}
const perPartition = await Promise.all(
partitions.map((p) =>
queryPartition(sp.web.getList(p.listId) as unknown as IListLike, p.listId).catch((e) => {
console.error(
`[Связь с подбором] партиция «${p.title}» (${p.listId}) не прочитана:`,
(e as Error).message
);
return [] as ISelectionRequestOption[];
})
)
);
// Новые заявки (больший Id) — выше; между партициями сортировать не нужно:
// партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв.
return perPartition.reduce((acc, cur) => acc.concat(cur), []);
}
@@ -3,7 +3,7 @@ import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
// Числовые поля: пустая строка → null, иначе Number(). // Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
/** /**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -24,6 +24,11 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status }; const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => { (Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Companies') {
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note.
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
const value = data[key]; const value = data[key];
if (NUMBER_FIELDS.indexOf(key) !== -1) { if (NUMBER_FIELDS.indexOf(key) !== -1) {
item[key] = value === '' ? null : Number(value); item[key] = value === '' ? null : Number(value);
@@ -178,6 +178,8 @@ export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: 'MS Outlook', label: 'MS Outlook' }, { value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' }, { value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' }, { value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
{ value: 'BIM', label: 'BIM' },
{ value: 'Revit', label: 'Revit' }, { value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' }, { value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' }, { value: 'Компас-3D', label: 'Компас-3D' },
@@ -0,0 +1,104 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
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: block;
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;
}
}
@@ -1,22 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import styles from './LanguagesField.module.scss'; import styles from './MultiChoiceField.module.scss';
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui'; import { useFormReadOnly, ISelectOption } from '../../../shared/ui';
import { OTHER_VALUE } from '../model';
export interface IMultiChoiceFieldProps { export interface IMultiChoiceFieldProps {
value: string[]; value: string[];
onChange: (values: string[]) => void; onChange: (values: string[]) => void;
options: ISelectOption[]; options: ISelectOption[];
/** Разрешить «Другое» с ручным вводом произвольного значения. */ /** Разрешить ввод произвольного значения, которого нет в списке. */
allowOther?: boolean; allowOther?: boolean;
placeholder?: string; placeholder?: string;
} }
const MAX_SUGGESTIONS = 30;
/** /**
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные — * Мультивыбор из фиксированного списка в стиле пикера-справочника
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет * (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField. * фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
* выбранные из списка исключаются. allowOther позволяет добавить чипом
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
*/ */
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value, value,
@@ -26,69 +29,106 @@ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
placeholder placeholder
}) => { }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const [draft, setDraft] = useState<string>(''); const [query, setQuery] = useState<string>('');
const [draftOther, setDraftOther] = useState<string>(''); const [open, setOpen] = useState<boolean>(false);
const selected = new Set(value); const selected = useMemo(() => new Set(value), [value]);
const available = options.filter((o) => !selected.has(o.value)); // Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available; const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
const isOther = draft === OTHER_VALUE; const suggestions = useMemo(() => {
const pending = isOther ? draftOther.trim() : draft; const q = query.trim().toLowerCase();
// Пустой запрос — показываем все ещё не выбранные опции (клик-дропдаун);
// с запросом — фильтруем по label (типа-хед).
const available = options.filter(
(o) => !selected.has(o.value) && (!q || o.label.toLowerCase().indexOf(q) !== -1)
);
return available.slice(0, MAX_SUGGESTIONS);
}, [options, query, selected]);
const add = (): void => { // «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
if (!pending || selected.has(pending)) return; // уже выбранных — предлагаем добавить его как произвольное значение.
onChange([...value, pending]); const trimmed = query.trim();
setDraft(''); const canAddOther =
setDraftOther(''); !!allowOther &&
trimmed.length > 0 &&
!selected.has(trimmed) &&
!options.some((o) => o.value.toLowerCase() === trimmed.toLowerCase());
const add = (v: string): void => {
if (!v || selected.has(v)) return;
onChange([...value, v]);
setQuery('');
// Список НЕ закрываем: поле сохраняет фокус (mousedown с preventDefault),
// поэтому оставляем открытым — сразу видно оставшиеся варианты и можно
// добавлять подряд, не теряя и не возвращая фокус.
setOpen(true);
}; };
const remove = (v: string): void => onChange(value.filter((x) => x !== v)); const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return ( return (
<div> <div className={styles.wrap}>
{value.length > 0 && ( {value.length > 0 && (
<div className={styles.list}> <div className={styles.chips}>
{value.map((v) => ( {value.map((v) => (
<div key={v} className={styles.row}> <span key={v} className={styles.chip}>
<span className={styles.lang}>{v}</span> {labelFor(v)}
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
className={styles.remove} className={styles.chipRemove}
aria-label={`Убрать ${v}`} aria-label={`Убрать ${labelFor(v)}`}
title="Убрать" title="Убрать"
onClick={() => remove(v)} onClick={() => remove(v)}
> >
× ×
</button> </button>
)} )}
</div> </span>
))} ))}
</div> </div>
)} )}
{!readOnly && addOptions.length > 0 && ( {!readOnly && (
<div className={styles.add}> <div className={styles.field}>
<div className={styles.addField}> <input
<Select className={styles.input}
value={draft} value={query}
onChange={(v) => { placeholder={placeholder}
setDraft(v); onChange={(e) => {
if (v !== OTHER_VALUE) setDraftOther(''); setQuery(e.target.value);
setOpen(true);
}} }}
options={addOptions} onFocus={() => setOpen(true)}
placeholder={placeholder ?? '— выберите —'} onBlur={() => setOpen(false)}
/> />
</div> {open && (suggestions.length > 0 || canAddOther) && (
{isOther && ( <div className={styles.list}>
<div className={styles.addField}> {suggestions.map((o) => (
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" /> <button
key={o.value}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(o.value)}
>
{o.label}
</button>
))}
{canAddOther && (
<button
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(trimmed)}
>
Добавить «{trimmed}»
</button>
)}
</div> </div>
)} )}
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
Добавить
</Button>
</div> </div>
)} )}
</div> </div>
@@ -76,6 +76,9 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
for (let j = 0; j < logins.length; j++) { for (let j = 0; j < logins.length; j++) {
ids.push(await resolveUserId(logins[j])); ids.push(await resolveUserId(logins[j]));
} }
// Multi-value поле (UserMulti) в режиме odata=minimalmetadata пишется ПЛОСКИМ
// массивом Id ([1,2,3]). Обёртка { results: [...] } — старый verbose-формат,
// здесь сервер её не принимает («Ожидался StartArray, обнаружен StartObject»).
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids; item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
} }
} }
@@ -38,6 +38,7 @@ export {
partitionsForTypeId, partitionsForTypeId,
currentPartitionForTypeId, currentPartitionForTypeId,
currentPartitionForPageUrl, currentPartitionForPageUrl,
partitionsForPageUrl,
partitionForPageUrlAndListId, partitionForPageUrlAndListId,
isConfirmedStaleListId, isConfirmedStaleListId,
registerCurrentPartition, registerCurrentPartition,
@@ -93,6 +93,18 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un
return id === undefined ? undefined : currentPartitionForTypeId(id); return id === undefined ? undefined : currentPartitionForTypeId(id);
} }
/**
* Все партиции типа заявки по слагу формы (архив + текущая), по возрастанию
* номера. Нужно, чтобы искать заявки СРАЗУ во всех списках серии — например,
* связать «прием» с «подбором», который мог быть подан давно и уехать в архивную
* партицию (см. forms/RecruitingHire/selectionLookup.ts). Пусто — реестр не
* загружен или тип без списка (вызывающий откатывается на текущий список по имени).
*/
export function partitionsForPageUrl(pageUrl: string): IListPartition[] {
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? [] : partitionsForTypeId(id);
}
/** /**
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую * Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того * (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того