Compare commits
7 Commits
e85a3a239e
...
1248d01202
| Author | SHA1 | Date | |
|---|---|---|---|
| 1248d01202 | |||
| af8d39152e | |||
| bf4c4c517a | |||
| c964405dd6 | |||
| df33608f42 | |||
| 0b2961a741 | |||
| e8daf0813d |
@@ -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
|
||||
@@ -3,7 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ApproveFeature.module.scss';
|
||||
import { getViewComponent } from '../../forms/viewRegistry';
|
||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||
import { RequestChat } from '../../shared/chat';
|
||||
import { RequestChat, isChatEnabledFor } from '../../shared/chat';
|
||||
|
||||
export interface IApproveFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -44,8 +44,11 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
||||
</React.Suspense>
|
||||
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||
в отличие от панели решения, которая видна только текущему шагу. */}
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
в отличие от панели решения, которая видна только текущему шагу.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getFormComponent } from '../../forms/registry';
|
||||
import { REQUEST_TYPES } from '../../data/requests';
|
||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||
import { RequestChat } from '../../shared/chat';
|
||||
import { RequestChat, isChatEnabledFor } from '../../shared/chat';
|
||||
|
||||
export interface IEditFeatureProps {
|
||||
context: WebPartContext;
|
||||
@@ -69,8 +69,11 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
|
||||
</React.Suspense>
|
||||
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||
возврата на доработку, и уточнить причину логично не уходя с формы. */}
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
возврата на доработку, и уточнить причину логично не уходя с формы.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,16 +13,38 @@
|
||||
## Особое поведение
|
||||
- **Маска телефона** (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,11 @@ 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 [otherFiles, setOtherFiles] = useState<File[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
// При открытии на редактирование (editId) — сразу известен, иначе появится
|
||||
// после первого сохранения.
|
||||
@@ -210,9 +223,17 @@ 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),
|
||||
...otherFiles
|
||||
];
|
||||
if (filesToUpload.length > 0) {
|
||||
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, filesToUpload); // файлы — после создания элемента
|
||||
setIdFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||
setResumeFiles([]);
|
||||
setOtherFiles([]);
|
||||
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
|
||||
}
|
||||
if (onSaved) {
|
||||
@@ -258,6 +279,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,17 +321,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>
|
||||
)}
|
||||
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
<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>
|
||||
<FormField label="Прочие вложения" full>
|
||||
{otherAttachments.length > 0 && (
|
||||
<AttachmentList items={otherAttachments} onRemove={handleRemoveAttachment} />
|
||||
)}
|
||||
<FileDropzone files={otherFiles} onChange={setOtherFiles} />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
+86
-46
@@ -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('');
|
||||
}}
|
||||
options={addOptions}
|
||||
placeholder={placeholder ?? '— выберите —'}
|
||||
/>
|
||||
</div>
|
||||
{isOther && (
|
||||
<div className={styles.addField}>
|
||||
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" />
|
||||
{!readOnly && (
|
||||
<div className={styles.field}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
/>
|
||||
{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) — вызывать сразу после успешного ответа, ДО того
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isChatEnabledFor } from './chatEnabled';
|
||||
|
||||
describe('isChatEnabledFor', () => {
|
||||
it('включён для «Обходного листа»', () => {
|
||||
expect(isChatEnabledFor('clearance-form')).toBe(true);
|
||||
});
|
||||
|
||||
it('выключен для остальных типов заявок', () => {
|
||||
expect(isChatEnabledFor('hr-vacation')).toBe(false);
|
||||
expect(isChatEnabledFor('it-1c-ticket')).toBe(false);
|
||||
expect(isChatEnabledFor('recruiting-hire')).toBe(false);
|
||||
});
|
||||
|
||||
it('незнакомый ключ — тоже выключен, а не бросает', () => {
|
||||
expect(isChatEnabledFor('что-то-новое')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
|
||||
/**
|
||||
* Типы заявок, для которых показывается чат (shared/chat).
|
||||
*
|
||||
* Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form').
|
||||
* Остальным формам чат пока не нужен — решение сузить область до одного типа,
|
||||
* а не включать сразу на все девять.
|
||||
*
|
||||
* Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model:
|
||||
* shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону
|
||||
* — формы берут общее из shared), а RequestTypeId — обычная строка, не enum,
|
||||
* так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE
|
||||
* (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок.
|
||||
*/
|
||||
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>(['clearance-form']);
|
||||
|
||||
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
|
||||
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
|
||||
* автообновление раз в 17с) и для заявок, где чат не включён. */
|
||||
export function isChatEnabledFor(formKey: RequestTypeId): boolean {
|
||||
return CHAT_ENABLED_FORM_IDS.has(formKey);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { displayFileName, fileStamp, sanitizeFileName, storedFileName } from './chatFileNames';
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('заменяет запрещённые SharePoint символы', () => {
|
||||
expect(sanitizeFileName('от 24/08 №1: акт*.pdf')).toBe('от 24_08 №1_ акт_.pdf');
|
||||
});
|
||||
|
||||
it('убирает точки в конце — такое имя SharePoint не принимает', () => {
|
||||
expect(sanitizeFileName('акт...')).toBe('акт');
|
||||
});
|
||||
|
||||
it('пустое имя заменяет заглушкой, а не пустой строкой', () => {
|
||||
expect(sanitizeFileName(' ')).toBe('файл');
|
||||
});
|
||||
|
||||
it('длинное имя обрезает, сохраняя расширение', () => {
|
||||
const long = `${'я'.repeat(200)}.pdf`;
|
||||
const result = sanitizeFileName(long);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(120);
|
||||
expect(result.endsWith('.pdf')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storedFileName / displayFileName', () => {
|
||||
it('складское имя разбирается обратно в исходное', () => {
|
||||
const stored = storedFileName('20260824-153012', 1, 'акт приёмки.pdf');
|
||||
|
||||
expect(stored).toBe('20260824-153012-1__акт приёмки.pdf');
|
||||
expect(displayFileName(stored)).toBe('акт приёмки.pdf');
|
||||
});
|
||||
|
||||
it('двойное подчёркивание в исходном имени не ломает разбор', () => {
|
||||
const stored = storedFileName('20260824-153012', 2, 'акт__итог.pdf');
|
||||
expect(displayFileName(stored)).toBe('акт__итог.pdf');
|
||||
});
|
||||
|
||||
it('файл, положенный в библиотеку не порталом, показывается как есть', () => {
|
||||
expect(displayFileName('справка.pdf')).toBe('справка.pdf');
|
||||
});
|
||||
|
||||
it('файлы одного сообщения получают разные имена', () => {
|
||||
const stamp = fileStamp(new Date(2026, 7, 24, 15, 30, 12));
|
||||
|
||||
expect(stamp).toBe('20260824-153012');
|
||||
expect(storedFileName(stamp, 1, 'скан.jpg')).not.toBe(storedFileName(stamp, 2, 'скан.jpg'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Имена файлов чата. Модуль чистый (без SharePoint) и покрыт тестами: от
|
||||
* формата имени зависит и то, что файл вообще запишется, и то, каким он
|
||||
* покажется в переписке.
|
||||
*
|
||||
* Имя в библиотеке: «<метка времени>-<номер>__<исходное имя>», например
|
||||
* «20260824-153012-1__акт приёмки.pdf». Префикс нужен, чтобы два человека
|
||||
* (или один дважды) могли прикрепить к одной заявке файлы с одинаковым
|
||||
* названием — без него второй файл перезаписал бы первый.
|
||||
*/
|
||||
|
||||
/** Разделитель служебного префикса и исходного имени. */
|
||||
const SEPARATOR = '__';
|
||||
|
||||
/**
|
||||
* Запрещённые SharePoint символы в имени файла (" * : < > ? / \ |), плюс # % ~:
|
||||
* формально они допустимы в SPO, но ломают прямые ссылки на файл.
|
||||
*/
|
||||
const FORBIDDEN_CHARS = /["*:<>?/\\|#%~]/g;
|
||||
|
||||
/**
|
||||
* Предел длины исходной части имени. Общий предел пути в SharePoint — 400
|
||||
* символов, и в него входят адрес сайта, имя библиотеки и папка заявки;
|
||||
* 120 символов на само имя оставляют запас и при этом не режут нормальные
|
||||
* названия документов.
|
||||
*/
|
||||
const MAX_NAME_LENGTH = 120;
|
||||
|
||||
function splitExtension(name: string): { base: string; extension: string } {
|
||||
const dot = name.lastIndexOf('.');
|
||||
// Точка в начале — это не расширение, а имя вида «.gitignore».
|
||||
if (dot <= 0) return { base: name, extension: '' };
|
||||
return { base: name.slice(0, dot), extension: name.slice(dot) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Привести имя файла к тому, что SharePoint примет: убрать запрещённые символы
|
||||
* и обрезать слишком длинное имя, СОХРАНИВ расширение (без него файл не
|
||||
* откроется нужным приложением).
|
||||
*/
|
||||
export function sanitizeFileName(name: string): string {
|
||||
const cleaned = name.replace(FORBIDDEN_CHARS, '_').replace(/\s+/g, ' ').trim();
|
||||
// Имя, оканчивающееся точкой, SharePoint тоже не принимает.
|
||||
const withoutTrailingDots = cleaned.replace(/\.+$/, '');
|
||||
if (!withoutTrailingDots) return 'файл';
|
||||
|
||||
if (withoutTrailingDots.length <= MAX_NAME_LENGTH) return withoutTrailingDots;
|
||||
|
||||
const { base, extension } = splitExtension(withoutTrailingDots);
|
||||
const room = MAX_NAME_LENGTH - extension.length;
|
||||
// Расширение длиннее всего лимита — вырожденный случай, режем как есть.
|
||||
if (room <= 0) return withoutTrailingDots.slice(0, MAX_NAME_LENGTH);
|
||||
return `${base.slice(0, room)}${extension}`;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value < 10 ? `0${value}` : `${value}`;
|
||||
}
|
||||
|
||||
/** Метка времени для префикса: «20260824-153012». */
|
||||
export function fileStamp(date: Date): string {
|
||||
const day = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
|
||||
const time = `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
|
||||
return `${day}-${time}`;
|
||||
}
|
||||
|
||||
/** Имя, под которым файл ляжет в библиотеку. index — порядок файла в сообщении. */
|
||||
export function storedFileName(stamp: string, index: number, originalName: string): string {
|
||||
return `${stamp}-${index}${SEPARATOR}${sanitizeFileName(originalName)}`;
|
||||
}
|
||||
|
||||
/** Исходное имя из складского: то, что видит пользователь в переписке. */
|
||||
export function displayFileName(storedName: string): string {
|
||||
const at = storedName.indexOf(SEPARATOR);
|
||||
// Разделителя нет — файл положили не порталом; показываем имя как есть.
|
||||
return at < 0 ? storedName : storedName.slice(at + SEPARATOR.length);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/folders';
|
||||
import '@pnp/sp/files';
|
||||
import type { IFolder } from '@pnp/sp/folders';
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import { resolveDataList } from '../dataList';
|
||||
import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { fileStamp, sanitizeFileName, storedFileName } from './chatFileNames';
|
||||
|
||||
/**
|
||||
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
|
||||
* данных, папка на каждую заявку: «Файлы чата/<RequestUniqueCode>», например
|
||||
* «Файлы чата/13-2608-42» — тот же код, что показан в заголовке заявки (см.
|
||||
* FormCard subtitle в <Форма>View.tsx). Так папку легко узнать в самой
|
||||
* библиотеке, не открывая портал.
|
||||
*
|
||||
* Почему не вложения самой заявки (shared/attachments): вложения — это
|
||||
* документы заявки, они попадают в её карточку и печать. Файл из переписки —
|
||||
* не документ заявки, а часть разговора; смешивать их значит засорять заявку.
|
||||
*
|
||||
* Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в
|
||||
* chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе,
|
||||
* пока в ней действительно нет файлов.
|
||||
*/
|
||||
|
||||
/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */
|
||||
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Сколько файлов можно приложить к одному сообщению. */
|
||||
export const MAX_FILES_PER_MESSAGE = 20;
|
||||
|
||||
// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке
|
||||
// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте
|
||||
// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что
|
||||
// в shared/approval/siteUser.ts).
|
||||
let libraryRootPromise: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Серверный относительный адрес корня библиотеки, например
|
||||
* «/sites/requests/Файлы чата».
|
||||
*
|
||||
* Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url
|
||||
* библиотеки может не совпадать с её названием (и меняться при переносе), а
|
||||
* заголовок — то, что реально настраивал администратор.
|
||||
*/
|
||||
function chatLibraryRoot(): Promise<string> {
|
||||
if (!libraryRootPromise) {
|
||||
libraryRootPromise = getSP()
|
||||
.web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE)
|
||||
.rootFolder.select('ServerRelativeUrl')()
|
||||
.then((folder) => folder.ServerRelativeUrl as string)
|
||||
.catch((e) => {
|
||||
libraryRootPromise = undefined;
|
||||
throw new Error(
|
||||
`Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}`
|
||||
);
|
||||
});
|
||||
}
|
||||
return libraryRootPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Имя папки заявки в библиотеке — RequestUniqueCode элемента.
|
||||
*
|
||||
* Код читаем из самого элемента, а не собираем строкой (formKey+itemId), чтобы
|
||||
* папка называлась ровно тем номером, который видит пользователь на заявке.
|
||||
* Код записывается сразу после создания элемента (см. saveRequestItem в
|
||||
* shared/dataList.ts) и с этого момента не меняется, поэтому лишний раз его
|
||||
* не кешируем: запрос — это одно select-поле, а не тяжёлая операция.
|
||||
*
|
||||
* Пустой код (гипотетическая гонка на только что созданной заявке) — откат на
|
||||
* старую схему имени, чтобы папка всё равно нашлась однозначно.
|
||||
*/
|
||||
async function requestFolderName(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined
|
||||
): Promise<string> {
|
||||
const item = (await resolveDataList(formKey, listId)
|
||||
.items.getById(itemId)
|
||||
.select('RequestUniqueCode')()) as { RequestUniqueCode?: string };
|
||||
const code = (item.RequestUniqueCode || '').trim();
|
||||
return sanitizeFileName(code || `${formKey}-${itemId}`);
|
||||
}
|
||||
|
||||
/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */
|
||||
export async function chatFilesFolderUrl(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId?: string
|
||||
): Promise<string> {
|
||||
const [root, name] = await Promise.all([
|
||||
chatLibraryRoot(),
|
||||
requestFolderName(formKey, itemId, listId)
|
||||
]);
|
||||
return `${root}/${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Папка заявки, гарантированно существующая.
|
||||
*
|
||||
* Проверяем существование отдельным запросом, а не создаём «поверх»:
|
||||
* addSubFolderUsingPath на существующей папке возвращает ошибку, а
|
||||
* addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее
|
||||
* приложенные к заявке файлы.
|
||||
*/
|
||||
async function ensureRequestFolder(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined
|
||||
): Promise<IFolder> {
|
||||
const [root, name] = await Promise.all([
|
||||
chatLibraryRoot(),
|
||||
requestFolderName(formKey, itemId, listId)
|
||||
]);
|
||||
const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`);
|
||||
|
||||
const exists = await folder
|
||||
.select('Exists')()
|
||||
.then((info) => !!info.Exists)
|
||||
.catch(() => false);
|
||||
|
||||
if (!exists) {
|
||||
await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их
|
||||
* сервис чата кладёт в текст комментария (см. buildCommentText).
|
||||
*
|
||||
* Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать.
|
||||
* Если создать комментарий потом не удастся, файлы останутся в библиотеке
|
||||
* ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы.
|
||||
*/
|
||||
export async function uploadChatFiles(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined,
|
||||
files: File[]
|
||||
): Promise<string[]> {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const folder = await ensureRequestFolder(formKey, itemId, listId);
|
||||
const stamp = fileStamp(new Date());
|
||||
const storedNames: string[] = [];
|
||||
|
||||
// Последовательно, а не Promise.all: параллельная запись в одну папку чаще
|
||||
// упирается в throttling SharePoint, а файлов здесь единицы.
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const name = storedFileName(stamp, i + 1, files[i].name);
|
||||
// File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно.
|
||||
await folder.files.addUsingPath(name, files[i]);
|
||||
storedNames.push(name);
|
||||
}
|
||||
return storedNames;
|
||||
}
|
||||
|
||||
/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */
|
||||
export function validateChatFiles(files: File[]): string | undefined {
|
||||
if (files.length > MAX_FILES_PER_MESSAGE) {
|
||||
return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`;
|
||||
}
|
||||
const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0];
|
||||
if (tooBig) {
|
||||
const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
|
||||
return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ICommentSource, toChatMessages } from './chatMessages';
|
||||
import {
|
||||
ICommentSource,
|
||||
buildCommentText,
|
||||
hasAttachments,
|
||||
toChatMessages
|
||||
} from './chatMessages';
|
||||
|
||||
const ME = 'ivan@tnsu.kz';
|
||||
|
||||
@@ -73,3 +78,79 @@ describe('toChatMessages', () => {
|
||||
expect(messages[1].authorName).toBe('Неизвестный автор');
|
||||
});
|
||||
});
|
||||
|
||||
describe('файлы в сообщении', () => {
|
||||
const FOLDER = '/sites/requests/Файлы чата/hr-vacation-12';
|
||||
const FILE = '20260824-153012-1__акт.pdf';
|
||||
const SECOND_FILE = '20260824-153012-2__скан.jpg';
|
||||
|
||||
it('пометка о файлах не показывается в тексте сообщения', () => {
|
||||
const messages = toChatMessages(
|
||||
[comment({ text: `Посмотрите акт\n[rp-files]${FILE}` })],
|
||||
ME,
|
||||
FOLDER
|
||||
);
|
||||
|
||||
expect(messages[0].text).toBe('Посмотрите акт');
|
||||
});
|
||||
|
||||
it('файл получает исходное имя и ссылку в папке заявки', () => {
|
||||
const messages = toChatMessages(
|
||||
[comment({ text: `[rp-files]${FILE}|${SECOND_FILE}` })],
|
||||
ME,
|
||||
FOLDER
|
||||
);
|
||||
|
||||
expect(messages[0].text).toBe('');
|
||||
expect(messages[0].attachments).toEqual([
|
||||
{ fileName: 'акт.pdf', url: `${FOLDER}/${FILE}` },
|
||||
{ fileName: 'скан.jpg', url: `${FOLDER}/${SECOND_FILE}` }
|
||||
]);
|
||||
});
|
||||
|
||||
it('сообщение из одних файлов не отбрасывается как пустое', () => {
|
||||
const messages = toChatMessages([comment({ text: `[rp-files]${FILE}` })], ME, FOLDER);
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('пометка переживает перевод строки в формате Windows', () => {
|
||||
const messages = toChatMessages([comment({ text: `Текст\r\n[rp-files]${FILE}` })], ME, FOLDER);
|
||||
|
||||
expect(messages[0].text).toBe('Текст');
|
||||
expect(messages[0].attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('обычное сообщение остаётся без вложений', () => {
|
||||
const messages = toChatMessages([comment({ text: 'Просто текст' })], ME);
|
||||
expect(messages[0].attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('hasAttachments отличает переписку с файлами от переписки без них', () => {
|
||||
expect(hasAttachments([comment({ text: 'Просто текст' })])).toBe(false);
|
||||
expect(hasAttachments([comment({ text: `[rp-files]${FILE}` })])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCommentText', () => {
|
||||
it('без файлов отдаёт очищенный текст', () => {
|
||||
expect(buildCommentText(' Привет ', [])).toBe('Привет');
|
||||
});
|
||||
|
||||
it('с файлами дописывает пометку отдельной строкой', () => {
|
||||
expect(buildCommentText('Смотрите', ['a__акт.pdf', 'b__скан.jpg'])).toBe(
|
||||
'Смотрите\n[rp-files]a__акт.pdf|b__скан.jpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('без текста остаётся одна пометка — отправка «только файл»', () => {
|
||||
expect(buildCommentText('', ['a__акт.pdf'])).toBe('[rp-files]a__акт.pdf');
|
||||
});
|
||||
|
||||
it('собранный текст читается обратно без потерь', () => {
|
||||
const raw = buildCommentText('Смотрите акт', ['20260824-153012-1__акт.pdf']);
|
||||
const messages = toChatMessages([comment({ text: raw })], ME, '/f');
|
||||
|
||||
expect(messages[0].text).toBe('Смотрите акт');
|
||||
expect(messages[0].attachments[0].fileName).toBe('акт.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IChatMessage } from './models/IChatMessage';
|
||||
import { IChatAttachment, IChatMessage } from './models/IChatMessage';
|
||||
import { displayFileName } from './chatFileNames';
|
||||
|
||||
/**
|
||||
* Часть комментария SharePoint, из которой строится сообщение чата.
|
||||
@@ -15,6 +16,53 @@ export interface ICommentSource {
|
||||
author?: { name?: string; email?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Служебная пометка о прикреплённых файлах в КОНЦЕ текста комментария:
|
||||
* Проверьте, пожалуйста, акт
|
||||
* [rp-files]20260824-153012-1__акт.pdf|20260824-153012-2__скан.jpg
|
||||
*
|
||||
* Почему в тексте, а не отдельным хранилищем: у комментария SharePoint нет
|
||||
* своих полей — только текст. Класть связь «сообщение → файлы» больше некуда,
|
||||
* не заводя отдельный список и не превращая каждую загрузку чата в лишний
|
||||
* запрос к библиотеке. Имена файлов складские (с префиксом), но не полные
|
||||
* адреса — адрес библиотеки резолвится на лету, поэтому её можно переместить,
|
||||
* и ссылки в старой переписке не сломаются.
|
||||
*
|
||||
* Разделитель имён — «|»: этот символ SharePoint в именах файлов запрещает
|
||||
* (см. FORBIDDEN_CHARS), значит в имени он встретиться не может.
|
||||
*/
|
||||
const FILE_MARKER = '[rp-files]';
|
||||
|
||||
/** Пометка всегда последняя и всегда в одну строку — отсюда привязка к концу. */
|
||||
const FILE_MARKER_PATTERN = /\s*\[rp-files\]([^\r\n]*)$/;
|
||||
|
||||
/**
|
||||
* Собрать текст комментария из сообщения и складских имён файлов.
|
||||
* Сообщение может быть пустым — тогда комментарий состоит из одной пометки
|
||||
* (так выглядит отправка «только файл»).
|
||||
*/
|
||||
export function buildCommentText(text: string, storedFileNames: string[]): string {
|
||||
const trimmed = text.trim();
|
||||
if (storedFileNames.length === 0) return trimmed;
|
||||
|
||||
const marker = `${FILE_MARKER}${storedFileNames.join('|')}`;
|
||||
return trimmed ? `${trimmed}\n${marker}` : marker;
|
||||
}
|
||||
|
||||
function parseStoredNames(raw: string): string[] {
|
||||
const found = FILE_MARKER_PATTERN.exec(raw);
|
||||
if (!found) return [];
|
||||
return found[1]
|
||||
.split('|')
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => !!name);
|
||||
}
|
||||
|
||||
/** Есть ли в переписке файлы — по этому признаку сервис решает, нужен ли адрес библиотеки. */
|
||||
export function hasAttachments(comments: ICommentSource[]): boolean {
|
||||
return comments.some((c) => !!c.text && FILE_MARKER_PATTERN.test(c.text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Дата комментария приходит строкой ISO. Если часового пояса в ней нет,
|
||||
* new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC —
|
||||
@@ -26,19 +74,32 @@ function parseCommentDate(raw: string): Date {
|
||||
return new Date(hasTimeZone ? raw : `${raw}Z`);
|
||||
}
|
||||
|
||||
function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMessage {
|
||||
function toAttachments(storedNames: string[], folderUrl: string): IChatAttachment[] {
|
||||
return storedNames.map((storedName) => ({
|
||||
fileName: displayFileName(storedName),
|
||||
url: folderUrl ? `${folderUrl}/${storedName}` : ''
|
||||
}));
|
||||
}
|
||||
|
||||
function toMessage(
|
||||
comment: ICommentSource,
|
||||
currentUserEmail: string,
|
||||
folderUrl: string
|
||||
): IChatMessage {
|
||||
const authorEmail = comment.author?.email || '';
|
||||
const authorName = comment.author?.name || authorEmail || 'Неизвестный автор';
|
||||
const storedNames = parseStoredNames(comment.text);
|
||||
return {
|
||||
id: comment.id,
|
||||
text: comment.text,
|
||||
text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(),
|
||||
authorName,
|
||||
authorEmail,
|
||||
createdAt: parseCommentDate(comment.createdDate),
|
||||
isOwn:
|
||||
!!authorEmail &&
|
||||
!!currentUserEmail &&
|
||||
authorEmail.toLowerCase() === currentUserEmail.toLowerCase()
|
||||
authorEmail.toLowerCase() === currentUserEmail.toLowerCase(),
|
||||
attachments: toAttachments(storedNames, folderUrl)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,16 +108,21 @@ function toMessage(comment: ICommentSource, currentUserEmail: string): IChatMess
|
||||
* переписке: свежее внизу).
|
||||
*
|
||||
* Порядок ответа API не документирован и мы на него не опираемся — сортируем
|
||||
* по дате сами. Пустые комментарии отбрасываем: показывать пустой «пузырь»
|
||||
* нечего, а в списке они появляются, если кто-то отправил комментарий не из
|
||||
* портала (например, из стандартной формы SharePoint).
|
||||
* по дате сами. Комментарии без текста и без файлов отбрасываем: показывать
|
||||
* пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил
|
||||
* комментарий не из портала (например, из стандартной формы SharePoint).
|
||||
*
|
||||
* folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима,
|
||||
* когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался).
|
||||
*/
|
||||
export function toChatMessages(
|
||||
comments: ICommentSource[],
|
||||
currentUserEmail: string
|
||||
currentUserEmail: string,
|
||||
folderUrl: string = ''
|
||||
): IChatMessage[] {
|
||||
return comments
|
||||
.filter((c) => !!c.text && !!c.text.trim())
|
||||
.map((c) => toMessage(c, currentUserEmail))
|
||||
.map((c) => toMessage(c, currentUserEmail, folderUrl))
|
||||
.filter((m) => !!m.text || m.attachments.length > 0)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
// Единая точка импорта чата по заявке.
|
||||
export { RequestChat } from './ui/RequestChat';
|
||||
export { isChatEnabledFor } from './chatEnabled';
|
||||
export { useRequestChat } from './useRequestChat';
|
||||
export type { IRequestChat } from './useRequestChat';
|
||||
export { getChatMessages, addChatMessage } from './requestChat.service';
|
||||
export { toChatMessages } from './chatMessages';
|
||||
export {
|
||||
uploadChatFiles,
|
||||
chatFilesFolderUrl,
|
||||
validateChatFiles,
|
||||
MAX_FILES_PER_MESSAGE,
|
||||
MAX_FILE_SIZE_BYTES
|
||||
} from './chatFiles.service';
|
||||
export { toChatMessages, buildCommentText, hasAttachments } from './chatMessages';
|
||||
export type { ICommentSource } from './chatMessages';
|
||||
export type { IChatMessage } from './models/IChatMessage';
|
||||
export { sanitizeFileName, storedFileName, displayFileName, fileStamp } from './chatFileNames';
|
||||
export type { IChatMessage, IChatAttachment } from './models/IChatMessage';
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/** Файл, прикреплённый к сообщению чата (лежит в библиотеке «Файлы чата»). */
|
||||
export interface IChatAttachment {
|
||||
/** Имя для показа — без служебного префикса (см. chatFileNames.ts). */
|
||||
fileName: string;
|
||||
/** Ссылка на файл в библиотеке. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение чата заявки — комментарий к элементу списка данных, приведённый
|
||||
* к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo).
|
||||
@@ -5,6 +13,7 @@
|
||||
export interface IChatMessage {
|
||||
/** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */
|
||||
id: string;
|
||||
/** Текст сообщения БЕЗ служебной пометки о файлах. */
|
||||
text: string;
|
||||
/** ФИО автора; если SharePoint его не отдал — email или заглушка. */
|
||||
authorName: string;
|
||||
@@ -15,4 +24,6 @@ export interface IChatMessage {
|
||||
* Считается по email автора, см. toChatMessages.
|
||||
*/
|
||||
isOwn: boolean;
|
||||
/** Прикреплённые файлы; пустой массив — файлов нет. */
|
||||
attachments: IChatAttachment[];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { ICommentInfo, IComments } from '@pnp/sp/comments';
|
||||
import { resolveDataList } from '../dataList';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IChatMessage } from './models/IChatMessage';
|
||||
import { toChatMessages } from './chatMessages';
|
||||
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
|
||||
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
|
||||
|
||||
/**
|
||||
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
|
||||
@@ -15,6 +16,10 @@ import { toChatMessages } from './chatMessages';
|
||||
* колонки в девяти списках, ни провижининга, и переписка не мешает
|
||||
* согласованию (комментарий решения — отдельное поле строки маршрута).
|
||||
*
|
||||
* Единственное, чего у комментария нет, — вложений: у него есть только текст.
|
||||
* Поэтому файлы лежат в отдельной библиотеке (chatFiles.service.ts), а в тексте
|
||||
* комментария остаётся пометка с их именами (chatMessages.ts).
|
||||
*
|
||||
* Заявка живёт в КОНКРЕТНОЙ партиции (списки нумерованные, см.
|
||||
* shared/approval/listPartitions.ts), поэтому список резолвим общим
|
||||
* resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку.
|
||||
@@ -47,18 +52,27 @@ export async function getChatMessages(
|
||||
currentUserEmail: string
|
||||
): Promise<IChatMessage[]> {
|
||||
const comments: ICommentInfo[] = await commentsOf(formKey, itemId, listId)();
|
||||
return toChatMessages(comments, currentUserEmail);
|
||||
// Адрес библиотеки спрашиваем, только если в переписке ЕСТЬ файлы: у заявки
|
||||
// без вложений чат тогда вообще не зависит от библиотеки — ни лишнего
|
||||
// запроса, ни падения, если её переименуют.
|
||||
const folderUrl = hasAttachments(comments) ? await chatFilesFolderUrl(formKey, itemId, listId) : '';
|
||||
return toChatMessages(comments, currentUserEmail, folderUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение — добавить комментарий к строке заявки. Автора и время
|
||||
* ставит сам SharePoint, поэтому передаём только текст.
|
||||
*
|
||||
* Файлы сначала уходят в библиотеку, и только потом создаётся комментарий с
|
||||
* пометкой об их именах — см. uploadChatFiles о том, почему такой порядок.
|
||||
*/
|
||||
export async function addChatMessage(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined,
|
||||
text: string
|
||||
text: string,
|
||||
files: File[] = []
|
||||
): Promise<void> {
|
||||
await commentsOf(formKey, itemId, listId).add(text);
|
||||
const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
|
||||
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestChat.module.scss';
|
||||
import { Button, Icon } from '../../ui';
|
||||
import { Button, FileDropzone, Icon } from '../../ui';
|
||||
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
|
||||
|
||||
interface IChatComposerProps {
|
||||
sending: boolean;
|
||||
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */
|
||||
onSend: (text: string) => Promise<boolean>;
|
||||
onSend: (text: string, files: File[]) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
|
||||
|
||||
/**
|
||||
* Поле ввода сообщения. Здесь НЕ ui-kit TextArea, а собственный textarea:
|
||||
* чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не
|
||||
* пробрасывает.
|
||||
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
|
||||
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
|
||||
* не пробрасывает.
|
||||
*/
|
||||
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
|
||||
const [text, setText] = React.useState<string>('');
|
||||
const canSend = text.trim().length > 0 && !sending;
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
|
||||
|
||||
// Сообщение из одних файлов — нормальный случай («вот скан»), поэтому пустой
|
||||
// текст отправку не блокирует.
|
||||
const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
|
||||
|
||||
const send = (): void => {
|
||||
if (!canSend) return;
|
||||
onSend(text)
|
||||
onSend(text, files)
|
||||
.then((sent) => {
|
||||
if (sent) setText('');
|
||||
if (!sent) return;
|
||||
setText('');
|
||||
setFiles([]);
|
||||
setFilesOpen(false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
@@ -35,21 +46,45 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.composer}>
|
||||
<textarea
|
||||
className={styles.input}
|
||||
value={text}
|
||||
rows={2}
|
||||
disabled={sending}
|
||||
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
|
||||
aria-label="Сообщение в обсуждение заявки"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button variant="accent" disabled={!canSend} onClick={send}>
|
||||
<Icon name="send" size="sm" />
|
||||
{sending ? 'Отправка…' : 'Отправить'}
|
||||
</Button>
|
||||
<div className={styles.composerBlock}>
|
||||
{filesOpen && (
|
||||
<div className={styles.dropzone}>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
<div className={styles.dropzoneHint}>
|
||||
До {MAX_FILES_PER_MESSAGE} файлов, каждый не больше {MAX_FILE_SIZE_MB} МБ.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.composer}>
|
||||
<textarea
|
||||
className={styles.input}
|
||||
value={text}
|
||||
rows={2}
|
||||
disabled={sending}
|
||||
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
|
||||
aria-label="Сообщение в обсуждение заявки"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className={styles.composerActions}>
|
||||
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
|
||||
пропадает с глаз, и легко отправить не то, что собирался. */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={sending}
|
||||
aria-label="Прикрепить файлы"
|
||||
onClick={() => setFilesOpen((open) => !open || files.length > 0)}
|
||||
>
|
||||
<Icon name="upload" size="sm" />
|
||||
{files.length > 0 ? `Файлы (${files.length})` : 'Файл'}
|
||||
</Button>
|
||||
<Button variant="accent" disabled={!canSend} onClick={send}>
|
||||
<Icon name="send" size="sm" />
|
||||
{sending ? 'Отправка…' : 'Отправить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestChat.module.scss';
|
||||
import { AttachmentList } from '../../ui';
|
||||
import { IChatMessage } from '../models/IChatMessage';
|
||||
|
||||
interface IChatMessageProps {
|
||||
@@ -28,8 +29,14 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
|
||||
<span className={styles.sentAt}>{formatSentAt(message.createdAt)}</span>
|
||||
</div>
|
||||
{/* Переносы строк сохраняем стилем (white-space: pre-wrap), а не <br/> —
|
||||
текст приходит от пользователя и в разметку его подставлять нельзя. */}
|
||||
<div className={styles.text}>{message.text}</div>
|
||||
текст приходит от пользователя и в разметку его подставлять нельзя.
|
||||
Текста может не быть вовсе: сообщение из одних файлов. */}
|
||||
{message.text && <div className={styles.text}>{message.text}</div>}
|
||||
{message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<AttachmentList items={message.attachments} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -125,11 +125,46 @@
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
// Вложения сообщения — отделяем от текста тонкой линией, чтобы список файлов
|
||||
// не сливался с самим сообщением.
|
||||
.attachments {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.composerBlock {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
// Зона выбора файлов — над строкой ввода: то, что уйдёт с сообщением, видно
|
||||
// раньше, чем кнопка «Отправить».
|
||||
.dropzone {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
.dropzoneHint {
|
||||
margin-top: 8px;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.composerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -168,6 +203,10 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.composerActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IChatMessage } from './models/IChatMessage';
|
||||
import { addChatMessage, getChatMessages } from './requestChat.service';
|
||||
import { validateChatFiles } from './chatFiles.service';
|
||||
|
||||
/**
|
||||
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
|
||||
@@ -18,11 +19,12 @@ export interface IRequestChat {
|
||||
sending: boolean;
|
||||
error?: string;
|
||||
/**
|
||||
* Отправить сообщение. Пустой текст игнорируется. Возвращает true, если
|
||||
* сообщение записано: по false поле ввода НЕ очищается, иначе набранный
|
||||
* текст пропал бы вместе с неудачной отправкой.
|
||||
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
|
||||
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
|
||||
* записано: по false поле ввода и выбранные файлы НЕ сбрасываются, иначе
|
||||
* набранное пропало бы вместе с неудачной отправкой.
|
||||
*/
|
||||
send: (text: string) => Promise<boolean>;
|
||||
send: (text: string, files: File[]) => Promise<boolean>;
|
||||
/** Перечитать переписку (кнопка «Обновить»). */
|
||||
reload: () => void;
|
||||
}
|
||||
@@ -85,12 +87,21 @@ export function useRequestChat(
|
||||
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
|
||||
|
||||
const send = React.useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
async (text: string, files: File[]): Promise<boolean> => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
// Сообщение из одних файлов — нормальный случай («вот скан»), поэтому
|
||||
// пустой текст сам по себе отправку не отменяет.
|
||||
if (!trimmed && files.length === 0) return false;
|
||||
|
||||
const filesError = validateChatFiles(files);
|
||||
if (filesError) {
|
||||
setError(filesError);
|
||||
return false;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await addChatMessage(formKey, itemId, listId, trimmed);
|
||||
await addChatMessage(formKey, itemId, listId, trimmed, files);
|
||||
setError(undefined);
|
||||
reload();
|
||||
return true;
|
||||
|
||||
@@ -142,3 +142,14 @@ export function approvalListTitleForListId(id: RequestTypeId, listId: string): s
|
||||
);
|
||||
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Библиотека документов, куда складываются файлы, прикреплённые к сообщениям
|
||||
* чата заявки (shared/chat). Живёт на том же сайте данных (DATA_SITE_URL), что
|
||||
* и списки заявок, и создана вручную — провижинер её не заводит.
|
||||
*
|
||||
* Не партиционируется в отличие от списков заявок: файлы лежат в подпапке на
|
||||
* заявку (см. chatFiles.service.ts), доступ к ним идёт по прямому пути, а не
|
||||
* через представление, поэтому порог в 5000 элементов на такой доступ не влияет.
|
||||
*/
|
||||
export const CHAT_FILES_LIBRARY_TITLE = 'Файлы чата';
|
||||
|
||||
Reference in New Issue
Block a user