fix: изменил заявку на подбор и заявку на прием согласно требованиям от 24.08.2026
Tests / frontend-test (push) Successful in 1m12s
Tests / frontend-test (push) Successful in 1m12s
This commit is contained in:
@@ -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
|
||||
@@ -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) на поле «Телефон».
|
||||
- **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после).
|
||||
- **Мультивыбор компаний (Companies)** — справочник `GET /Dictionary/Companies`
|
||||
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
|
||||
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
|
||||
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
|
||||
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
|
||||
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
|
||||
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
|
||||
и «Условия» (`AgencyTerms`), при выключении оба поля очищаются.
|
||||
- **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда
|
||||
тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели).
|
||||
- Секция **СИЗ** (рост, размеры одежды/обуви).
|
||||
- **Является источником для «Организация рабочего места»** — из просмотра этой
|
||||
заявки кнопка «Организация рабочего места на основе этой» открывает форму
|
||||
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`
|
||||
|
||||
@@ -47,10 +47,19 @@ import {
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { saveHireRequest, hireApprovalService } from './submit';
|
||||
import { loadHireRequest } from './load';
|
||||
import {
|
||||
ATT_CATEGORY_ID,
|
||||
ATT_CATEGORY_RESUME,
|
||||
categoryPrefix,
|
||||
prefixFiles,
|
||||
attachmentsOfCategory,
|
||||
uncategorizedAttachments
|
||||
} from './attachmentCategories';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { AgencySection } from './sections/AgencySection';
|
||||
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
@@ -61,7 +70,9 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
editListId
|
||||
}) => {
|
||||
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[]>([]);
|
||||
// При открытии на редактирование (editId) — сразу известен, иначе появится
|
||||
// после первого сохранения.
|
||||
@@ -210,9 +221,15 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
if (code) {
|
||||
setRequestCode(code);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента
|
||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||
// Категорию кодируем префиксом в имени файла (SP-вложения плоские).
|
||||
const filesToUpload = [
|
||||
...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)); // показать добавленные
|
||||
}
|
||||
if (onSaved) {
|
||||
@@ -258,6 +275,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
// Уже прикреплённые файлы, разложенные по категориям (+ прочие — старые без префикса).
|
||||
const idAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_ID);
|
||||
const resumeAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_RESUME);
|
||||
const otherAttachments = uncategorizedAttachments(existingAttachments);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
@@ -295,18 +317,34 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<AgencySection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
{!readOnly && (
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
{existingAttachments.length > 0 && (
|
||||
<FormField label="Прикреплённые файлы" full>
|
||||
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
|
||||
<FormField label="Удостоверение" full>
|
||||
{idAttachments.length > 0 && (
|
||||
<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 label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,3 +12,9 @@
|
||||
justify-content: flex-end;
|
||||
margin-bottom: $sp-4;
|
||||
}
|
||||
|
||||
.emptyAtt {
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,14 @@ import {
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadHireRequest } from './load';
|
||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
|
||||
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
|
||||
import { hireApprovalService } from './submit';
|
||||
import { ApplicationSection } from './sections/ApplicationSection';
|
||||
import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
import { AgencySection } from './sections/AgencySection';
|
||||
|
||||
interface IRecruitingHireViewProps {
|
||||
context: WebPartContext;
|
||||
@@ -187,13 +189,30 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<AgencySection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
<FormField label="Удостоверение" full>
|
||||
{attachmentsOfCategory(attachments, ATT_CATEGORY_ID).length > 0 ? (
|
||||
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_ID)} />
|
||||
) : (
|
||||
<span className={styles.emptyAtt}>—</span>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</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 { ICompany } from '../../shared/dictionary';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
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)[] = [
|
||||
'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind',
|
||||
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'SalaryProbation',
|
||||
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm'
|
||||
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
|
||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||
'AgencyName', 'AgencyTerms',
|
||||
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
|
||||
];
|
||||
|
||||
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 item = (await list
|
||||
.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 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) => {
|
||||
const value = item[key];
|
||||
if (value === null || value === undefined) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
import { ICompany } from '../../shared/dictionary';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
|
||||
/**
|
||||
@@ -14,9 +15,15 @@ export const FORM_ID: RequestTypeId = 'recruiting-hire';
|
||||
*/
|
||||
export interface IFormData {
|
||||
// Заявка (шапка)
|
||||
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
|
||||
Phone: string;
|
||||
HireType: string;
|
||||
ReplacedEmployee: string;
|
||||
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
|
||||
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
|
||||
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
|
||||
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
|
||||
SelectionRequestCode: string; // уникальный код для подписи/ссылки
|
||||
// Кандидат
|
||||
CandidateName: string;
|
||||
HireDate: string;
|
||||
@@ -29,10 +36,15 @@ export interface IFormData {
|
||||
PpeClothingSize: string;
|
||||
PpeShoeSize: string;
|
||||
// Условия оплаты и оформления
|
||||
ProbationPeriod: string;
|
||||
SalaryProbation: string;
|
||||
SalaryAfterProbation: 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 = {
|
||||
Companies: [],
|
||||
Phone: '',
|
||||
HireType: '',
|
||||
ReplacedEmployee: '',
|
||||
SelectionRequestId: '',
|
||||
SelectionRequestListId: '',
|
||||
SelectionRequestCode: '',
|
||||
CandidateName: '',
|
||||
HireDate: '',
|
||||
Position: '',
|
||||
@@ -58,10 +74,14 @@ export const EMPTY_DATA: IFormData = {
|
||||
PpeHeight: '',
|
||||
PpeClothingSize: '',
|
||||
PpeShoeSize: '',
|
||||
ProbationPeriod: '',
|
||||
SalaryProbation: '',
|
||||
SalaryAfterProbation: '',
|
||||
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 }
|
||||
];
|
||||
|
||||
/** Варианты «Вид оформления» — совпадают с choices колонки EmploymentForm. */
|
||||
export const EMPLOYMENT_FORM_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'ГПХ', label: 'ГПХ' },
|
||||
{ value: 'Штат', label: 'Штат' }
|
||||
/** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */
|
||||
export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [
|
||||
{ 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": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||
|
||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
||||
{ "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": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||
@@ -19,9 +25,14 @@
|
||||
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
|
||||
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
|
||||
|
||||
{ "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["Без испытательного срока", "1 месяц", "2 месяца", "3 месяца", "6 месяцев"] },
|
||||
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
|
||||
{ "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 { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
|
||||
import { CompanyPickerMulti } from '../../../shared/dictionary';
|
||||
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 handleHireTypeChange = (value: string): void => {
|
||||
@@ -14,6 +16,18 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||
</FormField>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import * as React from 'react';
|
||||
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 }) => (
|
||||
<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">
|
||||
<MoneyInput
|
||||
id="SalaryProbation"
|
||||
@@ -19,16 +28,25 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="График работы" htmlFor="WorkSchedule">
|
||||
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
|
||||
</FormField>
|
||||
<FormField label="Вид оформления" htmlFor="EmploymentForm">
|
||||
<Select
|
||||
id="EmploymentForm"
|
||||
value={data.EmploymentForm}
|
||||
onChange={(v) => set('EmploymentForm', v)}
|
||||
options={EMPLOYMENT_FORM_OPTIONS}
|
||||
id="WorkSchedule"
|
||||
value={data.WorkSchedule}
|
||||
onChange={(v) => {
|
||||
set('WorkSchedule', v);
|
||||
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
|
||||
}}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
{data.WorkSchedule === OTHER_VALUE && (
|
||||
<FormField label="График работы (другое)" htmlFor="WorkScheduleOther">
|
||||
<TextInput
|
||||
id="WorkScheduleOther"
|
||||
value={data.WorkScheduleOther}
|
||||
onChange={(v) => set('WorkScheduleOther', v)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
|
||||
+123
@@ -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';
|
||||
|
||||
// Числовые поля: пустая строка → 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 };
|
||||
|
||||
(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];
|
||||
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||
item[key] = value === '' ? null : Number(value);
|
||||
|
||||
@@ -178,6 +178,8 @@ export const SOFTWARE_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'MS Outlook', label: 'MS Outlook' },
|
||||
{ value: 'MS Project', label: 'MS Project' },
|
||||
{ value: 'AutoCAD', label: 'AutoCAD' },
|
||||
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
|
||||
{ value: 'BIM', label: 'BIM' },
|
||||
{ value: 'Revit', label: 'Revit' },
|
||||
{ value: 'ArchiCAD', label: 'ArchiCAD' },
|
||||
{ value: 'Компас-3D', label: 'Компас-3D' },
|
||||
|
||||
+104
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
-44
@@ -1,22 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styles from './LanguagesField.module.scss';
|
||||
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||
import { OTHER_VALUE } from '../model';
|
||||
import { useMemo, useState } from 'react';
|
||||
import styles from './MultiChoiceField.module.scss';
|
||||
import { useFormReadOnly, ISelectOption } from '../../../shared/ui';
|
||||
|
||||
export interface IMultiChoiceFieldProps {
|
||||
value: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
options: ISelectOption[];
|
||||
/** Разрешить «Другое» с ручным вводом произвольного значения. */
|
||||
/** Разрешить ввод произвольного значения, которого нет в списке. */
|
||||
allowOther?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 30;
|
||||
|
||||
/**
|
||||
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные —
|
||||
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет
|
||||
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField.
|
||||
* Мультивыбор из фиксированного списка в стиле пикера-справочника
|
||||
* (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
|
||||
* фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
|
||||
* выбранные из списка исключаются. allowOther позволяет добавить чипом
|
||||
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
|
||||
*/
|
||||
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
|
||||
value,
|
||||
@@ -26,69 +29,106 @@ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
|
||||
placeholder
|
||||
}) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
const [draftOther, setDraftOther] = useState<string>('');
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
|
||||
const selected = new Set(value);
|
||||
const available = options.filter((o) => !selected.has(o.value));
|
||||
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available;
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
// Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
|
||||
const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
|
||||
|
||||
const isOther = draft === OTHER_VALUE;
|
||||
const pending = isOther ? draftOther.trim() : draft;
|
||||
const suggestions = useMemo(() => {
|
||||
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]);
|
||||
setDraft('');
|
||||
setDraftOther('');
|
||||
// «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
|
||||
// уже выбранных — предлагаем добавить его как произвольное значение.
|
||||
const trimmed = query.trim();
|
||||
const canAddOther =
|
||||
!!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));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.wrap}>
|
||||
{value.length > 0 && (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.chips}>
|
||||
{value.map((v) => (
|
||||
<div key={v} className={styles.row}>
|
||||
<span className={styles.lang}>{v}</span>
|
||||
<span key={v} className={styles.chip}>
|
||||
{labelFor(v)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.remove}
|
||||
aria-label={`Убрать ${v}`}
|
||||
className={styles.chipRemove}
|
||||
aria-label={`Убрать ${labelFor(v)}`}
|
||||
title="Убрать"
|
||||
onClick={() => remove(v)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && addOptions.length > 0 && (
|
||||
<div className={styles.add}>
|
||||
<div className={styles.addField}>
|
||||
<Select
|
||||
value={draft}
|
||||
onChange={(v) => {
|
||||
setDraft(v);
|
||||
if (v !== OTHER_VALUE) setDraftOther('');
|
||||
{!readOnly && (
|
||||
<div className={styles.field}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
options={addOptions}
|
||||
placeholder={placeholder ?? '— выберите —'}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
{isOther && (
|
||||
<div className={styles.addField}>
|
||||
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
|
||||
{open && (suggestions.length > 0 || canAddOther) && (
|
||||
<div className={styles.list}>
|
||||
{suggestions.map((o) => (
|
||||
<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>
|
||||
)}
|
||||
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,9 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
|
||||
for (let j = 0; j < logins.length; 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
partitionsForTypeId,
|
||||
currentPartitionForTypeId,
|
||||
currentPartitionForPageUrl,
|
||||
partitionsForPageUrl,
|
||||
partitionForPageUrlAndListId,
|
||||
isConfirmedStaleListId,
|
||||
registerCurrentPartition,
|
||||
|
||||
@@ -93,6 +93,18 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un
|
||||
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 ТОЛЬКО ЧТО вернул как текущую
|
||||
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
|
||||
|
||||
Reference in New Issue
Block a user