7 Commits

Author SHA1 Message Date
gorbenko.pavel 1248d01202 fix: убрал из подсказки чата упоминание библиотеки «Файлы чата»
Tests / frontend-test (pull_request) Successful in 1m11s
Пользователю не нужно знать, куда физически складываются файлы —
это внутренняя механика. Лимиты (количество и размер) оставлены.
2026-08-24 16:22:48 +05:00
gorbenko.pavel af8d39152e fix: папка файлов чата называется по RequestUniqueCode, а не formKey-id
Раньше папка в библиотеке «Файлы чата» называлась «clearance-form-42» —
это техническое имя ничего не говорит без открытого портала. Теперь имя
папки — тот же код заявки, что показан в её заголовке («13-2608-42»),
поэтому папку легко узнать прямо в библиотеке.
2026-08-24 16:18:39 +05:00
gorbenko.pavel bf4c4c517a feat: чат по заявке — только в обходном листе, лимит файлов до 20
Остальным восьми типам заявок чат пока не нужен: сужаем область до
одной формы (isChatEnabledFor), не включая сразу везде. Лимит файлов
на сообщение поднят с 5 до 20 — пяти оказалось мало для реальной
переписки по обходному листу.
2026-08-24 16:16:59 +05:00
gorbenko.pavel c964405dd6 Merge branch 'main' into request-chat 2026-08-24 16:01:55 +05:00
Тимур Городецкий df33608f42 fix: добавил прочие вложения на форму заявки на прием
Tests / frontend-test (push) Successful in 1m12s
2026-08-24 15:47:57 +05:00
Тимур Городецкий 0b2961a741 fix: изменил заявку на подбор и заявку на прием согласно требованиям от 24.08.2026
Tests / frontend-test (push) Successful in 1m12s
2026-08-24 15:12:42 +05:00
gorbenko.pavel e8daf0813d feat: файлы в чате заявки — библиотека «Файлы чата»
У комментария SharePoint нет вложений, только текст. Поэтому файлы уходят
в отдельную библиотеку на сайте данных (папка на заявку), а в тексте
комментария остаётся пометка с их именами — по ней сообщение и находит свои
файлы. Адрес библиотеки резолвится по заголовку на лету, поэтому ссылки в
переписке не ломаются при её переносе, а чат без файлов к библиотеке вообще
не обращается.
2026-08-24 14:05:04 +05:00
40 changed files with 1707 additions and 135 deletions
+55
View File
@@ -0,0 +1,55 @@
<#
.SYNOPSIS
Пересоздаёт колонку с нужным типом, когда фактический тип на сайте разошёлся с
манифестом (провижинер сам тип существующей колонки не меняет). Колонка сначала
снимается со списка и удаляется как столбец сайта, затем создаётся заново нужного
типа и подключается к списку.
ВНИМАНИЕ: значения этой колонки в существующих элементах при удалении теряются.
.EXAMPLE
# Починить BusinessTrips: сделать Boolean вместо Note
./Fix-ColumnType.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Заявка на подбор 1 — данные" `
-InternalName "BusinessTrips" -DisplayName "Командировки" -NewType Boolean
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle,
[Parameter(Mandatory = $true)][string]$InternalName,
[Parameter(Mandatory = $true)][string]$DisplayName,
[Parameter(Mandatory = $true)][ValidateSet('Boolean', 'Number', 'Text', 'Note', 'DateTime')][string]$NewType
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
$group = 'Портал заявок'
# 1) Снять колонку со списка (если подключена).
try {
Remove-PnPField -List $ListTitle -Identity $InternalName -Force -ErrorAction Stop
Write-Host " [~] снята со списка: $InternalName" -ForegroundColor Yellow
} catch { Write-Host " [ ] на списке не было отдельной ссылки: $InternalName" -ForegroundColor DarkGray }
# 2) Удалить столбец сайта (каскадно снимает и оставшиеся ссылки).
try {
Remove-PnPField -Identity $InternalName -Force -ErrorAction Stop
Write-Host " [~] удалён столбец сайта: $InternalName" -ForegroundColor Yellow
} catch { Write-Host " [ ] столбца сайта не было: $InternalName" -ForegroundColor DarkGray }
# 3) Создать заново нужного типа как столбец сайта.
Add-PnPField -InternalName $InternalName -DisplayName $DisplayName -Type $NewType -Group $group | Out-Null
Write-Host " [+] создан заново: $InternalName [$NewType]" -ForegroundColor Green
# 4) Подключить к списку.
Add-PnPField -List $ListTitle -Field $InternalName | Out-Null
Write-Host " [+] подключён к списку «$ListTitle»" -ForegroundColor Green
Write-Host "`nГотово." -ForegroundColor Cyan
Disconnect-PnPOnline
+31
View File
@@ -0,0 +1,31 @@
<#
.SYNOPSIS
Диагностика: печатает фактический тип колонок списка данных в SharePoint —
чтобы поймать расхождение с манифестом (напр. Number-поле реально Text).
Только чтение, ничего не меняет.
.EXAMPLE
./Inspect-Fields.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
-ListTitle "Заявка на подбор 1 — данные"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[Parameter(Mandatory = $true)][string]$ListTitle
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
Write-Host "`nКолонки списка «$ListTitle»:`n" -ForegroundColor Cyan
Get-PnPField -List $ListTitle |
Where-Object { -not $_.Hidden -and $_.Group -ne '_Hidden' } |
Select-Object InternalName, TypeAsString, @{N='Mult';E={$_.TypeAsString -like '*Multi*'}} |
Sort-Object InternalName |
Format-Table -AutoSize
Disconnect-PnPOnline
@@ -3,7 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './ApproveFeature.module.scss'; import styles from './ApproveFeature.module.scss';
import { getViewComponent } from '../../forms/viewRegistry'; import { getViewComponent } from '../../forms/viewRegistry';
import { isConfirmedStaleListId } from '../../shared/approval'; import { isConfirmedStaleListId } from '../../shared/approval';
import { RequestChat } from '../../shared/chat'; import { RequestChat, isChatEnabledFor } from '../../shared/chat';
export interface IApproveFeatureProps { export interface IApproveFeatureProps {
context: WebPartContext; context: WebPartContext;
@@ -44,8 +44,11 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
</React.Suspense> </React.Suspense>
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим {/* Доступно всем, кто может открыть заявку: автору, всем согласующим
(и уже решившим, и ещё ждущим своей очереди) и исполнителям — (и уже решившим, и ещё ждущим своей очереди) и исполнителям —
в отличие от панели решения, которая видна только текущему шагу. */} в отличие от панели решения, которая видна только текущему шагу.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} /> <RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)}
</> </>
) : ( ) : (
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div> <div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
@@ -5,7 +5,7 @@ import { getFormComponent } from '../../forms/registry';
import { REQUEST_TYPES } from '../../data/requests'; import { REQUEST_TYPES } from '../../data/requests';
import { emitRequestsChanged } from '../../shared/requestEvents'; import { emitRequestsChanged } from '../../shared/requestEvents';
import { isConfirmedStaleListId } from '../../shared/approval'; import { isConfirmedStaleListId } from '../../shared/approval';
import { RequestChat } from '../../shared/chat'; import { RequestChat, isChatEnabledFor } from '../../shared/chat';
export interface IEditFeatureProps { export interface IEditFeatureProps {
context: WebPartContext; context: WebPartContext;
@@ -69,8 +69,11 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
</React.Suspense> </React.Suspense>
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке {/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
заявки). Здесь оно особенно нужно: заявку правят как раз после заявки). Здесь оно особенно нужно: заявку правят как раз после
возврата на доработку, и уточнить причину логично не уходя с формы. */} возврата на доработку, и уточнить причину логично не уходя с формы.
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
{isChatEnabledFor(formKey) && (
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} /> <RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
)}
</div> </div>
); );
}; };
@@ -13,16 +13,38 @@
## Особое поведение ## Особое поведение
- **Маска телефона** (PhoneInput) на поле «Телефон». - **Маска телефона** (PhoneInput) на поле «Телефон».
- **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после). - **Разделитель тысяч** (MoneyInput) на окладах (испытательный срок / после).
- **Мультивыбор компаний (Companies)** — справочник `GET /Dictionary/Companies`
(`CompanyPickerMulti`), хранится JSON-массивом `{Id,Name}` в Note-колонке `CompaniesJson`.
- **График работы (WorkSchedule)** — выпадающий список (набор совпадает с «Заявкой
на подбор»); при выборе «Другое» появляется ручной ввод `WorkScheduleOther`.
- **Испытательный срок (ProbationPeriod)** — выпадающий список частых сроков.
- **Рекрутинговое агентство (условный блок, последняя секция)** — `Toggle`
`RecruitingAgency`; при включении показываются «Название компании» (`AgencyName`)
и «Условия» (`AgencyTerms`), при выключении оба поля очищаются.
- **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда - **Условное поле «Взамен кого» (ReplacedEmployee)** — показывается только когда
тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели). тип приёма = замена уволенного (см. `HIRE_TYPE_REPLACEMENT` в модели).
- Секция **СИЗ** (рост, размеры одежды/обуви). - Секция **СИЗ** (рост, размеры одежды/обуви).
- **Является источником для «Организация рабочего места»** — из просмотра этой - **Является источником для «Организация рабочего места»** — из просмотра этой
заявки кнопка «Организация рабочего места на основе этой» открывает форму заявки кнопка «Организация рабочего места на основе этой» открывает форму
it-workplace-setup с префиллом (rpsource = id этой заявки). it-workplace-setup с префиллом (rpsource = id этой заявки).
- **Связь с «Заявкой на подбор» (SelectionRequest\*)** — необязательное поле в
секции «Заявка»: поиск-выбор согласованной заявки на подбор (`SelectionRequestField`
+ `selectionLookup.ts`, только статус «Согласовано», ищет по всем партициям серии).
Связь хранится тройкой полей `SelectionRequestId` / `SelectionRequestListId` /
`SelectionRequestCode`, а НЕ нативным Lookup — списки партиционированы. В форме
и в просмотре показывается ссылкой «№ код ↗», которая открывает
`RecruitingSelectionView` в **новой вкладке** (deep-link через `routeUrl`,
rptype=recruiting-selection, rpid/rplist из сохранённых полей).
- **Вложения по категориям** — три зоны загрузки: «Удостоверение», «Резюме» и
«Прочие вложения» (в каждую можно несколько файлов). Вложения SP плоские, поэтому
категория кодируется префиксом в имени файла «<Категория> — <имя>»
(`attachmentCategories.ts`); при показе группируются и префикс срезается.
«Прочие» грузятся БЕЗ префикса — это та же «незакатегоризированная» корзина, куда
же попадают и старые файлы, загруженные до разделения (не исчезают).
- Печать — общий механизм (без «Вложений»). - Печать — общий механизм (без «Вложений»).
## Секции формы ## Секции формы
ApplicationSection · CandidateSection · PpeSection · ConditionsSection ApplicationSection · CandidateSection · PpeSection · ConditionsSection · AgencySection
## Точки входа ## Точки входа
- rptype: `recruiting-hire` - rptype: `recruiting-hire`
@@ -47,10 +47,19 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService } from './submit'; import { saveHireRequest, hireApprovalService } from './submit';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import {
ATT_CATEGORY_ID,
ATT_CATEGORY_RESUME,
categoryPrefix,
prefixFiles,
attachmentsOfCategory,
uncategorizedAttachments
} from './attachmentCategories';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
@@ -61,7 +70,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
editListId editListId
}) => { }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); // Вложения — три зоны: удостоверение, резюме и прочие (см. attachmentCategories.ts).
// «Прочие» грузятся без префикса — это та же «незакатегоризированная» корзина.
const [idFiles, setIdFiles] = useState<File[]>([]);
const [resumeFiles, setResumeFiles] = useState<File[]>([]);
const [otherFiles, setOtherFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]); const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// При открытии на редактирование (editId) — сразу известен, иначе появится // При открытии на редактирование (editId) — сразу известен, иначе появится
// после первого сохранения. // после первого сохранения.
@@ -210,9 +223,17 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
if (code) { if (code) {
setRequestCode(code); setRequestCode(code);
} }
if (files.length > 0) { // Категорию кодируем префиксом в имени файла (SP-вложения плоские); прочие — без префикса.
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); // файлы — после создания элемента const filesToUpload = [
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало ...prefixFiles(idFiles, ATT_CATEGORY_ID),
...prefixFiles(resumeFiles, ATT_CATEGORY_RESUME),
...otherFiles
];
if (filesToUpload.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, filesToUpload); // файлы — после создания элемента
setIdFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setResumeFiles([]);
setOtherFiles([]);
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
@@ -258,6 +279,11 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
const sectionProps = { data, set, context }; const sectionProps = { data, set, context };
// Уже прикреплённые файлы, разложенные по категориям (+ прочие — старые без префикса).
const idAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_ID);
const resumeAttachments = attachmentsOfCategory(existingAttachments, ATT_CATEGORY_RESUME);
const otherAttachments = uncategorizedAttachments(existingAttachments);
return ( return (
<div> <div>
<button type="button" className={styles.back} onClick={onCancel}> <button type="button" className={styles.back} onClick={onCancel}>
@@ -295,17 +321,34 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
<CandidateSection {...sectionProps} /> <CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} /> <PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} /> <ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
{!readOnly && ( {!readOnly && (
<CollapseSection title="Вложения" icon="upload"> <CollapseSection title="Вложения" icon="upload">
{existingAttachments.length > 0 && ( <FormField label="Удостоверение" full>
<FormField label="Прикреплённые файлы" full> {idAttachments.length > 0 && (
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} /> <AttachmentList
</FormField> items={idAttachments}
onRemove={(name) => handleRemoveAttachment(categoryPrefix(ATT_CATEGORY_ID) + name)}
/>
)} )}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full> <FileDropzone files={idFiles} onChange={setIdFiles} />
<FileDropzone files={files} onChange={setFiles} /> </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> </FormField>
</CollapseSection> </CollapseSection>
)} )}
@@ -12,3 +12,9 @@
justify-content: flex-end; justify-content: flex-end;
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
.emptyAtt {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
@@ -24,12 +24,14 @@ import {
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService } from './submit'; import { hireApprovalService } from './submit';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection'; import { ConditionsSection } from './sections/ConditionsSection';
import { AgencySection } from './sections/AgencySection';
interface IRecruitingHireViewProps { interface IRecruitingHireViewProps {
context: WebPartContext; context: WebPartContext;
@@ -187,13 +189,30 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<CandidateSection {...sectionProps} /> <CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} /> <PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} /> <ConditionsSection {...sectionProps} />
<AgencySection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
<div className="rp-no-print"> <div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload"> <CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full> <FormField label="Удостоверение" full>
<AttachmentList items={attachments} /> {attachmentsOfCategory(attachments, ATT_CATEGORY_ID).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_ID)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField> </FormField>
<FormField label="Резюме" full>
{attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField>
{uncategorizedAttachments(attachments).length > 0 && (
<FormField label="Прочие вложения" full>
<AttachmentList items={uncategorizedAttachments(attachments)} />
</FormField>
)}
</CollapseSection> </CollapseSection>
</div> </div>
</FormCard> </FormCard>
@@ -0,0 +1,50 @@
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
/**
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
*/
export const ATT_CATEGORY_ID = 'Удостоверение';
export const ATT_CATEGORY_RESUME = 'Резюме';
const SEP = ' — ';
/** Префикс имени файла для категории: «Удостоверение — ». */
export function categoryPrefix(category: string): string {
return `${category}${SEP}`;
}
/**
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
*/
export function prefixFiles(files: File[], category: string): File[] {
const prefix = categoryPrefix(category);
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
}
/**
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
* обратно (см. categoryPrefix в обработчике удаления формы).
*/
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
const prefix = categoryPrefix(category);
return items
.filter((a) => a.fileName.indexOf(prefix) === 0)
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
}
/**
* Вложения без известной категории (старые заявки, загруженные до разделения).
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
*/
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
return items.filter(
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
);
}
@@ -1,12 +1,26 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { ICompany } from '../../shared/dictionary';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
/** Безопасный разбор JSON-массива из Note-колонки (пустой при ошибке/пустом). */
function parseJsonArray<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind', 'Phone', 'HireType', 'ReplacedEmployee', 'CandidateName', 'HireDate', 'Position', 'WorkKind',
'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'SalaryProbation', 'SiteName', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm' 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms',
'SelectionRequestId', 'SelectionRequestListId', 'SelectionRequestCode'
]; ];
export interface ILoadedRequest { export interface ILoadedRequest {
@@ -29,11 +43,16 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID)); const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list const item = (await list
.items.getById(itemId) .items.getById(itemId)
.select(...SCALAR_FIELDS, 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>; .select(...SCALAR_FIELDS, 'CompaniesJson', 'RecruitingAgency', 'RequestStatus', 'RequestUniqueCode')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA }; const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>; const writable = data as unknown as Record<string, string>;
// Мультивыбор компаний хранится Note-колонкой CompaniesJson (не одноимённой колонкой).
(data as unknown as Record<string, unknown>).Companies = parseJsonArray<ICompany>(item.CompaniesJson);
// Флаг агентства — булево поле (Yes/No), не строка.
(data as unknown as Record<string, unknown>).RecruitingAgency = !!item.RecruitingAgency;
SCALAR_FIELDS.forEach((key) => { SCALAR_FIELDS.forEach((key) => {
const value = item[key]; const value = item[key];
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -1,5 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { ICompany } from '../../shared/dictionary';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
/** /**
@@ -14,9 +15,15 @@ export const FORM_ID: RequestTypeId = 'recruiting-hire';
*/ */
export interface IFormData { export interface IFormData {
// Заявка (шапка) // Заявка (шапка)
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
Phone: string; Phone: string;
HireType: string; HireType: string;
ReplacedEmployee: string; ReplacedEmployee: string;
// Связанная «Заявка на подбор» (по ней оформляют приём). Хранится тройкой
// полей, а не Lookup: списки партиционированы, см. selectionLookup.ts.
SelectionRequestId: string; // Id заявки на подбор (→ rpid); '' — не выбрана
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
SelectionRequestCode: string; // уникальный код для подписи/ссылки
// Кандидат // Кандидат
CandidateName: string; CandidateName: string;
HireDate: string; HireDate: string;
@@ -29,10 +36,15 @@ export interface IFormData {
PpeClothingSize: string; PpeClothingSize: string;
PpeShoeSize: string; PpeShoeSize: string;
// Условия оплаты и оформления // Условия оплаты и оформления
ProbationPeriod: string;
SalaryProbation: string; SalaryProbation: string;
SalaryAfterProbation: string; SalaryAfterProbation: string;
WorkSchedule: string; WorkSchedule: string;
EmploymentForm: string; WorkScheduleOther: string;
// Рекрутинговое агентство (условный блок): при включённом флаге — название + условия.
RecruitingAgency: boolean;
AgencyName: string;
AgencyTerms: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -46,9 +58,13 @@ export interface ISectionProps {
} }
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Companies: [],
Phone: '', Phone: '',
HireType: '', HireType: '',
ReplacedEmployee: '', ReplacedEmployee: '',
SelectionRequestId: '',
SelectionRequestListId: '',
SelectionRequestCode: '',
CandidateName: '', CandidateName: '',
HireDate: '', HireDate: '',
Position: '', Position: '',
@@ -58,10 +74,14 @@ export const EMPTY_DATA: IFormData = {
PpeHeight: '', PpeHeight: '',
PpeClothingSize: '', PpeClothingSize: '',
PpeShoeSize: '', PpeShoeSize: '',
ProbationPeriod: '',
SalaryProbation: '', SalaryProbation: '',
SalaryAfterProbation: '', SalaryAfterProbation: '',
WorkSchedule: '', WorkSchedule: '',
EmploymentForm: '' WorkScheduleOther: '',
RecruitingAgency: false,
AgencyName: '',
AgencyTerms: ''
}; };
/** Значение «Тип приёма», при котором показывается поле «Взамен кого». */ /** Значение «Тип приёма», при котором показывается поле «Взамен кого». */
@@ -73,10 +93,33 @@ export const HIRE_TYPE_OPTIONS: ISelectOption[] = [
{ value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT } { value: HIRE_TYPE_REPLACEMENT, label: HIRE_TYPE_REPLACEMENT }
]; ];
/** Варианты «Вид оформления» — совпадают с choices колонки EmploymentForm. */ /** Варианты «Испытательный срок» — самые частые сроки (совпадают с choices колонки ProbationPeriod). */
export const EMPLOYMENT_FORM_OPTIONS: ISelectOption[] = [ export const PROBATION_PERIOD_OPTIONS: ISelectOption[] = [
{ value: 'ГПХ', label: 'ГПХ' }, { value: 'Без испытательного срока', label: 'Без испытательного срока' },
{ value: 'Штат', label: 'Штат' } { value: '1 месяц', label: '1 месяц' },
{ value: '2 месяца', label: '2 месяца' },
{ value: '3 месяца', label: '3 месяца' },
{ value: '6 месяцев', label: '6 месяцев' }
];
/** Значение-триггер «Другое»: при нём показываем поле ручного ввода графика. */
export const OTHER_VALUE = 'Другое';
/**
* Варианты «График работы» — совпадают с набором в форме «Заявка на подбор»
* (WORK_SCHEDULE_OPTIONS): формы независимы, значения продублированы намеренно.
*/
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = [
{ value: '5/2', label: '5/2' },
{ value: 'Вахтовый 30/30', label: 'Вахтовый 30/30' },
{ value: '20/10', label: '20/10' },
{ value: '30/15', label: '30/15' },
{ value: '22/8', label: '22/8' },
{ value: '15/15', label: '15/15' },
{ value: '6/1', label: '6/1' },
{ value: '1/2', label: '1/2' },
{ value: '2/2', label: '2/2' },
{ value: OTHER_VALUE, label: OTHER_VALUE }
]; ];
/** Плейсхолдер для полей-людей. */ /** Плейсхолдер для полей-людей. */
@@ -4,10 +4,16 @@
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
{ "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
{ "internalName": "SelectionRequestId", "title": "Заявка на подбор — Id", "type": "Number" },
{ "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" },
{ "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" },
{ "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, { "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
@@ -19,9 +25,14 @@
{ "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" }, { "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" },
{ "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" }, { "internalName": "PpeShoeSize", "title": "СИЗ: размер обуви", "type": "Number" },
{ "internalName": "ProbationPeriod", "title": "Испытательный срок", "type": "Choice", "choices": ["Без испытательного срока", "1 месяц", "2 месяца", "3 месяца", "6 месяцев"] },
{ "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 }, { "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 },
{ "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 },
{ "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" },
{ "internalName": "EmploymentForm", "title": "Вид оформления", "type": "Choice", "choices": ["ГПХ", "Штат"] } { "internalName": "WorkScheduleOther", "title": "График работы (другое)", "type": "Text" },
{ "internalName": "RecruitingAgency", "title": "Рекрутинговое агентство", "type": "Boolean" },
{ "internalName": "AgencyName", "title": "Название компании (агентство)", "type": "Text" },
{ "internalName": "AgencyTerms", "title": "Условия (агентство)", "type": "Note" }
] ]
} }
@@ -0,0 +1,36 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Toggle } from '../../../shared/ui';
import { ISectionProps } from '../model';
/**
* Рекрутинговое агентство: флаг «привлекается ли агентство» и — при включённом
* флаге — название агентства и условия сотрудничества. При выключении поля
* очищаются, чтобы в заявку не попали данные скрытого блока.
*/
export const AgencySection: React.FC<ISectionProps> = ({ data, set }) => {
const handleToggle = (value: boolean): void => {
set('RecruitingAgency', value);
if (!value) {
set('AgencyName', '');
set('AgencyTerms', '');
}
};
return (
<CollapseSection title="Рекрутинговое агентство" icon="building">
<FormField label="Привлекается рекрутинговое агентство">
<Toggle checked={data.RecruitingAgency} onChange={handleToggle} onLabel="Да" />
</FormField>
{data.RecruitingAgency && (
<>
<FormField label="Название компании" htmlFor="AgencyName" full>
<TextInput id="AgencyName" value={data.AgencyName} onChange={(v) => set('AgencyName', v)} />
</FormField>
<FormField label="Условия" htmlFor="AgencyTerms" full>
<TextArea id="AgencyTerms" value={data.AgencyTerms} onChange={(v) => set('AgencyTerms', v)} />
</FormField>
</>
)}
</CollapseSection>
);
};
@@ -1,8 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, PhoneInput, Select } from '../../../shared/ui';
import { CompanyPickerMulti } from '../../../shared/dictionary';
import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model'; import { ISectionProps, HIRE_TYPE_OPTIONS, HIRE_TYPE_REPLACEMENT } from '../model';
import { SelectionRequestField } from './SelectionRequestField';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => { export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context }) => {
const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT; const isReplacement = data.HireType === HIRE_TYPE_REPLACEMENT;
const handleHireTypeChange = (value: string): void => { const handleHireTypeChange = (value: string): void => {
@@ -14,6 +16,18 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set }) => {
return ( return (
<CollapseSection title="Заявка" icon="clipboard"> <CollapseSection title="Заявка" icon="clipboard">
<FormField label="Компания" htmlFor="Companies" full>
<CompanyPickerMulti
id="Companies"
context={context}
value={data.Companies}
onChange={(companies) => set('Companies', companies)}
placeholder="Начните вводить название компании…"
/>
</FormField>
<FormField label="Заявка на подбор" full>
<SelectionRequestField data={data} set={set} />
</FormField>
<FormField label="Телефон" htmlFor="Phone"> <FormField label="Телефон" htmlFor="Phone">
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} /> <PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField> </FormField>
@@ -1,9 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, MoneyInput, Select } from '../../../shared/ui';
import { ISectionProps, EMPLOYMENT_FORM_OPTIONS } from '../model'; import { ISectionProps, OTHER_VALUE, WORK_SCHEDULE_OPTIONS, PROBATION_PERIOD_OPTIONS } from '../model';
export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => ( export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Условия оплаты и оформления" icon="calculator"> <CollapseSection title="Условия оплаты и оформления" icon="calculator">
<FormField label="Испытательный срок" htmlFor="ProbationPeriod">
<Select
id="ProbationPeriod"
value={data.ProbationPeriod}
onChange={(v) => set('ProbationPeriod', v)}
options={PROBATION_PERIOD_OPTIONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation"> <FormField label="Оклад при испытательном сроке" htmlFor="SalaryProbation">
<MoneyInput <MoneyInput
id="SalaryProbation" id="SalaryProbation"
@@ -19,16 +28,25 @@ export const ConditionsSection: React.FC<ISectionProps> = ({ data, set }) => (
/> />
</FormField> </FormField>
<FormField label="График работы" htmlFor="WorkSchedule"> <FormField label="График работы" htmlFor="WorkSchedule">
<TextInput id="WorkSchedule" value={data.WorkSchedule} onChange={(v) => set('WorkSchedule', v)} />
</FormField>
<FormField label="Вид оформления" htmlFor="EmploymentForm">
<Select <Select
id="EmploymentForm" id="WorkSchedule"
value={data.EmploymentForm} value={data.WorkSchedule}
onChange={(v) => set('EmploymentForm', v)} onChange={(v) => {
options={EMPLOYMENT_FORM_OPTIONS} set('WorkSchedule', v);
if (v !== OTHER_VALUE) set('WorkScheduleOther', '');
}}
options={WORK_SCHEDULE_OPTIONS}
placeholder="— выберите —" placeholder="— выберите —"
/> />
</FormField> </FormField>
{data.WorkSchedule === OTHER_VALUE && (
<FormField label="График работы (другое)" htmlFor="WorkScheduleOther">
<TextInput
id="WorkScheduleOther"
value={data.WorkScheduleOther}
onChange={(v) => set('WorkScheduleOther', v)}
/>
</FormField>
)}
</CollapseSection> </CollapseSection>
); );
@@ -0,0 +1,123 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
.note {
padding: 10px 12px;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-500;
}
// Выбранная заявка: ссылка + управление.
.selected {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2 $sp-3;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: $font-sans;
font-size: 13.5px;
font-weight: 600;
color: $navy-700;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.arrow {
font-size: 12px;
}
.actions {
display: inline-flex;
align-items: center;
gap: $sp-3;
}
.textBtn {
padding: 0;
border: 0;
background: transparent;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-700;
cursor: pointer;
&:hover {
color: $navy-700;
text-decoration: underline;
}
}
.empty {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
@@ -0,0 +1,185 @@
import * as React from 'react';
import styles from './SelectionRequestField.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { routeUrl } from '../../../shared/routing';
import { getApprovedSelectionRequests, ISelectionRequestOption } from '../selectionLookup';
import { IFormData, SetField } from '../model';
/** Слаг формы подбора в реестре форм — цель ссылки (rptype). */
const SELECTION_FORM_KEY = 'recruiting-selection';
const MAX_SUGGESTIONS = 30;
interface ISelectionRequestFieldProps {
data: IFormData;
set: SetField;
}
/** Читаемая подпись варианта: «№ код — должность». */
function optionLabel(o: ISelectionRequestOption): string {
const head = `№ ${o.code || o.id}`;
return o.position ? `${head} — ${o.position}` : head;
}
/**
* Поле «Заявка на подбор» в форме приёма: поиск-выбор согласованной заявки на
* подбор + ссылка на неё (открывается в новой вкладке через RecruitingSelectionView).
*
* В режиме чтения (просмотр согласующего) — только ссылка, без поиска (то же
* поле переиспользуется во View через FormModeProvider). Связь необязательна:
* пустое поле в режиме чтения показывается прочерком.
*/
export const SelectionRequestField: React.FC<ISelectionRequestFieldProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
const [options, setOptions] = React.useState<ISelectionRequestOption[]>([]);
const [loaded, setLoaded] = React.useState<boolean>(false);
const [query, setQuery] = React.useState<string>('');
const [open, setOpen] = React.useState<boolean>(false);
const [editing, setEditing] = React.useState<boolean>(false);
const selected = data.SelectionRequestId !== '';
// Справочник согласованных подборов грузим лениво — только когда пользователь
// начинает выбирать: в режиме чтения он не нужен, а на старте формы приёма —
// тем более. loaded ставим до резолва, чтобы фокус/ввод не плодили запросы.
const ensureLoaded = React.useCallback((): void => {
if (loaded) return;
setLoaded(true);
getApprovedSelectionRequests()
.then(setOptions)
.catch(() => undefined);
}, [loaded]);
const suggestions = React.useMemo(() => {
const q = query.trim().toLowerCase();
const base = q
? options.filter(
(o) =>
o.code.toLowerCase().indexOf(q) !== -1 ||
o.position.toLowerCase().indexOf(q) !== -1 ||
o.title.toLowerCase().indexOf(q) !== -1
)
: options;
return base.slice(0, MAX_SUGGESTIONS);
}, [options, query]);
const href = selected
? routeUrl({
mode: 'approve',
nav: 'new',
formKey: SELECTION_FORM_KEY,
itemId: Number(data.SelectionRequestId),
// Партиция неизвестна (реестр не загрузился) → ссылка без rplist,
// просмотр откроется в текущей партиции (см. routing.ts).
listId: data.SelectionRequestListId || undefined
})
: '';
const selectedLabel = `№ ${data.SelectionRequestCode || data.SelectionRequestId}`;
const handlePick = (o: ISelectionRequestOption): void => {
set('SelectionRequestId', String(o.id));
set('SelectionRequestListId', o.listId);
set('SelectionRequestCode', o.code);
setEditing(false);
setOpen(false);
setQuery('');
};
const handleClear = (): void => {
set('SelectionRequestId', '');
set('SelectionRequestListId', '');
set('SelectionRequestCode', '');
setEditing(false);
setQuery('');
};
// Ссылка на выбранную заявку — общий кусок для режима чтения и выбранного состояния.
const link = (
<a className={styles.link} href={href} target="_blank" rel="noopener noreferrer">
{selectedLabel}
<span className={styles.arrow} aria-hidden="true">
↗
</span>
</a>
);
// Выбрана и не редактируем: показываем ссылку + управление (вне режима чтения).
if (selected && !editing) {
return (
<div className={styles.selected}>
{link}
{!readOnly && (
<div className={styles.actions}>
<button
type="button"
className={styles.textBtn}
onClick={() => {
ensureLoaded();
setEditing(true);
setOpen(true);
}}
>
Изменить
</button>
<button
type="button"
className={styles.textBtn}
onClick={handleClear}
>
Очистить
</button>
</div>
)}
</div>
);
}
// Режим чтения без выбранной заявки — прочерк (связи нет).
if (readOnly) {
return <span className={styles.empty}>—</span>;
}
return (
<div className={styles.wrap}>
<input
className={styles.input}
value={query}
placeholder="Начните вводить код, должность или название…"
onChange={(e) => {
setQuery(e.target.value);
ensureLoaded();
setOpen(true);
}}
onFocus={() => {
ensureLoaded();
setOpen(true);
}}
onBlur={() => setOpen(false)}
/>
{open && (
<div className={styles.list}>
{suggestions.length > 0 ? (
suggestions.map((o) => (
<button
key={`${o.listId}:${o.id}`}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur)
// раньше onClick, и список закрывается до выбора.
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(o)}
>
{optionLabel(o)}
{o.title && <span className={styles.meta}>{o.title}</span>}
</button>
))
) : (
<div className={styles.note}>
{loaded ? 'Нет согласованных заявок на подбор.' : 'Загрузка…'}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,101 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { REQUEST_STATUS, partitionsForPageUrl } from '../../shared/approval';
import { RequestTypeId } from '../../models/types';
/**
* Выборка «Заявок на подбор» для связывания из «Заявки на прием».
*
* Связь между заявками хранится НЕ нативной колонкой Lookup, а тройкой обычных
* полей (id + url партиции + код) — списки портала партиционированы (см.
* shared/approval/listPartitions.ts), а Lookup умеет ссылаться ровно на один
* список и через границу партиции ломается. Поэтому здесь мы читаем список(и)
* подбора «руками» и отдаём ровно то, что нужно для deep-link портала (rpid/rplist).
*/
/** Слаг формы подбора — ключ реестра форм и pageUrl типа заявки в approval-route. */
const SELECTION_FORM_ID: RequestTypeId = 'recruiting-selection';
export interface ISelectionRequestOption {
id: number;
/** Серверный url партиции, где лежит заявка (→ rplist в ссылке). '' — партиция неизвестна. */
listId: string;
/** Уникальный код заявки (напр. «4-2608-12»). '' — запись до появления поля кода. */
code: string;
/** Должность из заявки на подбор — для читаемой подписи в подсказках. */
position: string;
/** Системный Title элемента — фолбэк подписи, если код/должность пусты. */
title: string;
}
// Только согласованные заявки на подбор: приём оформляют по уже утверждённому подбору.
const APPROVED_FILTER = `RequestStatus eq '${REQUEST_STATUS.approved}'`;
// Предохранитель: партиция всё равно ограничена порогом элементов, но берём
// разумный потолок, чтобы одиночный запрос не тянул весь список целиком.
const MAX_ITEMS = 500;
interface IListLike {
items: {
filter: (f: string) => {
select: (...fields: string[]) => {
orderBy: (field: string, ascending: boolean) => {
top: (n: number) => () => Promise<Record<string, unknown>[]>;
};
};
};
};
}
async function queryPartition(list: IListLike, listId: string): Promise<ISelectionRequestOption[]> {
const rows = await list.items
.filter(APPROVED_FILTER)
.select('Id', 'Title', 'RequestUniqueCode', 'Position')
.orderBy('Id', false)
.top(MAX_ITEMS)();
return rows.map((row) => ({
id: Number(row.Id),
listId,
code: (row.RequestUniqueCode as string) || '',
position: (row.Position as string) || '',
title: (row.Title as string) || ''
}));
}
/**
* Все согласованные заявки на подбор из всех партиций серии (архив + текущая).
*
* Реестр партиций знает серию → читаем каждую партицию по её listId (url),
* заодно получая правильный rplist для ссылки. Реестр недоступен (approval-route
* молчал при старте) → откатываемся на текущий список по имени; ссылка тогда
* пойдёт без rplist и просмотр откроется в текущей партиции (см. routing.ts).
* Сбой чтения одной партиции не роняет остальные.
*/
export async function getApprovedSelectionRequests(): Promise<ISelectionRequestOption[]> {
const sp = getSP();
const partitions = partitionsForPageUrl(SELECTION_FORM_ID);
if (partitions.length === 0) {
return queryPartition(
sp.web.lists.getByTitle(dataListTitle(SELECTION_FORM_ID)) as unknown as IListLike,
''
);
}
const perPartition = await Promise.all(
partitions.map((p) =>
queryPartition(sp.web.getList(p.listId) as unknown as IListLike, p.listId).catch((e) => {
console.error(
`[Связь с подбором] партиция «${p.title}» (${p.listId}) не прочитана:`,
(e as Error).message
);
return [] as ISelectionRequestOption[];
})
)
);
// Новые заявки (больший Id) — выше; между партициями сортировать не нужно:
// партиции идут по возрастанию номера, внутри уже отсортировано по Id убыв.
return perPartition.reduce((acc, cur) => acc.concat(cur), []);
}
@@ -3,7 +3,7 @@ import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
// Числовые поля: пустая строка → null, иначе Number(). // Числовые поля: пустая строка → null, иначе Number().
const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation', 'SelectionRequestId'];
/** /**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -24,6 +24,11 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status }; const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => { (Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
if (key === 'Companies') {
// Мультивыбор компаний не разворачивается в колонки — сериализуем в Note.
item.CompaniesJson = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
return;
}
const value = data[key]; const value = data[key];
if (NUMBER_FIELDS.indexOf(key) !== -1) { if (NUMBER_FIELDS.indexOf(key) !== -1) {
item[key] = value === '' ? null : Number(value); item[key] = value === '' ? null : Number(value);
@@ -178,6 +178,8 @@ export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: 'MS Outlook', label: 'MS Outlook' }, { value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' }, { value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' }, { value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
{ value: 'BIM', label: 'BIM' },
{ value: 'Revit', label: 'Revit' }, { value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' }, { value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' }, { value: 'Компас-3D', label: 'Компас-3D' },
@@ -0,0 +1,104 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-1;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 6px 5px 10px;
border: 1px solid $gray-200;
border-radius: 999px;
background: $navy-50;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
word-break: break-word;
}
.chipRemove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $gray-500;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-200;
color: $gray-900;
}
}
.field {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: block;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
@@ -1,22 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import styles from './LanguagesField.module.scss'; import styles from './MultiChoiceField.module.scss';
import { Select, TextInput, Button, useFormReadOnly, ISelectOption } from '../../../shared/ui'; import { useFormReadOnly, ISelectOption } from '../../../shared/ui';
import { OTHER_VALUE } from '../model';
export interface IMultiChoiceFieldProps { export interface IMultiChoiceFieldProps {
value: string[]; value: string[];
onChange: (values: string[]) => void; onChange: (values: string[]) => void;
options: ISelectOption[]; options: ISelectOption[];
/** Разрешить «Другое» с ручным вводом произвольного значения. */ /** Разрешить ввод произвольного значения, которого нет в списке. */
allowOther?: boolean; allowOther?: boolean;
placeholder?: string; placeholder?: string;
} }
const MAX_SUGGESTIONS = 30;
/** /**
* Мультивыбор из фиксированного списка: выбор значения → «Добавить», выбранные — * Мультивыбор из фиксированного списка в стиле пикера-справочника
* чипами с удалением. Уже выбранные исключаются из списка. allowOther добавляет * (ср. CompanyPickerMulti): при фокусе выпадают доступные опции, ввод текста их
* пункт «Другое» с полем ручного ввода. Переиспользует стили LanguagesField. * фильтрует, выбор пункта СРАЗУ добавляет чип (без кнопки «Добавить»). Уже
* выбранные из списка исключаются. allowOther позволяет добавить чипом
* произвольное значение, которого нет в списке. Хранит массив строк (string[]).
*/ */
export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
value, value,
@@ -26,69 +29,106 @@ export const MultiChoiceField: React.FC<IMultiChoiceFieldProps> = ({
placeholder placeholder
}) => { }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const [draft, setDraft] = useState<string>(''); const [query, setQuery] = useState<string>('');
const [draftOther, setDraftOther] = useState<string>(''); const [open, setOpen] = useState<boolean>(false);
const selected = new Set(value); const selected = useMemo(() => new Set(value), [value]);
const available = options.filter((o) => !selected.has(o.value)); // Ярлык для чипа: известное значение — по label из опций, произвольное — как есть.
const addOptions: ISelectOption[] = allowOther ? [...available, { value: OTHER_VALUE, label: OTHER_VALUE }] : available; const labelFor = (v: string): string => options.filter((o) => o.value === v)[0]?.label ?? v;
const isOther = draft === OTHER_VALUE; const suggestions = useMemo(() => {
const pending = isOther ? draftOther.trim() : draft; const q = query.trim().toLowerCase();
// Пустой запрос — показываем все ещё не выбранные опции (клик-дропдаун);
// с запросом — фильтруем по label (типа-хед).
const available = options.filter(
(o) => !selected.has(o.value) && (!q || o.label.toLowerCase().indexOf(q) !== -1)
);
return available.slice(0, MAX_SUGGESTIONS);
}, [options, query, selected]);
const add = (): void => { // «Другое»: если разрешено и введён текст, которого нет ни среди опций, ни среди
if (!pending || selected.has(pending)) return; // уже выбранных — предлагаем добавить его как произвольное значение.
onChange([...value, pending]); const trimmed = query.trim();
setDraft(''); const canAddOther =
setDraftOther(''); !!allowOther &&
trimmed.length > 0 &&
!selected.has(trimmed) &&
!options.some((o) => o.value.toLowerCase() === trimmed.toLowerCase());
const add = (v: string): void => {
if (!v || selected.has(v)) return;
onChange([...value, v]);
setQuery('');
// Список НЕ закрываем: поле сохраняет фокус (mousedown с preventDefault),
// поэтому оставляем открытым — сразу видно оставшиеся варианты и можно
// добавлять подряд, не теряя и не возвращая фокус.
setOpen(true);
}; };
const remove = (v: string): void => onChange(value.filter((x) => x !== v)); const remove = (v: string): void => onChange(value.filter((x) => x !== v));
return ( return (
<div> <div className={styles.wrap}>
{value.length > 0 && ( {value.length > 0 && (
<div className={styles.list}> <div className={styles.chips}>
{value.map((v) => ( {value.map((v) => (
<div key={v} className={styles.row}> <span key={v} className={styles.chip}>
<span className={styles.lang}>{v}</span> {labelFor(v)}
{!readOnly && ( {!readOnly && (
<button <button
type="button" type="button"
className={styles.remove} className={styles.chipRemove}
aria-label={`Убрать ${v}`} aria-label={`Убрать ${labelFor(v)}`}
title="Убрать" title="Убрать"
onClick={() => remove(v)} onClick={() => remove(v)}
> >
× ×
</button> </button>
)} )}
</div> </span>
))} ))}
</div> </div>
)} )}
{!readOnly && addOptions.length > 0 && ( {!readOnly && (
<div className={styles.add}> <div className={styles.field}>
<div className={styles.addField}> <input
<Select className={styles.input}
value={draft} value={query}
onChange={(v) => { placeholder={placeholder}
setDraft(v); onChange={(e) => {
if (v !== OTHER_VALUE) setDraftOther(''); setQuery(e.target.value);
setOpen(true);
}} }}
options={addOptions} onFocus={() => setOpen(true)}
placeholder={placeholder ?? '— выберите —'} onBlur={() => setOpen(false)}
/> />
</div> {open && (suggestions.length > 0 || canAddOther) && (
{isOther && ( <div className={styles.list}>
<div className={styles.addField}> {suggestions.map((o) => (
<TextInput value={draftOther} onChange={setDraftOther} placeholder="Введите значение" /> <button
key={o.value}
type="button"
className={styles.item}
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick.
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(o.value)}
>
{o.label}
</button>
))}
{canAddOther && (
<button
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => add(trimmed)}
>
Добавить «{trimmed}»
</button>
)}
</div> </div>
)} )}
<Button type="button" variant="secondary" onClick={add} disabled={!pending}>
Добавить
</Button>
</div> </div>
)} )}
</div> </div>
@@ -76,6 +76,9 @@ async function applyPeople(item: Record<string, unknown>, data: IFormData): Prom
for (let j = 0; j < logins.length; j++) { for (let j = 0; j < logins.length; j++) {
ids.push(await resolveUserId(logins[j])); ids.push(await resolveUserId(logins[j]));
} }
// Multi-value поле (UserMulti) в режиме odata=minimalmetadata пишется ПЛОСКИМ
// массивом Id ([1,2,3]). Обёртка { results: [...] } — старый verbose-формат,
// здесь сервер её не принимает («Ожидался StartArray, обнаружен StartObject»).
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids; item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
} }
} }
@@ -38,6 +38,7 @@ export {
partitionsForTypeId, partitionsForTypeId,
currentPartitionForTypeId, currentPartitionForTypeId,
currentPartitionForPageUrl, currentPartitionForPageUrl,
partitionsForPageUrl,
partitionForPageUrlAndListId, partitionForPageUrlAndListId,
isConfirmedStaleListId, isConfirmedStaleListId,
registerCurrentPartition, registerCurrentPartition,
@@ -93,6 +93,18 @@ export function currentPartitionForPageUrl(pageUrl: string): IListPartition | un
return id === undefined ? undefined : currentPartitionForTypeId(id); return id === undefined ? undefined : currentPartitionForTypeId(id);
} }
/**
* Все партиции типа заявки по слагу формы (архив + текущая), по возрастанию
* номера. Нужно, чтобы искать заявки СРАЗУ во всех списках серии — например,
* связать «прием» с «подбором», который мог быть подан давно и уехать в архивную
* партицию (см. forms/RecruitingHire/selectionLookup.ts). Пусто — реестр не
* загружен или тип без списка (вызывающий откатывается на текущий список по имени).
*/
export function partitionsForPageUrl(pageUrl: string): IListPartition[] {
const id = typeIdByPageUrl.get(pageUrl);
return id === undefined ? [] : partitionsForTypeId(id);
}
/** /**
* Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую * Вписывает в кеш партицию, которую approval-route ТОЛЬКО ЧТО вернул как текущую
* (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того * (см. getCurrentListPartition) — вызывать сразу после успешного ответа, ДО того
@@ -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'; const ME = 'ivan@tnsu.kz';
@@ -73,3 +78,79 @@ describe('toChatMessages', () => {
expect(messages[1].authorName).toBe('Неизвестный автор'); 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, из которой строится сообщение чата. * Часть комментария SharePoint, из которой строится сообщение чата.
@@ -15,6 +16,53 @@ export interface ICommentSource {
author?: { name?: string; email?: string }; 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. Если часового пояса в ней нет, * Дата комментария приходит строкой ISO. Если часового пояса в ней нет,
* new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC — * new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC —
@@ -26,19 +74,32 @@ function parseCommentDate(raw: string): Date {
return new Date(hasTimeZone ? raw : `${raw}Z`); 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 authorEmail = comment.author?.email || '';
const authorName = comment.author?.name || authorEmail || 'Неизвестный автор'; const authorName = comment.author?.name || authorEmail || 'Неизвестный автор';
const storedNames = parseStoredNames(comment.text);
return { return {
id: comment.id, id: comment.id,
text: comment.text, text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(),
authorName, authorName,
authorEmail, authorEmail,
createdAt: parseCommentDate(comment.createdDate), createdAt: parseCommentDate(comment.createdDate),
isOwn: isOwn:
!!authorEmail && !!authorEmail &&
!!currentUserEmail && !!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 не документирован и мы на него не опираемся — сортируем * Порядок ответа API не документирован и мы на него не опираемся — сортируем
* по дате сами. Пустые комментарии отбрасываем: показывать пустой «пузырь» * по дате сами. Комментарии без текста и без файлов отбрасываем: показывать
* нечего, а в списке они появляются, если кто-то отправил комментарий не из * пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил
* портала (например, из стандартной формы SharePoint). * комментарий не из портала (например, из стандартной формы SharePoint).
*
* folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима,
* когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался).
*/ */
export function toChatMessages( export function toChatMessages(
comments: ICommentSource[], comments: ICommentSource[],
currentUserEmail: string currentUserEmail: string,
folderUrl: string = ''
): IChatMessage[] { ): IChatMessage[] {
return comments return comments
.filter((c) => !!c.text && !!c.text.trim()) .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()); .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
} }
@@ -1,8 +1,17 @@
// Единая точка импорта чата по заявке. // Единая точка импорта чата по заявке.
export { RequestChat } from './ui/RequestChat'; export { RequestChat } from './ui/RequestChat';
export { isChatEnabledFor } from './chatEnabled';
export { useRequestChat } from './useRequestChat'; export { useRequestChat } from './useRequestChat';
export type { IRequestChat } from './useRequestChat'; export type { IRequestChat } from './useRequestChat';
export { getChatMessages, addChatMessage } from './requestChat.service'; 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 { 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). * к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo).
@@ -5,6 +13,7 @@
export interface IChatMessage { export interface IChatMessage {
/** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */ /** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */
id: string; id: string;
/** Текст сообщения БЕЗ служебной пометки о файлах. */
text: string; text: string;
/** ФИО автора; если SharePoint его не отдал — email или заглушка. */ /** ФИО автора; если SharePoint его не отдал — email или заглушка. */
authorName: string; authorName: string;
@@ -15,4 +24,6 @@ export interface IChatMessage {
* Считается по email автора, см. toChatMessages. * Считается по email автора, см. toChatMessages.
*/ */
isOwn: boolean; isOwn: boolean;
/** Прикреплённые файлы; пустой массив — файлов нет. */
attachments: IChatAttachment[];
} }
@@ -4,7 +4,8 @@ import type { ICommentInfo, IComments } from '@pnp/sp/comments';
import { resolveDataList } from '../dataList'; import { resolveDataList } from '../dataList';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage'; 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), поэтому список резолвим общим * shared/approval/listPartitions.ts), поэтому список резолвим общим
* resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку. * resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку.
@@ -47,18 +52,27 @@ export async function getChatMessages(
currentUserEmail: string currentUserEmail: string
): Promise<IChatMessage[]> { ): Promise<IChatMessage[]> {
const comments: ICommentInfo[] = await commentsOf(formKey, itemId, listId)(); 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, поэтому передаём только текст. * ставит сам SharePoint, поэтому передаём только текст.
*
* Файлы сначала уходят в библиотеку, и только потом создаётся комментарий с
* пометкой об их именах — см. uploadChatFiles о том, почему такой порядок.
*/ */
export async function addChatMessage( export async function addChatMessage(
formKey: RequestTypeId, formKey: RequestTypeId,
itemId: number, itemId: number,
listId: string | undefined, listId: string | undefined,
text: string text: string,
files: File[] = []
): Promise<void> { ): 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 * as React from 'react';
import styles from './RequestChat.module.scss'; 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 { interface IChatComposerProps {
sending: boolean; sending: boolean;
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */ /** Возвращает 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: * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
* чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
* пробрасывает. * не пробрасывает.
*/ */
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => { export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
const [text, setText] = React.useState<string>(''); 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 => { const send = (): void => {
if (!canSend) return; if (!canSend) return;
onSend(text) onSend(text, files)
.then((sent) => { .then((sent) => {
if (sent) setText(''); if (!sent) return;
setText('');
setFiles([]);
setFilesOpen(false);
}) })
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -35,6 +46,16 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
}; };
return ( return (
<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}> <div className={styles.composer}>
<textarea <textarea
className={styles.input} className={styles.input}
@@ -46,10 +67,24 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} 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}> <Button variant="accent" disabled={!canSend} onClick={send}>
<Icon name="send" size="sm" /> <Icon name="send" size="sm" />
{sending ? 'Отправка…' : 'Отправить'} {sending ? 'Отправка…' : 'Отправить'}
</Button> </Button>
</div> </div>
</div>
</div>
); );
}; };
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RequestChat.module.scss'; import styles from './RequestChat.module.scss';
import { AttachmentList } from '../../ui';
import { IChatMessage } from '../models/IChatMessage'; import { IChatMessage } from '../models/IChatMessage';
interface IChatMessageProps { interface IChatMessageProps {
@@ -28,8 +29,14 @@ export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
<span className={styles.sentAt}>{formatSentAt(message.createdAt)}</span> <span className={styles.sentAt}>{formatSentAt(message.createdAt)}</span>
</div> </div>
{/* Переносы строк сохраняем стилем (white-space: pre-wrap), а не <br/> — {/* Переносы строк сохраняем стилем (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> </div>
</li> </li>
); );
@@ -125,11 +125,46 @@
border-radius: $r-md; 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 { .composer {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 10px; gap: 10px;
margin-top: 12px; }
.composerActions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
} }
.input { .input {
@@ -168,6 +203,10 @@
align-items: stretch; align-items: stretch;
} }
.composerActions {
justify-content: flex-end;
}
.bubble { .bubble {
max-width: 100%; max-width: 100%;
} }
@@ -3,6 +3,7 @@ import { friendlyErrorMessage } from '../diagnostics';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { IChatMessage } from './models/IChatMessage'; import { IChatMessage } from './models/IChatMessage';
import { addChatMessage, getChatMessages } from './requestChat.service'; import { addChatMessage, getChatMessages } from './requestChat.service';
import { validateChatFiles } from './chatFiles.service';
/** /**
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая, * Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
@@ -18,11 +19,12 @@ export interface IRequestChat {
sending: boolean; sending: boolean;
error?: string; error?: string;
/** /**
* Отправить сообщение. Пустой текст игнорируется. Возвращает true, если * Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
* сообщение записано: по false поле ввода НЕ очищается, иначе набранный * без текста и без файлов — игнорируется. Возвращает true, если сообщение
* текст пропал бы вместе с неудачной отправкой. * записано: по false поле ввода и выбранные файлы НЕ сбрасываются, иначе
* набранное пропало бы вместе с неудачной отправкой.
*/ */
send: (text: string) => Promise<boolean>; send: (text: string, files: File[]) => Promise<boolean>;
/** Перечитать переписку (кнопка «Обновить»). */ /** Перечитать переписку (кнопка «Обновить»). */
reload: () => void; reload: () => void;
} }
@@ -85,12 +87,21 @@ export function useRequestChat(
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]); }, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
const send = React.useCallback( const send = React.useCallback(
async (text: string): Promise<boolean> => { async (text: string, files: File[]): Promise<boolean> => {
const trimmed = text.trim(); 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); setSending(true);
try { try {
await addChatMessage(formKey, itemId, listId, trimmed); await addChatMessage(formKey, itemId, listId, trimmed, files);
setError(undefined); setError(undefined);
reload(); reload();
return true; return true;
@@ -142,3 +142,14 @@ export function approvalListTitleForListId(id: RequestTypeId, listId: string): s
); );
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`; return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
} }
/**
* Библиотека документов, куда складываются файлы, прикреплённые к сообщениям
* чата заявки (shared/chat). Живёт на том же сайте данных (DATA_SITE_URL), что
* и списки заявок, и создана вручную — провижинер её не заводит.
*
* Не партиционируется в отличие от списков заявок: файлы лежат в подпапке на
* заявку (см. chatFiles.service.ts), доступ к ним идёт по прямому пути, а не
* через представление, поэтому порог в 5000 элементов на такой доступ не влияет.
*/
export const CHAT_FILES_LIBRARY_TITLE = 'Файлы чата';