Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad2a19a70 | |||
| 95b0f8f21e | |||
| e8b060add3 | |||
| ad1234b84a | |||
| 353a1aa543 | |||
| 7f641b36c6 | |||
| 9cacb6b271 | |||
| 72e330e659 | |||
| 975681105b | |||
| ddf313c951 | |||
| c59b0ad208 | |||
| 6aef9a5ba9 | |||
| 4c018154d3 | |||
| 17f9dec39b | |||
| 4eece061d1 | |||
| 9f97a19cab | |||
| 82a9a68fbc | |||
| ac633c767e |
@@ -1 +1 @@
|
|||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
|
|||||||
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
||||||
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
||||||
$npmrc = @'
|
$npmrc = @'
|
||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
'@
|
'@
|
||||||
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
||||||
Success ".npmrc создан (адрес registry @tnsu)."
|
Success ".npmrc создан (адрес registry @tnsu)."
|
||||||
@@ -282,7 +282,7 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
||||||
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
||||||
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
|
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
|
||||||
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
|
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены |
|
||||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
@@ -9886,9 +9886,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.12.0",
|
"version": "0.14.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
||||||
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
|
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.14.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Диагностика: печатает строки маршрута одной заявки из списка «— согласование»
|
||||||
|
(StepOrder, статус, фаза, кем переназначено, кто создал/менял) и историю версий
|
||||||
|
строк исполнения — чтобы понять, откуда в маршруте взялся участник и кто
|
||||||
|
перевёл его в «На исполнении». Только чтение, ничего не меняет.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./Inspect-Route.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||||||
|
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||||||
|
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
|
||||||
|
-ListTitle "Прием на работу 1 — согласование" -RequestId 68
|
||||||
|
#>
|
||||||
|
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)][int]$RequestId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||||||
|
|
||||||
|
$query = "<View><Query><Where><Eq><FieldRef Name='Ticket' LookupId='TRUE'/><Value Type='Lookup'>$RequestId</Value></Eq></Where>" +
|
||||||
|
"<OrderBy><FieldRef Name='StepOrder'/></OrderBy></Query></View>"
|
||||||
|
$items = Get-PnPListItem -List $ListTitle -Query $query
|
||||||
|
|
||||||
|
function UserText($value) {
|
||||||
|
if ($null -eq $value) { return '' }
|
||||||
|
return $value.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`nМаршрут заявки #$RequestId («$ListTitle»):`n" -ForegroundColor Cyan
|
||||||
|
$items | ForEach-Object {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Id = $_.Id
|
||||||
|
StepOrder = $_['StepOrder']
|
||||||
|
Phase = $_['Phase']
|
||||||
|
Status = $_['ApprovalStatus']
|
||||||
|
Approver = UserText $_['TansuSiteApprover']
|
||||||
|
DelegatedBy = UserText $_['DelegatedBy']
|
||||||
|
Reassign = $_['AllowReassignToOtherApprovers']
|
||||||
|
Created = $_['Created']
|
||||||
|
CreatedBy = UserText $_['Author']
|
||||||
|
Modified = $_['Modified']
|
||||||
|
ModifiedBy = UserText $_['Editor']
|
||||||
|
}
|
||||||
|
} | Format-Table -AutoSize
|
||||||
|
|
||||||
|
# История версий — только строки исполнения: там видно, кто и когда менял статус.
|
||||||
|
$items | Where-Object { $_['Phase'] -eq 'Исполнение' } | ForEach-Object {
|
||||||
|
Write-Host "`nВерсии строки Id=$($_.Id) ($(UserText $_['TansuSiteApprover'])):" -ForegroundColor Yellow
|
||||||
|
Get-PnPProperty -ClientObject $_ -Property Versions | Out-Null
|
||||||
|
$_.Versions | ForEach-Object {
|
||||||
|
Get-PnPProperty -ClientObject $_ -Property CreatedBy | Out-Null
|
||||||
|
[pscustomobject]@{
|
||||||
|
Version = $_.VersionLabel
|
||||||
|
Created = $_.Created
|
||||||
|
By = $_.CreatedBy.Email
|
||||||
|
Status = $_['ApprovalStatus']
|
||||||
|
StepOrder = $_['StepOrder']
|
||||||
|
}
|
||||||
|
} | Format-Table -AutoSize
|
||||||
|
}
|
||||||
|
|
||||||
|
Disconnect-PnPOnline
|
||||||
@@ -42,6 +42,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
icon: 'people',
|
icon: 'people',
|
||||||
comingSoon: true
|
comingSoon: true
|
||||||
},
|
},
|
||||||
|
// Пока только тестировщикам: каркас без полей по видам; тип и маршрут не заведены в админке approval-route.
|
||||||
|
{
|
||||||
|
id: 'hr-contract-change',
|
||||||
|
categoryId: 'hr',
|
||||||
|
title: 'Изменение условий трудового договора',
|
||||||
|
icon: 'document',
|
||||||
|
comingSoon: true
|
||||||
|
},
|
||||||
|
|
||||||
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
||||||
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
usePhasedRoute
|
usePhasedRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -108,14 +109,17 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context };
|
const sectionProps = { data, set: NOOP_SET, context };
|
||||||
@@ -173,7 +177,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -255,16 +256,19 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
printOrgIndex
|
printOrgIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -348,7 +352,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
useViewExecutors,
|
useViewExecutors,
|
||||||
approvalPhaseRows
|
approvalPhaseRows
|
||||||
@@ -118,16 +119,19 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,7 +179,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
margin-bottom: $sp-4;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import styles from './ContractChangeForm.module.scss';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
Icon,
|
||||||
|
RequestNumber,
|
||||||
|
SubmitActions,
|
||||||
|
getDraftDeleteHandler,
|
||||||
|
useRequestSubmit
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
composeRemoveRowRule,
|
||||||
|
ApprovalMatrix,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
|
StatusBadge,
|
||||||
|
useMatrixEditor,
|
||||||
|
getPlannedMatrix,
|
||||||
|
loadRoutePhase,
|
||||||
|
isOpenedRouteReady,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId,
|
||||||
|
REQUEST_STATUS,
|
||||||
|
isEditableStatus,
|
||||||
|
IApproverToWrite,
|
||||||
|
unassignedStepMessage,
|
||||||
|
resolveWriteListId,
|
||||||
|
executorsToWrite,
|
||||||
|
initialSubmitStatus,
|
||||||
|
useFormExecutors
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
|
import { IRequestFormProps } from '../../models/types';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import {
|
||||||
|
approvalListTitle,
|
||||||
|
approvalListTitleForListId,
|
||||||
|
dataListTitle,
|
||||||
|
dataListTitleForListId
|
||||||
|
} from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
|
||||||
|
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
|
||||||
|
import { loadContractChangeRequest } from './load';
|
||||||
|
import { useContractEmployee } from './useContractEmployee';
|
||||||
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||||
|
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
|
||||||
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
|
const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
||||||
|
requestType,
|
||||||
|
context,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
editId,
|
||||||
|
editListId,
|
||||||
|
onCodeLoaded
|
||||||
|
}) => {
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||||
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
|
const submit = useRequestSubmit();
|
||||||
|
// Стабильная обёртка над колбэком родителя — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя (см. DismissalForm).
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
const applicantEmail = effectiveUserEmail(context);
|
||||||
|
|
||||||
|
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
|
||||||
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
|
const requestTypeId = requestTypeIdState.id;
|
||||||
|
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
|
||||||
|
const dataListRef = editListId || dataListTitle(FORM_ID);
|
||||||
|
const attachments = useAttachmentEditor(dataListRef, requestId);
|
||||||
|
const { setItems: setAttachmentItems } = attachments;
|
||||||
|
|
||||||
|
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
|
||||||
|
.then(([loaded, files]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(loaded.data);
|
||||||
|
setAttachmentItems(files);
|
||||||
|
setRequestStatus(loaded.status);
|
||||||
|
setRequestCode(loaded.code || undefined);
|
||||||
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
|
||||||
|
|
||||||
|
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
|
||||||
|
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
|
||||||
|
set('Employee', record)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
|
||||||
|
// У заявки на редактировании маршрут читается из уже записанных строк.
|
||||||
|
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
|
||||||
|
const loadMatrix = useCallback(
|
||||||
|
() =>
|
||||||
|
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||||
|
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
|
||||||
|
),
|
||||||
|
[context, approval, editId, requestStatus, routeUserEmail, requestTypeId]
|
||||||
|
);
|
||||||
|
const matrix = useMatrixEditor(
|
||||||
|
loadMatrix,
|
||||||
|
`${routeUserEmail}-${editId ?? 'new'}`,
|
||||||
|
!readOnly,
|
||||||
|
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
|
||||||
|
(login) => approval.getJobTitle(login)
|
||||||
|
);
|
||||||
|
|
||||||
|
const executors = useFormExecutors({
|
||||||
|
readOnly,
|
||||||
|
context,
|
||||||
|
approval,
|
||||||
|
requestTypeId,
|
||||||
|
userEmail: routeUserEmail,
|
||||||
|
editId,
|
||||||
|
requestStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
||||||
|
const validate = (): string | undefined => {
|
||||||
|
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
|
||||||
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
email: r.email,
|
||||||
|
position: r.position,
|
||||||
|
allowReassign: r.allowReassign,
|
||||||
|
order: r.order,
|
||||||
|
slaHours: r.slaHours,
|
||||||
|
askOverdueReason: r.askOverdueReason,
|
||||||
|
autoApprove: r.autoApprove
|
||||||
|
}));
|
||||||
|
|
||||||
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
|
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
|
||||||
|
const rtId = requestTypeId;
|
||||||
|
if (!rtId) {
|
||||||
|
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||||
|
}
|
||||||
|
// Новая заявка пишется в ТЕКУЩУЮ партицию — спрашиваем её прямо перед записью.
|
||||||
|
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||||
|
const { id, code } = await saveContractChangeRequest(
|
||||||
|
requestId,
|
||||||
|
rtId,
|
||||||
|
data,
|
||||||
|
toApprovers(matrix.rows),
|
||||||
|
executorsToWrite(executors.rows),
|
||||||
|
status,
|
||||||
|
writeListId
|
||||||
|
);
|
||||||
|
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
|
||||||
|
// обновит эту же заявку, а не создаст вторую.
|
||||||
|
setRequestId(id);
|
||||||
|
setRequestStatus(status);
|
||||||
|
if (code) setRequestCode(code);
|
||||||
|
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
|
||||||
|
try {
|
||||||
|
await attachments.uploadPending(prepareFilesFor(data), {
|
||||||
|
listRef: writeListId || dataListTitle(FORM_ID),
|
||||||
|
itemId: id
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
|
||||||
|
}
|
||||||
|
if (onSaved) onSaved();
|
||||||
|
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
|
||||||
|
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||||
|
await notifyActiveStep(context, approval, id, {
|
||||||
|
requestTypeId: rtId,
|
||||||
|
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
|
||||||
|
const validateSubmit = (): string | undefined => {
|
||||||
|
const formError = validate();
|
||||||
|
if (formError) return formError;
|
||||||
|
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
|
||||||
|
const missingFiles = missingRequiredAttachments(data, attachments);
|
||||||
|
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
||||||
|
if (matrix.error) {
|
||||||
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
|
}
|
||||||
|
const unassignedError = unassignedStepMessage(
|
||||||
|
matrix.rows,
|
||||||
|
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||||
|
);
|
||||||
|
if (unassignedError) return unassignedError;
|
||||||
|
return unassignedStepMessage(
|
||||||
|
executors.rows,
|
||||||
|
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||||
|
const handleSubmit = (): Promise<void> =>
|
||||||
|
submit.run(
|
||||||
|
'submit',
|
||||||
|
save(
|
||||||
|
executors.rows.length > 0
|
||||||
|
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||||
|
: REQUEST_STATUS.onApproval
|
||||||
|
),
|
||||||
|
validateSubmit
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = getDraftDeleteHandler({
|
||||||
|
formKey: FORM_ID,
|
||||||
|
requestId,
|
||||||
|
requestStatus,
|
||||||
|
listId: editListId,
|
||||||
|
code: requestCode,
|
||||||
|
submit,
|
||||||
|
onDeleted: onCancel
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" className={styles.back} onClick={onCancel}>
|
||||||
|
<Icon name="arrow-left" size="sm" />
|
||||||
|
<span>К списку заявок</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FormCard
|
||||||
|
title={requestType.title}
|
||||||
|
subtitle="Категория: Отдел кадров"
|
||||||
|
belowSubtitle={
|
||||||
|
<InitiatorLine
|
||||||
|
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||||
|
itemId={requestId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
requestId || requestStatus ? (
|
||||||
|
<>
|
||||||
|
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||||
|
{requestCode && <RequestNumber code={requestCode} />}
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submit.savedKind && !submit.error && (
|
||||||
|
<Alert variant="ok">
|
||||||
|
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<FormModeProvider readOnly={readOnly}>
|
||||||
|
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
|
||||||
|
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
|
||||||
|
</FormModeProvider>
|
||||||
|
|
||||||
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(applicantEmail)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{/* Автору на доработке важно видеть, почему вернули, — история и в форме. */}
|
||||||
|
{editId && (
|
||||||
|
<div className="rp-no-print">
|
||||||
|
<ApprovalHistory
|
||||||
|
approval={approval}
|
||||||
|
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||||
|
requestId={editId}
|
||||||
|
reloadKey={requestStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SubmitActions
|
||||||
|
onDelete={handleDelete}
|
||||||
|
submit={submit}
|
||||||
|
isValid={isValid}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitLabel="Отправить на согласование"
|
||||||
|
/>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractChangeForm;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Заглушка на время загрузки/ошибки — как в остальных просмотрах.
|
||||||
|
.state {
|
||||||
|
padding: $sp-8;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './ContractChangeView.module.scss';
|
||||||
|
import { Alert, FormCard, InitiatorLine, FormModeProvider } from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
ApproverService,
|
||||||
|
DecisionPanel,
|
||||||
|
StatusBadge,
|
||||||
|
ApprovalMatrix,
|
||||||
|
ApprovalHistory,
|
||||||
|
useMatrixEditor,
|
||||||
|
useViewExecutors,
|
||||||
|
ExecutorsMatrix,
|
||||||
|
approvalPhaseRows,
|
||||||
|
notifyActiveStep,
|
||||||
|
useRequestTypeId
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import {
|
||||||
|
approvalListTitle,
|
||||||
|
approvalListTitleForListId,
|
||||||
|
dataListTitle,
|
||||||
|
dataListTitleForListId
|
||||||
|
} from '../../shared/listTitles';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
|
import { loadContractChangeRequest } from './load';
|
||||||
|
import { contractChangeApprovalService } from './submit';
|
||||||
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||||
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
|
interface IContractChangeViewProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
itemId: number;
|
||||||
|
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||||
|
formKey: string;
|
||||||
|
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||||
|
listId?: string;
|
||||||
|
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||||
|
onCodeLoaded?: (code: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_TITLE = 'Изменение условий трудового договора';
|
||||||
|
|
||||||
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заявка только для просмотра (режим согласующего): та же секция, что в форме
|
||||||
|
* подачи, под <FormModeProvider readOnly>, + маршрут, история и панель решения.
|
||||||
|
* Печати пока нет — бланк кадров ещё не согласован.
|
||||||
|
*/
|
||||||
|
const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const approval = useMemo(() => contractChangeApprovalService(listId), [listId]);
|
||||||
|
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||||
|
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
|
||||||
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
|
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
|
||||||
|
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||||
|
const { setItems: setAttachmentItems } = attachments;
|
||||||
|
|
||||||
|
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||||
|
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||||
|
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||||
|
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||||
|
approval.getJobTitle(l)
|
||||||
|
);
|
||||||
|
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
|
||||||
|
.then(([loaded, files]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(loaded.data);
|
||||||
|
setAttachmentItems(files);
|
||||||
|
setStatus(loaded.status);
|
||||||
|
setCode(loaded.code);
|
||||||
|
notifyCode(loaded.code);
|
||||||
|
setError('');
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<Alert variant="err">{error}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notify = (): void => {
|
||||||
|
// Тип ещё не резолвился — решение уже применилось, просто некого уведомлять;
|
||||||
|
// не роняем UI ради необязательного оповещения.
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
notifyActiveStep(context, approval, itemId, {
|
||||||
|
requestTypeId,
|
||||||
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
|
}).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRouteChanged = (): void => {
|
||||||
|
setDecisionSeq((n) => n + 1);
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormCard
|
||||||
|
title={VIEW_TITLE}
|
||||||
|
subtitle={`№${code || itemId}`}
|
||||||
|
belowSubtitle={
|
||||||
|
<InitiatorLine
|
||||||
|
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||||
|
itemId={itemId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
|
>
|
||||||
|
<FormModeProvider readOnly>
|
||||||
|
<RequestSection data={data} set={NOOP_SET} context={context} />
|
||||||
|
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
|
||||||
|
</FormModeProvider>
|
||||||
|
</FormCard>
|
||||||
|
|
||||||
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{/* История: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
|
||||||
|
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||||
|
|
||||||
|
<DecisionPanel
|
||||||
|
service={approverService}
|
||||||
|
requestId={itemId}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
onDecided={(newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
handleRouteChanged();
|
||||||
|
}}
|
||||||
|
onDelegated={handleRouteChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractChangeView;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Изменение условий трудового договора (hr-contract-change)
|
||||||
|
|
||||||
|
**Назначение:** заявка на изменение условий трудового договора сотрудника (ст. 33 ТК РК) —
|
||||||
|
повышение оклада, надбавка, перевод, смена режима работы и т. д. (10 видов из ТЗ кадров).
|
||||||
|
**Категория:** Отдел кадров
|
||||||
|
**Списки SP:** «Изменение условий трудового договора 1 — данные» / «… 1 — согласование»
|
||||||
|
**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по
|
||||||
|
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
|
||||||
|
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
|
||||||
|
|
||||||
|
> **Статус: каркас.** Есть сотрудник и вид изменения; поля под конкретные виды
|
||||||
|
> добавляются по мере разбора ТЗ кадров. Таблица ТЗ (пример, кто инициирует, что
|
||||||
|
> приложить, сроки, маршрут А/Б/В/Г…) — общая, в форму из неё попадает только то,
|
||||||
|
> что явно согласовано. Печатного бланка пока нет.
|
||||||
|
|
||||||
|
## Маршрут
|
||||||
|
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
|
||||||
|
- Маршрут строится по **e-mail сотрудника**, а если его нет (рабочий без учётной
|
||||||
|
записи M365) — по **e-mail заявителя** (`routeEmail` в `model.ts`); на форме под
|
||||||
|
сотрудником тогда пометка об этом.
|
||||||
|
- Матрица редактируема заявителем при новой заявке / черновике / возврате.
|
||||||
|
- От вида изменения маршрут пока не зависит (`fieldValues` не передаются).
|
||||||
|
|
||||||
|
## Особое поведение
|
||||||
|
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
|
||||||
|
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
|
||||||
|
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
|
||||||
|
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
|
||||||
|
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
|
||||||
|
в командировке: места работы человека в виде «Компания — должность — подразделение».
|
||||||
|
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
|
||||||
|
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
|
||||||
|
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
|
||||||
|
возвращались все места работы — тогда у совместителей появится выбор.
|
||||||
|
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
|
||||||
|
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
|
||||||
|
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
|
||||||
|
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
|
||||||
|
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
|
||||||
|
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
|
||||||
|
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
|
||||||
|
тот же ИИН); не нашлось — в списке только сохранённая.
|
||||||
|
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
|
||||||
|
грузится сразу после выбора сотрудника, ещё до выбора организации.
|
||||||
|
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
|
||||||
|
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
|
||||||
|
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
|
||||||
|
(`ChangeType`), в SP — подпись (`CHANGE_TYPE_LABELS`): подпись = choice колонки,
|
||||||
|
менять только вместе с `provisioning.json`.
|
||||||
|
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
|
||||||
|
|
||||||
|
## Поля по видам
|
||||||
|
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
|
||||||
|
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
|
||||||
|
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
|
||||||
|
оставалось скрытых значений после смены вида.
|
||||||
|
|
||||||
|
| Вид | Секция | Поля (все необязательные, без ограничений) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
|
||||||
|
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
|
||||||
|
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
|
||||||
|
| Остальные 7 | — | ещё не разобраны с кадрами |
|
||||||
|
|
||||||
|
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
|
||||||
|
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
|
||||||
|
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
|
||||||
|
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
|
||||||
|
|
||||||
|
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
|
||||||
|
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
|
||||||
|
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
|
||||||
|
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
|
||||||
|
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
|
||||||
|
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
|
||||||
|
только при отправке — черновик сохраняется и без файла.
|
||||||
|
|
||||||
|
## Открытые вопросы (проверить на тесте)
|
||||||
|
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
|
||||||
|
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
|
||||||
|
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
|
||||||
|
|
||||||
|
## Секции формы
|
||||||
|
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
- rptype: `hr-contract-change`
|
||||||
|
- Режим согласующего (просмотр): есть (`ContractChangeView`), без печати
|
||||||
|
- «Создать на основе»: нет
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Изменение условий трудового договора — согласование",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
|
||||||
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
|
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||||
|
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||||
|
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||||
|
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||||
|
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||||
|
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||||
|
]
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
|
||||||
|
.amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amountInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
|
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
|
||||||
|
import { UnitSwitch } from './UnitSwitch';
|
||||||
|
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||||
|
import styles from './AllowanceSection.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
|
||||||
|
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
|
||||||
|
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
|
||||||
|
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
|
||||||
|
*
|
||||||
|
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
||||||
|
*/
|
||||||
|
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
|
||||||
|
<FormField label="Основание" htmlFor="AllowanceReason" full>
|
||||||
|
<Select
|
||||||
|
id="AllowanceReason"
|
||||||
|
value={data.AllowanceReason}
|
||||||
|
onChange={(v) => set('AllowanceReason', v)}
|
||||||
|
options={ALLOWANCE_REASONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
|
||||||
|
<TextInput
|
||||||
|
id="AllowanceStartDate"
|
||||||
|
type="date"
|
||||||
|
value={data.AllowanceStartDate}
|
||||||
|
onChange={(v) => set('AllowanceStartDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
|
||||||
|
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
|
||||||
|
{!data.AllowanceIndefinite && (
|
||||||
|
<TextInput
|
||||||
|
id="AllowanceEndDate"
|
||||||
|
type="date"
|
||||||
|
value={data.AllowanceEndDate}
|
||||||
|
onChange={(v) => set('AllowanceEndDate', v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Checkbox
|
||||||
|
checked={data.AllowanceIndefinite}
|
||||||
|
onChange={(checked) => {
|
||||||
|
set('AllowanceIndefinite', checked);
|
||||||
|
if (checked) set('AllowanceEndDate', '');
|
||||||
|
}}
|
||||||
|
label="Бессрочно"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Размер" htmlFor="AllowanceAmount">
|
||||||
|
<div className={styles.amount}>
|
||||||
|
<div className={styles.amountInput}>
|
||||||
|
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
|
||||||
|
{data.AllowanceUnit === '%' ? (
|
||||||
|
<TextInput
|
||||||
|
id="AllowanceAmount"
|
||||||
|
value={data.AllowanceAmount}
|
||||||
|
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
|
||||||
|
placeholder="например, 7.5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<UnitSwitch
|
||||||
|
value={data.AllowanceUnit}
|
||||||
|
onChange={(unit) => {
|
||||||
|
set('AllowanceUnit', unit);
|
||||||
|
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
|
||||||
|
<TextArea
|
||||||
|
id="AllowanceJustification"
|
||||||
|
value={data.AllowanceJustification}
|
||||||
|
onChange={(v) => set('AllowanceJustification', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{attachments && requiresAttestation(data.AllowanceReason) && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label={ATTESTATION_ATTACHMENT}
|
||||||
|
editor={attachments}
|
||||||
|
editable={!readOnly}
|
||||||
|
groupKey={ATTESTATION_ATTACHMENT}
|
||||||
|
category={ATTESTATION_ATTACHMENT}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid $gray-300;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0 $sp-3;
|
||||||
|
border: 0;
|
||||||
|
background: $white;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: $gray-700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
border-left: 1px solid $gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active,
|
||||||
|
.active:hover:not(:disabled) {
|
||||||
|
background: $navy-700;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
|
||||||
|
import styles from './UnitSwitch.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: AllowanceUnit;
|
||||||
|
onChange: (unit: AllowanceUnit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
|
||||||
|
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
|
||||||
|
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
|
||||||
|
*/
|
||||||
|
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
|
||||||
|
{ALLOWANCE_UNITS.map((unit) => (
|
||||||
|
<button
|
||||||
|
key={unit}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={unit === value}
|
||||||
|
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onChange(unit)}
|
||||||
|
>
|
||||||
|
{unit}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||||
|
|
||||||
|
describe('sanitizePercentInput', () => {
|
||||||
|
it('оставляет целое как есть', () => {
|
||||||
|
expect(sanitizePercentInput('10')).toBe('10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('запятую превращает в точку', () => {
|
||||||
|
expect(sanitizePercentInput('7,5')).toBe('7.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('выбрасывает буквы, пробелы и знак процента', () => {
|
||||||
|
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
|
||||||
|
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
|
||||||
|
expect(sanitizePercentInput('7,')).toBe('7.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('amountForUnit', () => {
|
||||||
|
it('при переходе в тенге отбрасывает дробную часть', () => {
|
||||||
|
expect(amountForUnit('7.5', '₸')).toBe('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('в процентах значение не меняет', () => {
|
||||||
|
expect(amountForUnit('7.5', '%')).toBe('7.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AllowanceUnit } from './options';
|
||||||
|
|
||||||
|
/** Знаков после разделителя у процента (7,25 %). */
|
||||||
|
const PERCENT_FRACTION_DIGITS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
|
||||||
|
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
|
||||||
|
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
|
||||||
|
*/
|
||||||
|
export function sanitizePercentInput(text: string): string {
|
||||||
|
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
|
||||||
|
const [whole, ...rest] = normalized.split('.');
|
||||||
|
if (rest.length === 0) return whole;
|
||||||
|
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
|
||||||
|
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
|
||||||
|
*/
|
||||||
|
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
|
||||||
|
if (unit === '₸') return amount.split('.')[0];
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
|
||||||
|
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
|
||||||
|
* совпадают с choices колонки AllowanceReason (provisioning.json).
|
||||||
|
*/
|
||||||
|
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
|
||||||
|
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
|
||||||
|
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
|
||||||
|
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
|
||||||
|
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
|
||||||
|
|
||||||
|
export const ALLOWANCE_REASONS: ISelectOption[] = [
|
||||||
|
ALLOWANCE_REASON_MENTORING,
|
||||||
|
ALLOWANCE_REASON_TRAVEL,
|
||||||
|
ALLOWANCE_REASON_COMBINING,
|
||||||
|
ALLOWANCE_REASON_HAZARDOUS,
|
||||||
|
ALLOWANCE_REASON_OTHER
|
||||||
|
].map((reason) => ({ value: reason, label: reason }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
|
||||||
|
* за вредные условия (пункт «г»); там же оно обязательно.
|
||||||
|
*/
|
||||||
|
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
|
||||||
|
|
||||||
|
export function requiresAttestation(reason: string): boolean {
|
||||||
|
return reason === ALLOWANCE_REASON_HAZARDOUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Единица размера; значения = choices колонки AllowanceUnit. */
|
||||||
|
export type AllowanceUnit = '%' | '₸';
|
||||||
|
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
|
||||||
|
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
|
||||||
|
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
|
||||||
|
|
||||||
|
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
|
||||||
|
export function allowanceUnitFromText(text: string): AllowanceUnit {
|
||||||
|
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChangeType } from '../changeTypes';
|
||||||
|
import { ISectionProps } from '../model';
|
||||||
|
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
|
||||||
|
import { AllowanceSection } from './Allowance/AllowanceSection';
|
||||||
|
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
||||||
|
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
|
||||||
|
* Новый вид = папка byType/<Вид>/ + строка здесь.
|
||||||
|
*/
|
||||||
|
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
|
||||||
|
'salary-increase': SalaryIncreaseSection,
|
||||||
|
allowance: AllowanceSection,
|
||||||
|
'position-transfer': PositionTransferSection
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
|
||||||
|
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
|
||||||
|
<>
|
||||||
|
{props.data.ChangeTypes.map((type) => {
|
||||||
|
const Section = SECTION_BY_TYPE[type];
|
||||||
|
return Section ? <Section key={type} {...props} /> : undefined;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { PositionPicker } from '../../../../shared/dictionary';
|
||||||
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
|
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
|
||||||
|
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
|
||||||
|
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
|
||||||
|
* должность уже видна в «Организации» — отдельного «было» нет.
|
||||||
|
*
|
||||||
|
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
||||||
|
*/
|
||||||
|
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
|
||||||
|
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
|
||||||
|
<PositionPicker
|
||||||
|
context={context}
|
||||||
|
id="PositionTransferNewPosition"
|
||||||
|
value={data.PositionTransferNewPosition}
|
||||||
|
onChange={(v) => set('PositionTransferNewPosition', v)}
|
||||||
|
placeholder="Начните вводить название должности…"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
|
||||||
|
<TextInput
|
||||||
|
id="PositionTransferDate"
|
||||||
|
type="date"
|
||||||
|
value={data.PositionTransferDate}
|
||||||
|
onChange={(v) => set('PositionTransferDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Причина" htmlFor="PositionTransferReason" full>
|
||||||
|
<Select
|
||||||
|
id="PositionTransferReason"
|
||||||
|
value={data.PositionTransferReason}
|
||||||
|
onChange={(v) => set('PositionTransferReason', v)}
|
||||||
|
options={POSITION_TRANSFER_REASONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
|
||||||
|
<TextArea
|
||||||
|
id="PositionTransferJustification"
|
||||||
|
value={data.PositionTransferJustification}
|
||||||
|
onChange={(v) => set('PositionTransferJustification', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{attachments && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label={JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
editor={attachments}
|
||||||
|
editable={!readOnly}
|
||||||
|
groupKey={JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
category={JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
|
||||||
|
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
|
||||||
|
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
|
||||||
|
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
|
||||||
|
*/
|
||||||
|
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
|
||||||
|
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
|
||||||
|
'Вакантная должность, соответствующая квалификации работника',
|
||||||
|
'Открытие нового объекта / проекта, перераспределение функций',
|
||||||
|
'Иное'
|
||||||
|
].map((reason) => ({ value: reason, label: reason }));
|
||||||
|
|
||||||
|
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
|
||||||
|
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||||
|
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { SALARY_INCREASE_REASONS } from './reasons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
|
||||||
|
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
|
||||||
|
* без ограничений — по решению от 2026-10-28.
|
||||||
|
*
|
||||||
|
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является
|
||||||
|
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
|
||||||
|
*/
|
||||||
|
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
|
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
|
||||||
|
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
|
||||||
|
<MoneyInput
|
||||||
|
id="SalaryIncreaseCurrent"
|
||||||
|
value={data.SalaryIncreaseCurrent}
|
||||||
|
onChange={(v) => set('SalaryIncreaseCurrent', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
|
||||||
|
<MoneyInput
|
||||||
|
id="SalaryIncreaseNew"
|
||||||
|
value={data.SalaryIncreaseNew}
|
||||||
|
onChange={(v) => set('SalaryIncreaseNew', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
|
||||||
|
<TextInput
|
||||||
|
id="SalaryIncreaseDate"
|
||||||
|
type="date"
|
||||||
|
value={data.SalaryIncreaseDate}
|
||||||
|
onChange={(v) => set('SalaryIncreaseDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
|
||||||
|
<Select
|
||||||
|
id="SalaryIncreaseReason"
|
||||||
|
value={data.SalaryIncreaseReason}
|
||||||
|
onChange={(v) => set('SalaryIncreaseReason', v)}
|
||||||
|
options={SALARY_INCREASE_REASONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
|
||||||
|
<TextArea
|
||||||
|
id="SalaryIncreaseJustification"
|
||||||
|
value={data.SalaryIncreaseJustification}
|
||||||
|
onChange={(v) => set('SalaryIncreaseJustification', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
|
||||||
|
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
|
||||||
|
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
|
||||||
|
*/
|
||||||
|
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
|
||||||
|
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
|
||||||
|
export const SALARY_REASON_OTHER = 'Иное';
|
||||||
|
|
||||||
|
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
|
||||||
|
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
|
||||||
|
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
|
||||||
|
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
|
||||||
|
];
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
|
||||||
|
import { IFormData } from '../model';
|
||||||
|
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
|
||||||
|
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
|
||||||
|
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
|
||||||
|
*/
|
||||||
|
export interface IAttachmentSlot {
|
||||||
|
category: string;
|
||||||
|
/** Обязателен там, где ТЗ прямо требует документ. */
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
|
||||||
|
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
|
||||||
|
const slots: IAttachmentSlot[] = [];
|
||||||
|
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
|
||||||
|
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('position-transfer')) {
|
||||||
|
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
|
||||||
|
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
|
||||||
|
*/
|
||||||
|
export function prepareFilesFor(data: IFormData): PrepareFiles {
|
||||||
|
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
|
||||||
|
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
|
||||||
|
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
|
||||||
|
return activeAttachmentSlots(data)
|
||||||
|
.filter((slot) => slot.required)
|
||||||
|
.filter((slot) => {
|
||||||
|
const uploaded = attachmentsOfCategory(editor.items, slot.category).length;
|
||||||
|
const selected = (editor.pending[slot.category] || []).length;
|
||||||
|
return uploaded + selected === 0;
|
||||||
|
})
|
||||||
|
.map((slot) => slot.category);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ISelectOption } from '../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Виды изменения условий трудового договора (ст. 33 ТК РК) — по ТЗ кадров.
|
||||||
|
*
|
||||||
|
* В коде вид — стабильный ключ (union ниже), в SharePoint — его подпись: колонка
|
||||||
|
* ChangeTypes (MultiChoice) хранит текст, чтобы кадры видели в списке и отчётах
|
||||||
|
* понятные значения. Подпись = choice колонки, поэтому менять её можно только
|
||||||
|
* вместе с choices в provisioning.json (иначе старые заявки перестанут узнаваться).
|
||||||
|
*/
|
||||||
|
export type ChangeType =
|
||||||
|
| 'salary-increase'
|
||||||
|
| 'allowance'
|
||||||
|
| 'position-transfer'
|
||||||
|
| 'department-transfer'
|
||||||
|
| 'workplace-change'
|
||||||
|
| 'contract-extension'
|
||||||
|
| 'schedule-change'
|
||||||
|
| 'function-change'
|
||||||
|
| 'employer-initiative'
|
||||||
|
| 'temporary-transfer';
|
||||||
|
|
||||||
|
/** Порядок — как в ТЗ (в нём же показываются в выпадающем списке). */
|
||||||
|
export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
|
||||||
|
'salary-increase': 'Повышение должностного оклада',
|
||||||
|
allowance: 'Установление надбавки / доплаты',
|
||||||
|
'position-transfer': 'Перевод на другую должность (постоянный)',
|
||||||
|
'department-transfer': 'Перевод в другое подразделение',
|
||||||
|
'workplace-change': 'Изменение места работы (другая местность)',
|
||||||
|
'contract-extension': 'Продление срока трудового договора',
|
||||||
|
'schedule-change':
|
||||||
|
'Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника',
|
||||||
|
'function-change': 'Изменение трудовой функции (без смены должности)',
|
||||||
|
// Самая длинная подпись (~225 символов) — близко к лимиту choice в SP (255), не удлинять.
|
||||||
|
'employer-initiative':
|
||||||
|
'Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, ' +
|
||||||
|
'изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)',
|
||||||
|
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько видов можно выбрать в одной заявке. По ТЗ — один; данные (массив
|
||||||
|
* ChangeTypes + MultiChoice-колонка) уже рассчитаны на несколько, чтобы снять
|
||||||
|
* ограничение правкой UI, а не миграцией.
|
||||||
|
*/
|
||||||
|
export const MAX_CHANGE_TYPES = 1;
|
||||||
|
|
||||||
|
export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map(
|
||||||
|
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
|
||||||
|
export function changeTypeFromLabel(label: string): ChangeType | undefined {
|
||||||
|
const normalized = label.trim();
|
||||||
|
return (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).find(
|
||||||
|
(type) => CHANGE_TYPE_LABELS[type] === normalized
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { getSP } from '../../shared/pnp/pnpjs';
|
||||||
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
|
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
|
import { ChangeType, changeTypeFromLabel } from './changeTypes';
|
||||||
|
import { allowanceUnitFromText } from './byType/Allowance/options';
|
||||||
|
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||||
|
|
||||||
|
export interface ILoadedRequest {
|
||||||
|
data: IFormData;
|
||||||
|
status: string;
|
||||||
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(raw: unknown): string {
|
||||||
|
return typeof raw === 'string' ? raw : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
|
||||||
|
function numberText(raw: unknown): string {
|
||||||
|
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
|
||||||
|
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
|
||||||
|
* Даты приёма/увольнения не хранятся — ''; e-mail пустой — у сотрудника нет учётки.
|
||||||
|
*/
|
||||||
|
function employeeFromItem(item: Record<string, unknown>): IEmployeeFullBaseRecord | undefined {
|
||||||
|
const id = text(item.EmployeeRecordId);
|
||||||
|
const fio = text(item.EmployeeFio);
|
||||||
|
if (!id && !fio) return undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fio,
|
||||||
|
iin: text(item.EmployeeIin),
|
||||||
|
code: text(item.EmployeeCode),
|
||||||
|
company: text(item.EmployeeCompany),
|
||||||
|
position: text(item.EmployeePosition),
|
||||||
|
department: text(item.EmployeeDepartment),
|
||||||
|
admissionDate: '',
|
||||||
|
dismissalDate: '',
|
||||||
|
email: text(item.EmployeeEmail)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MultiChoice → ключи видов. Подпись, которой нет в CHANGE_TYPE_LABELS (choice
|
||||||
|
* переименовали в SP, а код — нет), пропускаем с предупреждением в консоли:
|
||||||
|
* форма покажет «вид не выбран», и при отправке валидация попросит выбрать заново.
|
||||||
|
*/
|
||||||
|
function changeTypesFromItem(raw: unknown): ChangeType[] {
|
||||||
|
const labels = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
|
||||||
|
const types: ChangeType[] = [];
|
||||||
|
labels.forEach((label) => {
|
||||||
|
const type = changeTypeFromLabel(label);
|
||||||
|
if (type) {
|
||||||
|
types.push(type);
|
||||||
|
} else {
|
||||||
|
console.warn(`[ContractChange] Неизвестный вид изменения «${label}» — нет в CHANGE_TYPE_LABELS`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать заявку по id: модель формы + статус (правка / просмотр).
|
||||||
|
*
|
||||||
|
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||||
|
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую
|
||||||
|
* по url списка.
|
||||||
|
*/
|
||||||
|
export async function loadContractChangeRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||||
|
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||||
|
const item = (await list.items
|
||||||
|
.getById(itemId)
|
||||||
|
.select(
|
||||||
|
'Id',
|
||||||
|
'RequestStatus',
|
||||||
|
'RequestUniqueCode',
|
||||||
|
'EmployeeRecordId',
|
||||||
|
'EmployeeFio',
|
||||||
|
'EmployeeIin',
|
||||||
|
'EmployeeCode',
|
||||||
|
'EmployeeEmail',
|
||||||
|
'EmployeeCompany',
|
||||||
|
'EmployeePosition',
|
||||||
|
'EmployeeDepartment',
|
||||||
|
'ChangeTypes',
|
||||||
|
'SalaryIncreaseCurrent',
|
||||||
|
'SalaryIncreaseNew',
|
||||||
|
'SalaryIncreaseDate',
|
||||||
|
'SalaryIncreaseReason',
|
||||||
|
'SalaryIncreaseJustification',
|
||||||
|
'AllowanceReason',
|
||||||
|
'AllowanceStartDate',
|
||||||
|
'AllowanceEndDate',
|
||||||
|
'AllowanceIndefinite',
|
||||||
|
'AllowanceAmount',
|
||||||
|
'AllowanceUnit',
|
||||||
|
'AllowanceJustification',
|
||||||
|
'PositionTransferNewPosition',
|
||||||
|
'PositionTransferDate',
|
||||||
|
'PositionTransferReason',
|
||||||
|
'PositionTransferJustification'
|
||||||
|
)()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const data: IFormData = {
|
||||||
|
...EMPTY_DATA,
|
||||||
|
Employee: employeeFromItem(item),
|
||||||
|
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
|
||||||
|
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
|
||||||
|
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
|
||||||
|
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
|
||||||
|
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
|
||||||
|
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
|
||||||
|
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
|
||||||
|
AllowanceReason: text(item.AllowanceReason),
|
||||||
|
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
|
||||||
|
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
|
||||||
|
AllowanceIndefinite: item.AllowanceIndefinite === true,
|
||||||
|
AllowanceAmount: numberText(item.AllowanceAmount),
|
||||||
|
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
|
||||||
|
AllowanceJustification: text(item.AllowanceJustification),
|
||||||
|
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
|
||||||
|
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
|
||||||
|
PositionTransferReason: text(item.PositionTransferReason),
|
||||||
|
PositionTransferJustification: text(item.PositionTransferJustification)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: text(item.RequestStatus),
|
||||||
|
code: text(item.RequestUniqueCode)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
|
import { ChangeType } from './changeTypes';
|
||||||
|
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
|
||||||
|
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
|
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||||
|
*/
|
||||||
|
export const FORM_ID: RequestTypeId = 'hr-contract-change';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные формы «Изменение условий трудового договора» (ст. 33 ТК РК).
|
||||||
|
*
|
||||||
|
* Сотрудник — запись из Employee/FullBase, а не People: заявку подают и за
|
||||||
|
* рабочих без учётной записи M365. В списке SP запись лежит снимком в текстовых
|
||||||
|
* колонках Employee* (разборка/сборка — submit.ts/load.ts); даты приёма и
|
||||||
|
* увольнения не храним — после загрузки они ''.
|
||||||
|
*
|
||||||
|
* Поля под конкретные виды изменения добавляются по мере согласования с кадрами
|
||||||
|
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
|
||||||
|
*/
|
||||||
|
export interface IFormData {
|
||||||
|
/**
|
||||||
|
* Место работы сотрудника, где меняются условия ТД (человек + организация —
|
||||||
|
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
|
||||||
|
*/
|
||||||
|
Employee: IEmployeeFullBaseRecord | undefined;
|
||||||
|
/**
|
||||||
|
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
|
||||||
|
* задел под несколько, см. changeTypes.ts. [] — не выбран.
|
||||||
|
*/
|
||||||
|
ChangeTypes: ChangeType[];
|
||||||
|
|
||||||
|
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
|
||||||
|
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
|
||||||
|
SalaryIncreaseCurrent: string;
|
||||||
|
/** Новый оклад — как SalaryIncreaseCurrent. */
|
||||||
|
SalaryIncreaseNew: string;
|
||||||
|
/** С какой даты, ISO YYYY-MM-DD. */
|
||||||
|
SalaryIncreaseDate: string;
|
||||||
|
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
|
||||||
|
SalaryIncreaseReason: string;
|
||||||
|
/** Обоснование; при причине «Иное» — её текст. */
|
||||||
|
SalaryIncreaseJustification: string;
|
||||||
|
|
||||||
|
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
|
||||||
|
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
|
||||||
|
AllowanceReason: string;
|
||||||
|
/** С какой даты, ISO YYYY-MM-DD. */
|
||||||
|
AllowanceStartDate: string;
|
||||||
|
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
|
||||||
|
AllowanceEndDate: string;
|
||||||
|
/** Бессрочно — тогда даты окончания нет. */
|
||||||
|
AllowanceIndefinite: boolean;
|
||||||
|
/**
|
||||||
|
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
|
||||||
|
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
|
||||||
|
*/
|
||||||
|
AllowanceAmount: string;
|
||||||
|
AllowanceUnit: AllowanceUnit;
|
||||||
|
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
|
||||||
|
AllowanceJustification: string;
|
||||||
|
|
||||||
|
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
|
||||||
|
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
|
||||||
|
/** Новая должность — из справочника или вручную (PositionPicker). */
|
||||||
|
PositionTransferNewPosition: string;
|
||||||
|
/** С какой даты, ISO YYYY-MM-DD. */
|
||||||
|
PositionTransferDate: string;
|
||||||
|
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
|
||||||
|
PositionTransferReason: string;
|
||||||
|
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||||
|
PositionTransferJustification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (запросы к справочникам). */
|
||||||
|
export interface ISectionProps {
|
||||||
|
data: IFormData;
|
||||||
|
set: SetField;
|
||||||
|
context: WebPartContext;
|
||||||
|
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
|
||||||
|
attachments?: IAttachmentEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DATA: IFormData = {
|
||||||
|
Employee: undefined,
|
||||||
|
ChangeTypes: [],
|
||||||
|
SalaryIncreaseCurrent: '',
|
||||||
|
SalaryIncreaseNew: '',
|
||||||
|
SalaryIncreaseDate: '',
|
||||||
|
SalaryIncreaseReason: '',
|
||||||
|
SalaryIncreaseJustification: '',
|
||||||
|
AllowanceReason: '',
|
||||||
|
AllowanceStartDate: '',
|
||||||
|
AllowanceEndDate: '',
|
||||||
|
AllowanceIndefinite: false,
|
||||||
|
AllowanceAmount: '',
|
||||||
|
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
|
||||||
|
AllowanceJustification: '',
|
||||||
|
PositionTransferNewPosition: '',
|
||||||
|
PositionTransferDate: '',
|
||||||
|
PositionTransferReason: '',
|
||||||
|
PositionTransferJustification: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
|
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
|
||||||
|
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
|
||||||
|
/** Плейсхолдер для выбора вида изменения. */
|
||||||
|
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
|
||||||
|
/**
|
||||||
|
* Пояснение под сотрудником без учётной записи: approval-route резолвит маршрут
|
||||||
|
* по e-mail, а его нет — маршрут строится по заявителю (см. routeEmail в форме).
|
||||||
|
*/
|
||||||
|
export const ROUTE_BY_APPLICANT_NOTE =
|
||||||
|
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
|
||||||
|
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
|
||||||
|
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
|
||||||
|
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
|
||||||
|
*/
|
||||||
|
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
|
||||||
|
if (!person) return '';
|
||||||
|
return person.email || applicantEmail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"dataListTitle": "Изменение условий трудового договора — данные",
|
||||||
|
"columns": [
|
||||||
|
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||||
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
|
|
||||||
|
{ "internalName": "EmployeeRecordId", "title": "Сотрудник: id записи кадровой базы", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeFio", "title": "Сотрудник: Ф.И.О.", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeIin", "title": "Сотрудник: ИИН", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeCode", "title": "Сотрудник: табельный номер", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeEmail", "title": "Сотрудник: e-mail", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeCompany", "title": "Сотрудник: организация", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeePosition", "title": "Сотрудник: должность", "type": "Text" },
|
||||||
|
{ "internalName": "EmployeeDepartment", "title": "Сотрудник: подразделение", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "ChangeTypes", "title": "Вид изменения", "type": "Choice", "allowMultiple": true, "choices": [
|
||||||
|
"Повышение должностного оклада",
|
||||||
|
"Установление надбавки / доплаты",
|
||||||
|
"Перевод на другую должность (постоянный)",
|
||||||
|
"Перевод в другое подразделение",
|
||||||
|
"Изменение места работы (другая местность)",
|
||||||
|
"Продление срока трудового договора",
|
||||||
|
"Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника",
|
||||||
|
"Изменение трудовой функции (без смены должности)",
|
||||||
|
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
|
||||||
|
"Временный перевод при производственной необходимости / простое"
|
||||||
|
] },
|
||||||
|
|
||||||
|
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
|
||||||
|
"Успешное прохождение испытательного срока",
|
||||||
|
"Расширение функционала / зоны ответственности",
|
||||||
|
"Иное"
|
||||||
|
] },
|
||||||
|
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
|
||||||
|
"Надбавка: наставничество и обучение новых работников",
|
||||||
|
"Надбавка: разъездной характер работы",
|
||||||
|
"Доплата: совмещение должностей / расширение зоны обслуживания",
|
||||||
|
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
|
||||||
|
"Доплата: иное по решению первого руководителя"
|
||||||
|
] },
|
||||||
|
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
|
||||||
|
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
|
||||||
|
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
|
||||||
|
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
|
||||||
|
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
|
||||||
|
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
|
||||||
|
"Вакантная должность, соответствующая квалификации работника",
|
||||||
|
"Открытие нового объекта / проекта, перераспределение функций",
|
||||||
|
"Иное"
|
||||||
|
] },
|
||||||
|
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Пояснение под полем «Сотрудник» (маршрут по заявителю — см. ROUTE_BY_APPLICANT_NOTE).
|
||||||
|
.note {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import {
|
||||||
|
EmployeeFullBasePicker,
|
||||||
|
formatOrganizationLine,
|
||||||
|
personFromWorkplace,
|
||||||
|
workplaceKey
|
||||||
|
} from '../../../shared/dictionary';
|
||||||
|
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
|
||||||
|
import {
|
||||||
|
ISectionProps,
|
||||||
|
EMPLOYEE_PLACEHOLDER,
|
||||||
|
WORKPLACE_PLACEHOLDER,
|
||||||
|
CHANGE_TYPE_PLACEHOLDER,
|
||||||
|
ROUTE_BY_APPLICANT_NOTE
|
||||||
|
} from '../model';
|
||||||
|
import { IContractEmployee } from '../useContractEmployee';
|
||||||
|
import styles from './RequestSection.module.scss';
|
||||||
|
|
||||||
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
|
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
|
||||||
|
employeeControl?: IContractEmployee;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP = (): void => undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
|
||||||
|
* в какой организации (у совместителя их несколько) и что именно меняется.
|
||||||
|
* Поля конкретных видов — отдельными секциями (byType/).
|
||||||
|
*/
|
||||||
|
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
|
||||||
|
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
|
||||||
|
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
|
||||||
|
value: workplaceKey(w),
|
||||||
|
label: formatOrganizationLine(w.company, w.position, w.department)
|
||||||
|
}));
|
||||||
|
const hasNoAccount = !!person && !person.email;
|
||||||
|
|
||||||
|
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
|
||||||
|
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
|
||||||
|
// встанет MultiChoiceField, модель и SP уже готовы.
|
||||||
|
const selectedType = data.ChangeTypes[0] || '';
|
||||||
|
const handleTypeChange = (value: string): void => {
|
||||||
|
set('ChangeTypes', value ? [value as ChangeType] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
|
<EmployeeFullBasePicker
|
||||||
|
context={context}
|
||||||
|
id="Employee"
|
||||||
|
value={person}
|
||||||
|
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
|
||||||
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
|
||||||
|
</FormField>
|
||||||
|
{workplaceOptions.length > 0 && (
|
||||||
|
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
|
||||||
|
<Select
|
||||||
|
id="EmployeeWorkplace"
|
||||||
|
value={data.Employee ? workplaceKey(data.Employee) : ''}
|
||||||
|
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
|
||||||
|
options={workplaceOptions}
|
||||||
|
placeholder={WORKPLACE_PLACEHOLDER}
|
||||||
|
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
|
||||||
|
readOnly={workplaceOptions.length === 1 && !!data.Employee}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
|
||||||
|
<Select
|
||||||
|
id="ChangeTypes"
|
||||||
|
value={selectedType}
|
||||||
|
onChange={handleTypeChange}
|
||||||
|
options={CHANGE_TYPE_OPTIONS}
|
||||||
|
placeholder={CHANGE_TYPE_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
|
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
|
import { CHANGE_TYPE_LABELS } from './changeTypes';
|
||||||
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
|
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
|
||||||
|
*/
|
||||||
|
export function contractChangeApprovalService(listId?: string): ApprovalListService {
|
||||||
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
|
function buildTitle(employeeFio: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const date = `${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||||
|
return `Изменение условий ТД — ${employeeFio || 'сотрудник не выбран'} — ${date}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снимок сотрудника → текстовые колонки Employee* (см. provisioning.json).
|
||||||
|
* Не выбран (черновик) — все колонки очищаются null (так SharePoint REST очищает
|
||||||
|
* поле), а не остаются от прошлого выбора.
|
||||||
|
*/
|
||||||
|
function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
EmployeeRecordId: employee?.id || null,
|
||||||
|
EmployeeFio: employee?.fio || null,
|
||||||
|
EmployeeIin: employee?.iin || null,
|
||||||
|
EmployeeCode: employee?.code || null,
|
||||||
|
EmployeeEmail: employee?.email || null,
|
||||||
|
EmployeeCompany: employee?.company || null,
|
||||||
|
EmployeePosition: employee?.position || null,
|
||||||
|
EmployeeDepartment: employee?.department || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
|
||||||
|
function numberColumn(raw: string): number | undefined {
|
||||||
|
const amount = Number(raw);
|
||||||
|
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
|
||||||
|
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
|
||||||
|
*/
|
||||||
|
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
if (!data.ChangeTypes.includes('salary-increase')) {
|
||||||
|
return {
|
||||||
|
SalaryIncreaseCurrent: null,
|
||||||
|
SalaryIncreaseNew: null,
|
||||||
|
SalaryIncreaseDate: null,
|
||||||
|
SalaryIncreaseReason: null,
|
||||||
|
SalaryIncreaseJustification: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
|
||||||
|
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
|
||||||
|
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
|
||||||
|
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
|
||||||
|
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||||
|
function allowanceColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
if (!data.ChangeTypes.includes('allowance')) {
|
||||||
|
return {
|
||||||
|
AllowanceReason: null,
|
||||||
|
AllowanceStartDate: null,
|
||||||
|
AllowanceEndDate: null,
|
||||||
|
AllowanceIndefinite: null,
|
||||||
|
AllowanceAmount: null,
|
||||||
|
AllowanceUnit: null,
|
||||||
|
AllowanceJustification: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
AllowanceReason: data.AllowanceReason || null,
|
||||||
|
AllowanceStartDate: data.AllowanceStartDate || null,
|
||||||
|
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
|
||||||
|
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
|
||||||
|
AllowanceIndefinite: data.AllowanceIndefinite,
|
||||||
|
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
|
||||||
|
AllowanceUnit: data.AllowanceUnit,
|
||||||
|
AllowanceJustification: data.AllowanceJustification || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||||
|
function positionTransferColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
if (!data.ChangeTypes.includes('position-transfer')) {
|
||||||
|
return {
|
||||||
|
PositionTransferNewPosition: null,
|
||||||
|
PositionTransferDate: null,
|
||||||
|
PositionTransferReason: null,
|
||||||
|
PositionTransferJustification: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
|
||||||
|
PositionTransferDate: data.PositionTransferDate || null,
|
||||||
|
PositionTransferReason: data.PositionTransferReason || null,
|
||||||
|
PositionTransferJustification: data.PositionTransferJustification || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поля элемента списка данных из модели формы. */
|
||||||
|
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Title: buildTitle(data.Employee?.fio || ''),
|
||||||
|
RequestStatus: status,
|
||||||
|
...employeeColumns(data.Employee),
|
||||||
|
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
|
||||||
|
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
||||||
|
...salaryIncreaseColumns(data),
|
||||||
|
...allowanceColumns(data),
|
||||||
|
...positionTransferColumns(data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить заявку: содержимое + маршрут согласования.
|
||||||
|
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||||
|
* executors — фаза «Исполнение» маршрута из админки (пусто, если её нет).
|
||||||
|
*
|
||||||
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadContractChangeRequest); новая
|
||||||
|
* заявка всегда создаётся в текущей партиции.
|
||||||
|
*/
|
||||||
|
export async function saveContractChangeRequest(
|
||||||
|
existingId: number | undefined,
|
||||||
|
requestTypeId: number,
|
||||||
|
data: IFormData,
|
||||||
|
approvers: IApproverToWrite[],
|
||||||
|
executors: IApproverToWrite[],
|
||||||
|
status: string,
|
||||||
|
listId?: string
|
||||||
|
): Promise<ISaveResult> {
|
||||||
|
const item = buildItem(data, status);
|
||||||
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
|
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
IEmployeeFullBasePerson,
|
||||||
|
IEmployeeFullBaseRecord,
|
||||||
|
getEmployeeOrganizations,
|
||||||
|
groupEmployeesByPerson,
|
||||||
|
personFromWorkplace,
|
||||||
|
personKey,
|
||||||
|
searchActiveEmployees,
|
||||||
|
workplaceKey
|
||||||
|
} from '../../shared/dictionary';
|
||||||
|
|
||||||
|
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
|
||||||
|
const MAX_NAMESAKE_RECORDS = 60;
|
||||||
|
|
||||||
|
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
|
||||||
|
export interface IContractEmployee {
|
||||||
|
person: IEmployeeFullBasePerson | undefined;
|
||||||
|
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
|
||||||
|
/** key — workplaceKey выбранного места работы. */
|
||||||
|
onWorkplaceChange: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
|
||||||
|
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
|
||||||
|
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
|
||||||
|
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
|
||||||
|
*/
|
||||||
|
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
|
||||||
|
const organizations = await getEmployeeOrganizations(context, email);
|
||||||
|
const fio = organizations[0]?.fio || '';
|
||||||
|
if (!fio) return undefined;
|
||||||
|
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
|
||||||
|
const target = email.toLowerCase();
|
||||||
|
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
|
||||||
|
async function findPersonOfWorkplace(
|
||||||
|
context: WebPartContext,
|
||||||
|
record: IEmployeeFullBaseRecord
|
||||||
|
): Promise<IEmployeeFullBasePerson | undefined> {
|
||||||
|
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
|
||||||
|
return people.find((p) => p.key === personKey(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
|
||||||
|
* пикером, место работы — списком; одно место выбирается само.
|
||||||
|
*
|
||||||
|
* В заявке хранится только выбранное место работы (setEmployee); человек со
|
||||||
|
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
|
||||||
|
*/
|
||||||
|
export function useContractEmployee(
|
||||||
|
context: WebPartContext,
|
||||||
|
applicantEmail: string,
|
||||||
|
isNew: boolean,
|
||||||
|
employee: IEmployeeFullBaseRecord | undefined,
|
||||||
|
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
|
||||||
|
): IContractEmployee {
|
||||||
|
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
|
||||||
|
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
|
||||||
|
const isTouchedRef = useRef(false);
|
||||||
|
|
||||||
|
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
|
||||||
|
setPerson(next);
|
||||||
|
setEmployee(next ? singleWorkplace(next) : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Новая заявка — по умолчанию на себя.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || !applicantEmail) return;
|
||||||
|
let alive = true;
|
||||||
|
findPersonByEmail(context, applicantEmail)
|
||||||
|
.then((found) => {
|
||||||
|
if (alive && found && !isTouchedRef.current) applyPerson(found);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, applicantEmail, isNew]);
|
||||||
|
|
||||||
|
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
|
||||||
|
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restoreKey || !employee) return;
|
||||||
|
let alive = true;
|
||||||
|
findPersonOfWorkplace(context, employee)
|
||||||
|
.then((found) => {
|
||||||
|
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, restoreKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
person,
|
||||||
|
onPersonChange: (next) => {
|
||||||
|
isTouchedRef.current = true;
|
||||||
|
applyPerson(next);
|
||||||
|
},
|
||||||
|
onWorkplaceChange: (key) => {
|
||||||
|
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
approvalPhaseRows,
|
approvalPhaseRows,
|
||||||
@@ -218,16 +219,19 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
|
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
|
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
|
||||||
@@ -311,7 +315,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
|
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
|
||||||
// него нет, см. README формы) — значит увольнение подтверждено и пора
|
// него нет, см. README формы) — значит увольнение подтверждено и пора
|
||||||
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
|
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ExecutorsMatrix,
|
ExecutorsMatrix,
|
||||||
approvalPhaseRows,
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -117,16 +118,19 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +176,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { AttachmentList, FileDropzone, FormField } from '../../shared/ui';
|
|
||||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
|
||||||
import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories';
|
|
||||||
import styles from './HireAttachmentField.module.scss';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string;
|
|
||||||
editor: IAttachmentEditor;
|
|
||||||
editable: boolean;
|
|
||||||
/** Группа новых файлов в editor.pending. */
|
|
||||||
groupKey: string;
|
|
||||||
/** Категория в имени файла; не задана — «прочие» вложения без префикса. */
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */
|
|
||||||
export const HireAttachmentField: React.FC<Props> = ({ label, editor, editable, groupKey, category }) => {
|
|
||||||
const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items);
|
|
||||||
// В списке имена показаны без префикса категории — для удаления возвращаем его.
|
|
||||||
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField label={label} full>
|
|
||||||
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
|
|
||||||
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
|
|
||||||
{editable && (
|
|
||||||
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Alert, CollapseSection } from '../../shared/ui';
|
import { Alert, CollapseSection } from '../../shared/ui';
|
||||||
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
|
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField';
|
||||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
|
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
|
||||||
import { HireAttachmentField } from './HireAttachmentField';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
|
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
|
||||||
@@ -37,9 +37,16 @@ export const HireAttachments: React.FC<Props> = ({ editor, editable }) => {
|
|||||||
return (
|
return (
|
||||||
<CollapseSection title="Вложения" icon="upload">
|
<CollapseSection title="Вложения" icon="upload">
|
||||||
{editor.error && <Alert variant="err">{editor.error}</Alert>}
|
{editor.error && <Alert variant="err">{editor.error}</Alert>}
|
||||||
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
<AttachmentSlotField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
||||||
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
<AttachmentSlotField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
||||||
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
|
{showOther && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label="Прочие вложения"
|
||||||
|
groupKey={GROUP_OTHER}
|
||||||
|
knownCategories={[ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]}
|
||||||
|
{...fieldProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
isApprovedStatus,
|
isApprovedStatus,
|
||||||
@@ -283,13 +284,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
|
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавить согласующего/исполнителя в поданную заявку — то же право
|
// Добавить согласующего/исполнителя в поданную заявку — то же право
|
||||||
@@ -479,7 +483,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
// Шаг переназначен: активной стала строка делегата — обновляем
|
// Шаг переназначен: активной стала строка делегата — обновляем
|
||||||
|
|||||||
@@ -1,50 +1,24 @@
|
|||||||
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||||
|
import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
|
* Категории вложений заявки на прием. Механизм подписи префиксом в имени файла —
|
||||||
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
|
* общий (shared/attachments/attachmentCategories); здесь только категории этой
|
||||||
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
|
* формы. Общие функции реэкспортируются, чтобы импорты формы не менялись.
|
||||||
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
|
|
||||||
*/
|
*/
|
||||||
export const ATT_CATEGORY_ID = 'Удостоверение';
|
export const ATT_CATEGORY_ID = 'Удостоверение';
|
||||||
export const ATT_CATEGORY_RESUME = 'Резюме';
|
export const ATT_CATEGORY_RESUME = 'Резюме';
|
||||||
|
|
||||||
const SEP = ' — ';
|
export {
|
||||||
|
categoryPrefix,
|
||||||
/** Префикс имени файла для категории: «Удостоверение — ». */
|
prefixFiles,
|
||||||
export function categoryPrefix(category: string): string {
|
attachmentsOfCategory
|
||||||
return `${category}${SEP}`;
|
} from '../../shared/attachments/attachmentCategories';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Копии 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[] {
|
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
|
||||||
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
|
return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]);
|
||||||
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
|
|
||||||
return items.filter(
|
|
||||||
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isApprovedStatus,
|
isApprovedStatus,
|
||||||
resolveApproverOrgInfo,
|
resolveApproverOrgInfo,
|
||||||
@@ -204,20 +205,25 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
await saveAllFields();
|
await saveAllFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
|
||||||
const notify = (rowId?: number): void => {
|
const notifyTarget = requestTypeId
|
||||||
if (!requestTypeId) return;
|
? {
|
||||||
notifyActiveStep(
|
|
||||||
context,
|
|
||||||
approval,
|
|
||||||
itemId,
|
|
||||||
{
|
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
},
|
}
|
||||||
rowId
|
: undefined;
|
||||||
).catch(() => undefined);
|
|
||||||
|
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||||
|
const notify = (rowId?: number): void => {
|
||||||
|
if (!notifyTarget) return;
|
||||||
|
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
const notifyInitiator = (newStatus: string): void => {
|
||||||
|
if (!notifyTarget) return;
|
||||||
|
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||||
@@ -467,6 +473,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify();
|
||||||
|
notifyInitiator(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
isApprovedStatus,
|
isApprovedStatus,
|
||||||
@@ -169,16 +170,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -253,7 +257,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ExecutorsMatrix,
|
ExecutorsMatrix,
|
||||||
approvalPhaseRows,
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus
|
isEditableStatus
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -141,13 +142,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
|
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -205,7 +209,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
usePhasedRoute
|
usePhasedRoute
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
@@ -110,14 +111,17 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
@@ -175,7 +179,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE
|
||||||
@@ -102,20 +103,25 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
approval.getJobTitle(l)
|
approval.getJobTitle(l)
|
||||||
);
|
);
|
||||||
|
|
||||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
|
||||||
const notify = (rowId?: number): void => {
|
const notifyTarget = requestTypeId
|
||||||
if (!requestTypeId) return;
|
? {
|
||||||
notifyActiveStep(
|
|
||||||
context,
|
|
||||||
approval,
|
|
||||||
itemId,
|
|
||||||
{
|
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
},
|
}
|
||||||
rowId
|
: undefined;
|
||||||
).catch(() => undefined);
|
|
||||||
|
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||||
|
const notify = (rowId?: number): void => {
|
||||||
|
if (!notifyTarget) return;
|
||||||
|
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
const notifyInitiator = (newStatus: string): void => {
|
||||||
|
if (!notifyTarget) return;
|
||||||
|
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const executorEditing = useExecutorEditing({
|
const executorEditing = useExecutorEditing({
|
||||||
@@ -246,6 +252,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
notify();
|
notify();
|
||||||
|
notifyInitiator(newStatus);
|
||||||
|
|
||||||
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
|
// Дошла до исполнения / завершилась / отклонена — карточка в Planner
|
||||||
// (повторно не создаётся, см. plannerHandoff.ts).
|
// (повторно не создаётся, см. plannerHandoff.ts).
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
APPROVAL_PHASE
|
APPROVAL_PHASE
|
||||||
@@ -144,13 +145,16 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
|
|
||||||
const sectionProps = { data, set, context, printOrgIndex };
|
const sectionProps = { data, set, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -222,7 +226,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useViewExecutors,
|
useViewExecutors,
|
||||||
approvalPhaseRows,
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
@@ -125,16 +126,19 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -182,7 +186,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
|
- **Даты окончания нет**: отпуск задаётся началом и количеством дней, чтобы не считать
|
||||||
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
|
праздники и выходные. Колонка `EndDate` не используется: из провижининга, типа контента и представления она убрана (само системное поле SharePoint `FromBaseType` удалить нельзя).
|
||||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||||
|
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
|
||||||
|
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
|
||||||
|
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
|
||||||
|
|
||||||
## Подписание в ЭДО
|
## Подписание в ЭДО
|
||||||
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
|
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
RequestSection (сотрудник · должность · дата начала · количество дней)
|
RequestSection (сотрудник · должность · дата начала · количество дней · замещающий · комментарий)
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `hr-vacation`
|
- rptype: `hr-vacation`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ExecutorsMatrix,
|
ExecutorsMatrix,
|
||||||
approvalPhaseRows,
|
approvalPhaseRows,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
|
import { completesApprovalPhase } from '../../shared/approval/models/statuses';
|
||||||
@@ -143,16 +144,19 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
|
|
||||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||||
// весь UI ради необязательного оповещения.
|
// весь UI ради необязательного оповещения.
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
|
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
|
||||||
@@ -263,7 +267,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
onDecided={(newStatus) => {
|
onDecided={(newStatus) => {
|
||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify(newStatus);
|
||||||
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
|
return VACATION_EDO_SIGNING_ENABLED && sentBeforeDecision.current ?'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
|
|||||||
'DepartmentId',
|
'DepartmentId',
|
||||||
'Project',
|
'Project',
|
||||||
'ProjectId',
|
'ProjectId',
|
||||||
|
'VacationComment',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/Title',
|
'Employee/Title',
|
||||||
@@ -58,7 +59,8 @@ export async function loadVacationRequest(itemId: number, listId?: string): Prom
|
|||||||
Department: (item.Department as string) || '',
|
Department: (item.Department as string) || '',
|
||||||
DepartmentId: (item.DepartmentId as string) || '',
|
DepartmentId: (item.DepartmentId as string) || '',
|
||||||
Project: (item.Project as string) || '',
|
Project: (item.Project as string) || '',
|
||||||
ProjectId: (item.ProjectId as string) || ''
|
ProjectId: (item.ProjectId as string) || '',
|
||||||
|
VacationComment: (item.VacationComment as string) || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface IFormData {
|
|||||||
Project: string;
|
Project: string;
|
||||||
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
||||||
ProjectId: string;
|
ProjectId: string;
|
||||||
|
/** Комментарий инициатора к заявке — необязательный, для согласующих. */
|
||||||
|
VacationComment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -73,7 +75,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Department: '',
|
Department: '',
|
||||||
DepartmentId: '',
|
DepartmentId: '',
|
||||||
Project: '',
|
Project: '',
|
||||||
ProjectId: ''
|
ProjectId: '',
|
||||||
|
VacationComment: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
|
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
|
||||||
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
|
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
|
||||||
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
||||||
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }
|
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
||||||
|
{ "internalName": "VacationComment", "title": "Комментарий", "type": "Note" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Обёртка поля только ради класса rp-no-print: contents убирает её из раскладки,
|
||||||
|
// поэтому поле остаётся ячейкой FormGrid и сохраняет растяжку `full`.
|
||||||
|
.noPrintField {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, TextArea, PeopleField, OrganizationTable, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS, MAX_DAYS } from '../model';
|
||||||
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
import { isoDatePlusDays } from '../../../shared/dateUtils';
|
||||||
|
import styles from './RequestSection.module.scss';
|
||||||
|
|
||||||
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */
|
||||||
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => (
|
export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange, printOrgIndex }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
return (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
@@ -56,5 +60,20 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
placeholder={SUBSTITUTE_PLACEHOLDER}
|
placeholder={SUBSTITUTE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать.
|
||||||
|
В печатное заявление комментарий не входит — он для согласующих, не для кадров. */}
|
||||||
|
{(!readOnly || data.VacationComment.trim()) && (
|
||||||
|
<div className={`${styles.noPrintField} rp-no-print`}>
|
||||||
|
<FormField label="Комментарий" htmlFor="VacationComment" full>
|
||||||
|
<TextArea
|
||||||
|
id="VacationComment"
|
||||||
|
value={data.VacationComment}
|
||||||
|
onChange={(v) => set('VacationComment', v)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ function buildItem(
|
|||||||
Department: data.Department || null,
|
Department: data.Department || null,
|
||||||
DepartmentId: data.DepartmentId || null,
|
DepartmentId: data.DepartmentId || null,
|
||||||
Project: data.Project || null,
|
Project: data.Project || null,
|
||||||
ProjectId: data.ProjectId || null
|
ProjectId: data.ProjectId || null,
|
||||||
|
VacationComment: data.VacationComment.trim() || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
useMatrixEditor,
|
useMatrixEditor,
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
|
notifyInitiatorOfReturn,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
APPROVAL_PHASE,
|
APPROVAL_PHASE,
|
||||||
@@ -224,13 +225,16 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
const sectionProps = { data, set, context, printOrgIndex };
|
const sectionProps = { data, set, context, printOrgIndex };
|
||||||
const canEditAnything = canEditAll || canEditReviewFields;
|
const canEditAnything = canEditAll || canEditReviewFields;
|
||||||
|
|
||||||
const notify = (): void => {
|
const notify = (newStatus?: string): void => {
|
||||||
if (!requestTypeId) return;
|
if (!requestTypeId) return;
|
||||||
notifyActiveStep(context, approval, itemId, {
|
const target = {
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||||
}).catch(() => undefined);
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -316,7 +320,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||||
notify();
|
notify(newStatus);
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
// Шаг переназначен: активной стала строка делегата — обновляем
|
// Шаг переназначен: активной стала строка делегата — обновляем
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
|
|||||||
),
|
),
|
||||||
'wh-concrete-request': React.lazy(
|
'wh-concrete-request': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
|
||||||
|
),
|
||||||
|
'hr-contract-change': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'form-contract-change' */ './ContractChange/ContractChangeForm')
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
|
|||||||
),
|
),
|
||||||
'wh-concrete-request': React.lazy(
|
'wh-concrete-request': React.lazy(
|
||||||
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
|
||||||
|
),
|
||||||
|
'hr-contract-change': React.lazy(
|
||||||
|
() => import(/* webpackChunkName: 'view-contract-change' */ './ContractChange/ContractChangeView')
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
|
|||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { notifyActiveStep, postToFlow } from './notify.service';
|
export { notifyActiveStep, notifyInitiatorOfReturn, postToFlow } from './notify.service';
|
||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { AadHttpClient } from '@microsoft/sp-http';
|
import { AadHttpClient } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
||||||
|
import { getCreatedBy } from '../createdBy';
|
||||||
import { ApprovalListService } from './approvalList.service';
|
import { ApprovalListService } from './approvalList.service';
|
||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
|
import { REQUEST_STATUS } from './models/statuses';
|
||||||
import { pickRowsToNotify } from './notifyTargets';
|
import { pickRowsToNotify } from './notifyTargets';
|
||||||
|
|
||||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||||
@@ -42,11 +44,17 @@ export async function postToFlow(context: WebPartContext, flowUrl: string, body:
|
|||||||
// живому человеку лучше не слать».
|
// живому человеку лучше не слать».
|
||||||
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
|
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
|
||||||
if (!getNotificationsEnabled()) return false;
|
if (!getNotificationsEnabled()) return false;
|
||||||
|
if (!getNotifyOnlyTo()) return true;
|
||||||
|
return isRecipientAllowed(await approval.resolveUserEmail(approverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// То же ограничение notifyOnlyTo, когда адрес получателя уже известен.
|
||||||
|
function isRecipientAllowed(email: string): boolean {
|
||||||
|
if (!getNotificationsEnabled()) return false;
|
||||||
|
|
||||||
const onlyTo = getNotifyOnlyTo();
|
const onlyTo = getNotifyOnlyTo();
|
||||||
if (!onlyTo) return true;
|
if (!onlyTo) return true;
|
||||||
|
|
||||||
const email = await approval.resolveUserEmail(approverId);
|
|
||||||
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
|
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
|
||||||
|
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -109,3 +117,51 @@ export async function notifyActiveStep(
|
|||||||
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
|
pickRowsToNotify(rows, rowId).map((row) => notifyRow(context, approval, requestId, target, row))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Решения, после которых заявка уходит обратно к инициатору и ему нужно письмо.
|
||||||
|
const RETURNED_TO_INITIATOR_STATUSES: string[] = [REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
|
||||||
|
|
||||||
|
// Сообщить инициатору, что заявку отклонили или вернули на доработку. Вызывать
|
||||||
|
// в onDecided с новым статусом заявки — при любом другом статусе ничего не делает.
|
||||||
|
//
|
||||||
|
// Поток тот же, что у notifyActiveStep: у него есть ветки «Отклонено» и «Возврат
|
||||||
|
// на доработку», в которых он пишет не согласующему строки, а автору заявки.
|
||||||
|
// notifyActiveStep до них не доходит — после такого решения активных строк нет.
|
||||||
|
//
|
||||||
|
// Строка — та, по которой решил ТЕКУЩИЙ пользователь: он только что принял
|
||||||
|
// решение. Искать «любую отклонённую» нельзя — на параллельном шаге второй
|
||||||
|
// участник может решить по уже отклонённой заявке, и в письме оказался бы чужой
|
||||||
|
// комментарий.
|
||||||
|
//
|
||||||
|
// Некритично, как и notifyActiveStep: ошибки глотаем.
|
||||||
|
export async function notifyInitiatorOfReturn(
|
||||||
|
context: WebPartContext,
|
||||||
|
approval: ApprovalListService,
|
||||||
|
requestId: number,
|
||||||
|
target: INotifyTarget,
|
||||||
|
requestStatus: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (RETURNED_TO_INITIATOR_STATUSES.indexOf(requestStatus) === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [rows, userId, initiator] = await Promise.all([
|
||||||
|
approval.getForRequest(requestId),
|
||||||
|
approval.getCurrentUserId(),
|
||||||
|
getCreatedBy(target.dataListName, requestId),
|
||||||
|
]);
|
||||||
|
const decidedRow = rows.filter((r) => r.status === requestStatus && r.approverId === userId)[0];
|
||||||
|
if (!decidedRow || !isRecipientAllowed(initiator?.email || '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await postToFlow(context, FLOW_NOTIFY_URL, {
|
||||||
|
RequestTypeId: target.requestTypeId,
|
||||||
|
RowID: decidedRow.id,
|
||||||
|
RequestID: requestId,
|
||||||
|
ListName: target.dataListName,
|
||||||
|
ApproveListName: target.approvalListName,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// не критично — просто не отправили уведомление
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Сколько строк комментария видно до «Показать полностью». Три — короткие
|
||||||
|
// пометки («Вместо Иванова») видны целиком, а длинная причина отказа не
|
||||||
|
// растягивает таблицу маршрута на пол-экрана.
|
||||||
|
$comment-visible-lines: 3;
|
||||||
|
|
||||||
|
// Высота первой строки текста ($fs-sm × line-height 1.45 ≈ 19px): кавычка
|
||||||
|
// стоит вровень с ней, а не сползает вниз из-за своего крупного кегля.
|
||||||
|
$quote-line-height: 19px;
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $sp-2;
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-left: 3px solid;
|
||||||
|
border-radius: 0 $r-sm $r-sm 0;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
|
||||||
|
// спокойно, как остальная таблица, а не кричать.
|
||||||
|
.neutral {
|
||||||
|
border-left-color: $navy-600;
|
||||||
|
background: $navy-50;
|
||||||
|
|
||||||
|
.quote { color: $navy-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
border-left-color: $green-600;
|
||||||
|
background: $green-50;
|
||||||
|
|
||||||
|
.quote { color: $green-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
border-left-color: $red-500;
|
||||||
|
background: $red-50;
|
||||||
|
|
||||||
|
.quote { color: $red-500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
border-left-color: $gold-500;
|
||||||
|
background: $gold-50;
|
||||||
|
|
||||||
|
.quote { color: $gold-600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: $quote-line-height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; // иначе flex-элемент не сужается и длинный текст распирает таблицу
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
white-space: pre-wrap; // сохранить переносы строк из комментария
|
||||||
|
overflow-wrap: anywhere; // длинная ссылка без пробелов не распирает ячейку
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: $comment-visible-lines;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: $fs-label;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $navy-700;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $gold-600;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На бумаге кнопку не нажать — печатаем комментарий целиком.
|
||||||
|
@media print {
|
||||||
|
.clamped {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ApprovalComment.module.scss';
|
||||||
|
import { REQUEST_STATUS } from '../models/statuses';
|
||||||
|
import { useTextOverflow } from './useTextOverflow';
|
||||||
|
|
||||||
|
type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral';
|
||||||
|
|
||||||
|
// Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого
|
||||||
|
// взгляда, ещё до того, как глаз дойдёт до колонки «Статус».
|
||||||
|
const TONE_BY_STATUS: Record<string, CommentTone> = {
|
||||||
|
[REQUEST_STATUS.approved]: 'positive',
|
||||||
|
[REQUEST_STATUS.executed]: 'positive',
|
||||||
|
[REQUEST_STATUS.rejected]: 'negative',
|
||||||
|
[REQUEST_STATUS.returned]: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_CLASS: Record<CommentTone, string> = {
|
||||||
|
positive: styles.positive,
|
||||||
|
negative: styles.negative,
|
||||||
|
warning: styles.warning,
|
||||||
|
neutral: styles.neutral,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
expand: 'Показать полностью',
|
||||||
|
collapse: 'Свернуть',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IApprovalCommentProps {
|
||||||
|
/** Текст комментария к решению на шаге. */
|
||||||
|
text: string;
|
||||||
|
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
|
||||||
|
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
|
||||||
|
*/
|
||||||
|
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
const textRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isClamped = useTextOverflow(textRef, text);
|
||||||
|
|
||||||
|
const tone = (status && TONE_BY_STATUS[status]) || 'neutral';
|
||||||
|
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
|
||||||
|
// «Свернуть» при этом всё равно нужна.
|
||||||
|
const showToggle = isExpanded || isClamped;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.comment} ${TONE_CLASS[tone]}`}>
|
||||||
|
<span className={styles.quote} aria-hidden="true">
|
||||||
|
“
|
||||||
|
</span>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div ref={textRef} className={isExpanded ? styles.text : `${styles.text} ${styles.clamped}`}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
{showToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggle}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{isExpanded ? TEXT.collapse : TEXT.expand}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,9 +7,16 @@ import { IMatrixRow } from '../models/IMatrixRow';
|
|||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
import { UserPicker } from './UserPicker';
|
import { UserPicker } from './UserPicker';
|
||||||
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
import { ReplaceRowPanel } from './ReplaceRowPanel';
|
||||||
|
import { ApprovalComment } from './ApprovalComment';
|
||||||
import { useFlipRows } from './useFlipRows';
|
import { useFlipRows } from './useFlipRows';
|
||||||
import { StepMoveDirection } from '../queuedStepMove';
|
import { StepMoveDirection } from '../queuedStepMove';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ <tr> с комментарием участника. Своя строка — свой ключ: анимация
|
||||||
|
* перестановки (useFlipRows) должна везти комментарий вместе с его участником.
|
||||||
|
*/
|
||||||
|
const commentRowKey = (rowKey: string): string => `${rowKey}:comment`;
|
||||||
|
|
||||||
export interface IApprovalMatrixProps {
|
export interface IApprovalMatrixProps {
|
||||||
editor: IUseMatrixEditor;
|
editor: IUseMatrixEditor;
|
||||||
search: (query: string) => Promise<ISiteUser[]>;
|
search: (query: string) => Promise<ISiteUser[]>;
|
||||||
@@ -114,10 +121,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
|
|
||||||
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
// Строка → шаг, которому она принадлежит. Нужно, чтобы отрисовать ячейку
|
||||||
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
// «Шаг» один раз на весь шаг (rowSpan) и понять, можно ли двигать его выше/ниже.
|
||||||
const blockByRow: Array<{ index: number; start: number; size: number }> = [];
|
// size — сколько участников в шаге;
|
||||||
|
// span — сколько <tr> он занимает в таблице: у участника с комментарием
|
||||||
|
// под его строкой идёт ещё одна (см. ApprovalComment), и объединённые
|
||||||
|
// ячейки «Шаг» и стрелок обязаны её накрыть, иначе таблица съедет.
|
||||||
|
const blockByRow: Array<{ index: number; start: number; size: number; span: number }> = [];
|
||||||
blocks.forEach((b, bi) => {
|
blocks.forEach((b, bi) => {
|
||||||
|
const size = b.end - b.start + 1;
|
||||||
|
const commentCount = rows.slice(b.start, b.end + 1).filter((r) => !!r.comment).length;
|
||||||
for (let i = b.start; i <= b.end; i += 1) {
|
for (let i = b.start; i <= b.end; i += 1) {
|
||||||
blockByRow[i] = { index: bi, start: b.start, size: b.end - b.start + 1 };
|
blockByRow[i] = { index: bi, start: b.start, size, span: size + commentCount };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +152,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const b = blockByRow[rowIndex];
|
const b = blockByRow[rowIndex];
|
||||||
setActiveKeys(rowKeys.slice(b.start, b.start + b.size));
|
// Подсвечиваем и поднимаем шаг вместе со строками комментариев его участников.
|
||||||
|
const blockKeys: string[] = [];
|
||||||
|
for (let i = b.start; i < b.start + b.size; i += 1) {
|
||||||
|
blockKeys.push(rowKeys[i]);
|
||||||
|
if (rows[i].comment) blockKeys.push(commentRowKey(rowKeys[i]));
|
||||||
|
}
|
||||||
|
setActiveKeys(blockKeys);
|
||||||
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
if (direction === 'up') editor.moveBlockUp(rowIndex);
|
||||||
else editor.moveBlockDown(rowIndex);
|
else editor.moveBlockDown(rowIndex);
|
||||||
};
|
};
|
||||||
@@ -190,8 +209,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
|
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
|
// Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
|
||||||
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
|
// стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
|
||||||
|
// сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка.
|
||||||
|
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
|
||||||
|
const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
|
||||||
|
const hasActionsCol = editable || hasRemovable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.matrixWrap}>
|
<div className={styles.matrixWrap}>
|
||||||
@@ -223,21 +246,24 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
{hasDates && <th>Дата</th>}
|
{hasDates && <th>Дата</th>}
|
||||||
{hasSla && <th>SLA</th>}
|
{hasSla && <th>SLA</th>}
|
||||||
{showMoveCol && <th />}
|
{showMoveCol && <th />}
|
||||||
{(editable || hasRemovable) && <th />}
|
{hasActionsCol && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, i) => {
|
{rows.map((r, i) => {
|
||||||
const block = blockByRow[i];
|
const block = blockByRow[i];
|
||||||
const isBlockStart = block.start === i;
|
const isBlockStart = block.start === i;
|
||||||
|
const isParallel = block.size > 1;
|
||||||
|
const rowClassNames: string[] = [];
|
||||||
|
if (isParallel) rowClassNames.push(styles.parallelRow);
|
||||||
|
if (r.comment) rowClassNames.push(styles.rowWithComment);
|
||||||
|
const commentClassNames: string[] = [styles.commentRow];
|
||||||
|
if (isParallel) commentClassNames.push(styles.parallelRow);
|
||||||
return (
|
return (
|
||||||
<tr
|
<React.Fragment key={rowKeys[i]}>
|
||||||
key={rowKeys[i]}
|
<tr ref={registerRow(rowKeys[i])} className={rowClassNames.join(' ') || undefined}>
|
||||||
ref={registerRow(rowKeys[i])}
|
|
||||||
className={block.size > 1 ? styles.parallelRow : undefined}
|
|
||||||
>
|
|
||||||
{isBlockStart && (
|
{isBlockStart && (
|
||||||
<td className={styles.matrixStepCol} rowSpan={block.size}>
|
<td className={styles.matrixStepCol} rowSpan={block.span}>
|
||||||
<span className={styles.stepNo}>{r.order}</span>
|
<span className={styles.stepNo}>{r.order}</span>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -255,22 +281,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
<span className={styles.warningInline}>{r.warning}</span>
|
<span className={styles.warningInline}>{r.warning}</span>
|
||||||
)}
|
)}
|
||||||
{/* Доп. участник шага, добавленный переназначением — помечаем,
|
{/* Доп. участник шага, добавленный переназначением — помечаем,
|
||||||
кто его добавил. Пусто у обычных строк маршрута. Как и
|
кто его добавил. Пусто у обычных строк маршрута. */}
|
||||||
комментарий — inline под именем, чтобы не ломать rowSpan
|
|
||||||
колонки «Шаг» у параллельных шагов. */}
|
|
||||||
{r.addedBy && (
|
{r.addedBy && (
|
||||||
<span className={styles.addedByInline}>
|
<span className={styles.addedByInline}>
|
||||||
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
|
<span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
|
||||||
{r.addedBy}
|
{r.addedBy}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Комментарий решения — мелким текстом под именем (не отдельной
|
|
||||||
строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
|
|
||||||
{r.comment && (
|
|
||||||
<span className={styles.commentInline}>
|
|
||||||
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Должность">{r.position}</td>
|
<td data-label="Должность">{r.position}</td>
|
||||||
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
|
{hasDepartment && <td data-label="Отдел">{r.department || '—'}</td>}
|
||||||
@@ -292,7 +309,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
{/* Перемещение действует на ШАГ целиком (параллельные строки
|
||||||
переезжают вместе): одна пара стрелок на весь шаг. */}
|
переезжают вместе): одна пара стрелок на весь шаг. */}
|
||||||
{showMoveCol && isBlockStart && (
|
{showMoveCol && isBlockStart && (
|
||||||
<td className={styles.matrixMoveCol} rowSpan={block.size}>
|
<td className={styles.matrixMoveCol} rowSpan={block.span}>
|
||||||
{/* В поданной заявке у шагов, которые двигать нельзя совсем
|
{/* В поданной заявке у шагов, которые двигать нельзя совсем
|
||||||
(активный, пройденный), стрелок нет — иначе вся колонка
|
(активный, пройденный), стрелок нет — иначе вся колонка
|
||||||
из неактивных кнопок только шумит. */}
|
из неактивных кнопок только шумит. */}
|
||||||
@@ -320,7 +337,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{(editable || hasRemovable) && (
|
{hasActionsCol && (
|
||||||
<td className={styles.matrixActions}>
|
<td className={styles.matrixActions}>
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
@@ -360,6 +377,19 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/* Комментарий — отдельной строкой во всю ширину под участником:
|
||||||
|
в узкой колонке «Согласующий» текст ломался по слову на строку.
|
||||||
|
Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan
|
||||||
|
(см. block.span); колонка действий — пустая ячейка. */}
|
||||||
|
{r.comment && (
|
||||||
|
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
|
||||||
|
<td colSpan={commentColSpan} className={styles.commentCell}>
|
||||||
|
<ApprovalComment text={r.comment} status={r.status} />
|
||||||
|
</td>
|
||||||
|
{hasActionsCol && <td />}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -157,20 +157,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Комментарий решения — мелким текстом под именем согласующего (в той же ячейке,
|
// Строка участника, под которой идёт его комментарий (см. ApprovalComment):
|
||||||
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных).
|
// разделитель между ними убираем, иначе комментарий читается как отдельная
|
||||||
.commentInline {
|
// запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь
|
||||||
display: block;
|
// шаг (rowSpan), и их нижняя граница — это граница всего шага.
|
||||||
margin-top: 3px;
|
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) {
|
||||||
font-size: $fs-caption;
|
border-bottom-color: transparent;
|
||||||
color: $gray-500;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-wrap; // сохранить переносы строк из комментария
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentLabel {
|
// Отступ сверху убран: комментарий прилегает к строке своего участника.
|
||||||
color: $gray-400;
|
// Двойной класс — чтобы перебить padding из `.matrix td`.
|
||||||
font-weight: 500;
|
.matrix .commentCell {
|
||||||
|
padding: 0 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
|
// Пометка «доп. согласующий, добавил: …» — под именем согласующего. Золотой
|
||||||
@@ -197,7 +195,7 @@
|
|||||||
|
|
||||||
// Причина, по которой approval-route не назначил человека на шаг (см.
|
// Причина, по которой approval-route не назначил человека на шаг (см.
|
||||||
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
|
// ApprovalMatrix.tsx/r.warning) — красным, как настоящая проблема маршрута
|
||||||
// (в отличие от нейтрального addedByInline/commentInline): пустая строка
|
// (в отличие от нейтрального addedByInline): пустая строка
|
||||||
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
|
// «Согласующий» без этого текста выглядит как баг, а не как «маршрут просит
|
||||||
// вмешательства администратора».
|
// вмешательства администратора».
|
||||||
.warningInline {
|
.warningInline {
|
||||||
@@ -371,12 +369,30 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Комментарий под именем — на всю ширину ячейки (своя строка).
|
|
||||||
.commentInline,
|
|
||||||
.addedByInline {
|
.addedByInline {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Карточка участника и его комментарий — одна карточка: без линии и
|
||||||
|
// отступа между ними.
|
||||||
|
.matrix tr.rowWithComment {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix tr.commentRow {
|
||||||
|
padding-top: $sp-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentCell > * {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
|
||||||
|
.commentRow > td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.matrixActions,
|
.matrixActions,
|
||||||
.matrixMoveCol {
|
.matrixMoveCol {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
/** Расхождение меньше этого — округление субпикселей, а не обрезанный текст. */
|
||||||
|
const OVERFLOW_TOLERANCE_PX = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрезан ли текст элемента по высоте (например, CSS line-clamp).
|
||||||
|
*
|
||||||
|
* Заранее по длине строки это не понять: сколько строк займёт текст, зависит от
|
||||||
|
* ширины колонки, а она меняется вместе с окном и набором колонок таблицы. Поэтому
|
||||||
|
* меряем реальную раскладку и перемеряем при каждом изменении размера элемента.
|
||||||
|
*/
|
||||||
|
export function useTextOverflow<T extends HTMLElement>(ref: React.RefObject<T>, text: string): boolean {
|
||||||
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||||
|
|
||||||
|
// useLayoutEffect, а не useEffect: кнопка «Показать полностью» должна появиться
|
||||||
|
// в том же кадре, что и текст, иначе строка таблицы дёрнется по высоте.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
|
||||||
|
const check = (): void => setIsOverflowing(el.scrollHeight - el.clientHeight > OVERFLOW_TOLERANCE_PX);
|
||||||
|
check();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(check);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ref, text]);
|
||||||
|
|
||||||
|
return isOverflowing;
|
||||||
|
}
|
||||||
+1
@@ -1,5 +1,6 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Прочерк вместо пустого слота в режиме чтения.
|
||||||
.empty {
|
.empty {
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { AttachmentList, FileDropzone, FormField } from '../ui';
|
||||||
|
import { IAttachmentEditor } from './useAttachmentEditor';
|
||||||
|
import { attachmentsOfCategory, attachmentsWithoutCategories, categoryPrefix } from './attachmentCategories';
|
||||||
|
import styles from './AttachmentSlotField.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Подпись поля — что за документ. */
|
||||||
|
label: string;
|
||||||
|
editor: IAttachmentEditor;
|
||||||
|
/** Можно добавлять и удалять файлы (форма подачи / согласующий с полным правом). */
|
||||||
|
editable: boolean;
|
||||||
|
/** Группа новых файлов в editor.pending. */
|
||||||
|
groupKey: string;
|
||||||
|
/** Категория в имени файла (см. attachmentCategories); не задана — «прочие» вложения. */
|
||||||
|
category?: string;
|
||||||
|
/** Для «прочих» (category не задана): категории формы, файлы которых сюда НЕ попадают. */
|
||||||
|
knownCategories?: string[];
|
||||||
|
/** Звёздочка «обязательно» у подписи; саму проверку делает форма. */
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле одного подписанного слота вложений: уже прикреплённые файлы + (в правке)
|
||||||
|
* выбор новых. Новые файлы копятся в editor.pending[groupKey] и загружаются
|
||||||
|
* формой при сохранении (editor.uploadPending), с префиксом категории в имени.
|
||||||
|
*/
|
||||||
|
export const AttachmentSlotField: React.FC<Props> = ({
|
||||||
|
label,
|
||||||
|
editor,
|
||||||
|
editable,
|
||||||
|
groupKey,
|
||||||
|
category,
|
||||||
|
knownCategories = [],
|
||||||
|
required
|
||||||
|
}) => {
|
||||||
|
const items = category
|
||||||
|
? attachmentsOfCategory(editor.items, category)
|
||||||
|
: attachmentsWithoutCategories(editor.items, knownCategories);
|
||||||
|
// В списке имена показаны без префикса категории — для удаления возвращаем его.
|
||||||
|
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField label={label} required={required} full>
|
||||||
|
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
|
||||||
|
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
|
||||||
|
{editable && (
|
||||||
|
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
categoryPrefix,
|
||||||
|
attachmentsOfCategory,
|
||||||
|
attachmentsWithoutCategories
|
||||||
|
} from './attachmentCategories';
|
||||||
|
|
||||||
|
const file = (fileName: string): { fileName: string; url: string } => ({ fileName, url: `/att/${fileName}` });
|
||||||
|
|
||||||
|
describe('attachmentCategories', () => {
|
||||||
|
const items = [file('Резюме — cv.pdf'), file('Удостоверение — id.jpg'), file('старый.docx')];
|
||||||
|
|
||||||
|
it('префикс — подпись и длинное тире с пробелами', () => {
|
||||||
|
expect(categoryPrefix('Резюме')).toBe('Резюме — ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('файлы категории — с обрезанным префиксом и прежней ссылкой', () => {
|
||||||
|
expect(attachmentsOfCategory(items, 'Резюме')).toEqual([{ fileName: 'cv.pdf', url: '/att/Резюме — cv.pdf' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без категорий — только файлы, не попавшие ни в одну из известных', () => {
|
||||||
|
expect(attachmentsWithoutCategories(items, ['Резюме', 'Удостоверение'])).toEqual([file('старый.docx')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без известных категорий «прочие» — это все файлы', () => {
|
||||||
|
expect(attachmentsWithoutCategories(items, [])).toEqual(items);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IAttachmentInfo } from './attachments.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписанные вложения («категории»). Вложения SharePoint — плоская коллекция
|
||||||
|
* файлов на элементе списка, подписи у них нет. Поэтому подпись (что за
|
||||||
|
* документ) кодируем ПРЕФИКСОМ в имени файла: «<Подпись> — <исходное имя>».
|
||||||
|
* При загрузке имя файла переписываем, при показе — группируем по префиксу и
|
||||||
|
* префикс срезаем. Вынесено из «Заявки на приём», где появилось первым.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 attachmentsWithoutCategories(items: IAttachmentInfo[], categories: string[]): IAttachmentInfo[] {
|
||||||
|
const prefixes = categories.map(categoryPrefix);
|
||||||
|
return items.filter((a) => !prefixes.some((prefix) => a.fileName.indexOf(prefix) === 0));
|
||||||
|
}
|
||||||
@@ -4,10 +4,16 @@ import { friendlyErrorMessage } from '../diagnostics';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Подготовка новых файлов группы к загрузке — напр. префикс категории в имени
|
* Подготовка новых файлов группы к загрузке — напр. префикс категории в имени
|
||||||
* (см. forms/RecruitingHire/attachmentCategories). По умолчанию — как есть.
|
* (см. attachmentCategories). По умолчанию — как есть.
|
||||||
*/
|
*/
|
||||||
export type PrepareFiles = (groupKey: string, files: File[]) => File[];
|
export type PrepareFiles = (groupKey: string, files: File[]) => File[];
|
||||||
|
|
||||||
|
/** Куда грузить: список (Title текущей партиции или url архивной) + id элемента. */
|
||||||
|
export interface IAttachmentTarget {
|
||||||
|
listRef: string;
|
||||||
|
itemId: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAttachmentEditor {
|
export interface IAttachmentEditor {
|
||||||
/** Вложения, уже прикреплённые к заявке. */
|
/** Вложения, уже прикреплённые к заявке. */
|
||||||
items: IAttachmentInfo[];
|
items: IAttachmentInfo[];
|
||||||
@@ -17,8 +23,12 @@ export interface IAttachmentEditor {
|
|||||||
setPending: (groupKey: string, files: File[]) => void;
|
setPending: (groupKey: string, files: File[]) => void;
|
||||||
/** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */
|
/** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */
|
||||||
remove: (fileName: string) => void;
|
remove: (fileName: string) => void;
|
||||||
/** Загрузить все выбранные файлы и перечитать список вложений. */
|
/**
|
||||||
uploadPending: (prepare?: PrepareFiles) => Promise<void>;
|
* Загрузить все выбранные файлы и перечитать список вложений. target — когда
|
||||||
|
* элемент только что создан и его id ещё не дошёл до хука (сохранение новой
|
||||||
|
* заявки); не задан — грузим в listRef/itemId хука.
|
||||||
|
*/
|
||||||
|
uploadPending: (prepare?: PrepareFiles, target?: IAttachmentTarget) => Promise<void>;
|
||||||
/** Ошибка последнего удаления — показать рядом со списком. */
|
/** Ошибка последнего удаления — показать рядом со списком. */
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
}
|
}
|
||||||
@@ -26,14 +36,16 @@ export interface IAttachmentEditor {
|
|||||||
const keepAsIs: PrepareFiles = (_groupKey, files) => files;
|
const keepAsIs: PrepareFiles = (_groupKey, files) => files;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правка вложений уже поданной заявки — для согласующего с полным правом
|
* Правка вложений заявки: удаление — сразу, новые файлы копятся и загружаются
|
||||||
* (см. shared/approval/fullEditRights.ts). Удаление — сразу, как в форме
|
* вместе с сохранением полей (чтобы не было второй кнопки сохранения).
|
||||||
* автора; новые файлы копятся и загружаются вместе с сохранением полей
|
* Используется в просмотре согласующего с полным правом (см. shared/approval/
|
||||||
* (кнопка «Сохранить» или решение), чтобы не было второй кнопки сохранения.
|
* fullEditRights.ts) и в формах подачи.
|
||||||
*
|
*
|
||||||
* listRef — Title текущей партиции или url архивной (см. attachments.service).
|
* listRef — Title текущей партиции или url архивной (см. attachments.service).
|
||||||
|
* itemId — undefined, пока новая заявка не сохранена: удалять ещё нечего, а
|
||||||
|
* загрузка идёт с явным target.
|
||||||
*/
|
*/
|
||||||
export function useAttachmentEditor(listRef: string, itemId: number): IAttachmentEditor {
|
export function useAttachmentEditor(listRef: string, itemId: number | undefined): IAttachmentEditor {
|
||||||
const [items, setItems] = useState<IAttachmentInfo[]>([]);
|
const [items, setItems] = useState<IAttachmentInfo[]>([]);
|
||||||
const [pending, setPendingState] = useState<Record<string, File[]>>({});
|
const [pending, setPendingState] = useState<Record<string, File[]>>({});
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
@@ -42,22 +54,27 @@ export function useAttachmentEditor(listRef: string, itemId: number): IAttachmen
|
|||||||
setPendingState((prev) => ({ ...prev, [groupKey]: files }));
|
setPendingState((prev) => ({ ...prev, [groupKey]: files }));
|
||||||
|
|
||||||
const remove = (fileName: string): void => {
|
const remove = (fileName: string): void => {
|
||||||
|
if (itemId === undefined) return;
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
deleteAttachment(listRef, itemId, fileName)
|
deleteAttachment(listRef, itemId, fileName)
|
||||||
.then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName)))
|
.then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName)))
|
||||||
.catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`));
|
.catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`));
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadPending = async (prepare: PrepareFiles = keepAsIs): Promise<void> => {
|
const uploadPending = async (prepare: PrepareFiles = keepAsIs, target?: IAttachmentTarget): Promise<void> => {
|
||||||
const files = Object.keys(pending).reduce<File[]>(
|
const files = Object.keys(pending).reduce<File[]>(
|
||||||
(acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])),
|
(acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
await uploadAttachments(listRef, itemId, files);
|
const to = target ?? (itemId === undefined ? undefined : { listRef, itemId });
|
||||||
|
if (!to) {
|
||||||
|
throw new Error('Не удалось прикрепить файлы: заявка ещё не сохранена.');
|
||||||
|
}
|
||||||
|
await uploadAttachments(to.listRef, to.itemId, files);
|
||||||
// Загруженные убираем из выбора, иначе повторное сохранение продублирует их.
|
// Загруженные убираем из выбора, иначе повторное сохранение продублирует их.
|
||||||
setPendingState({});
|
setPendingState({});
|
||||||
setItems(await getAttachments(listRef, itemId));
|
setItems(await getAttachments(to.listRef, to.itemId));
|
||||||
};
|
};
|
||||||
|
|
||||||
return { items, setItems, pending, setPending, remove, uploadPending, error };
|
return { items, setItems, pending, setPending, remove, uploadPending, error };
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||||
|
import { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
|
||||||
|
import { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
|
||||||
|
|
||||||
|
// null из API → '' (см. IEmployeeFullBaseRecord).
|
||||||
|
function textOrEmpty(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(r: Record<string, unknown>): IEmployeeFullBaseRecord {
|
||||||
|
return {
|
||||||
|
id: textOrEmpty(r.id),
|
||||||
|
fio: textOrEmpty(r.fio),
|
||||||
|
iin: textOrEmpty(r.iin),
|
||||||
|
code: textOrEmpty(r.code),
|
||||||
|
company: textOrEmpty(r.company),
|
||||||
|
// Должности в базе бывают с хвостовым пробелом («Монтажник ») — textOrEmpty его срезает.
|
||||||
|
position: textOrEmpty(r.position),
|
||||||
|
department: textOrEmpty(r.department),
|
||||||
|
// В API опечатка «admision» — переименовываем на входе, дальше по коду правильное имя.
|
||||||
|
admissionDate: textOrEmpty(r.admisionDate),
|
||||||
|
dismissalDate: textOrEmpty(r.dismissalDate),
|
||||||
|
email: textOrEmpty(r.email)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /Employee/FullBase?Fio=... — работающие сотрудники по части Ф.И.О.
|
||||||
|
*
|
||||||
|
* Без фильтра эндпоинт отдаёт всю базу (тысячи записей, с уволенными), поэтому
|
||||||
|
* ищем на сервере по мере ввода, как PositionPicker — и ничего не кешируем.
|
||||||
|
*
|
||||||
|
* Уволенных отсекаем по dismissalDate на клиенте. Параметр Status у эндпоинта
|
||||||
|
* есть, но его значения не задокументированы — ДОПУЩЕНИЕ, проверить и, если он
|
||||||
|
* фильтрует работающих, перейти на него.
|
||||||
|
*/
|
||||||
|
export async function searchActiveEmployees(
|
||||||
|
context: WebPartContext,
|
||||||
|
fio: string,
|
||||||
|
maxResults: number
|
||||||
|
): Promise<IEmployeeFullBaseRecord[]> {
|
||||||
|
const query = fio.trim();
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const response: HttpClientResponse = await client.get(
|
||||||
|
`${DICTIONARY_API_BASE_URL}/Employee/FullBase?Fio=${encodeURIComponent(query)}`,
|
||||||
|
AadHttpClient.configurations.v1
|
||||||
|
);
|
||||||
|
if (response.status === 404) return [];
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Employee/FullBase: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as Array<Record<string, unknown>>;
|
||||||
|
return body
|
||||||
|
.map(toRecord)
|
||||||
|
.filter((r) => !r.dismissalDate)
|
||||||
|
.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Чей это человек: ИИН, а без него — id записи (тогда запись считается отдельным человеком). */
|
||||||
|
export function personKey(record: IEmployeeFullBaseRecord): string {
|
||||||
|
return record.iin || record.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Уникальный ключ места работы — для value в выпадающем списке. Не только id:
|
||||||
|
* у совместителя на каждое место работы может прийти один и тот же id человека.
|
||||||
|
*/
|
||||||
|
export function workplaceKey(record: IEmployeeFullBaseRecord): string {
|
||||||
|
return `${record.id}|${record.company}|${record.position}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человек из одной записи — когда остальные его места работы неизвестны (напр. просмотр заявки). */
|
||||||
|
export function personFromWorkplace(record: IEmployeeFullBaseRecord): IEmployeeFullBasePerson {
|
||||||
|
return { key: personKey(record), fio: record.fio, iin: record.iin, email: record.email, workplaces: [record] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Записи FullBase → люди (по ИИН), порядок — по первому появлению человека в ответе. */
|
||||||
|
export function groupEmployeesByPerson(records: IEmployeeFullBaseRecord[]): IEmployeeFullBasePerson[] {
|
||||||
|
const people: IEmployeeFullBasePerson[] = [];
|
||||||
|
records.forEach((record) => {
|
||||||
|
const person = people.find((p) => p.key === personKey(record));
|
||||||
|
if (!person) {
|
||||||
|
people.push(personFromWorkplace(record));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
person.workplaces.push(record);
|
||||||
|
if (!person.email) person.email = record.email;
|
||||||
|
});
|
||||||
|
return people;
|
||||||
|
}
|
||||||
@@ -19,6 +19,17 @@ export {
|
|||||||
employeeCompanies
|
employeeCompanies
|
||||||
} from './employeeOrganizations.service';
|
} from './employeeOrganizations.service';
|
||||||
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
export type { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
||||||
|
export {
|
||||||
|
searchActiveEmployees,
|
||||||
|
groupEmployeesByPerson,
|
||||||
|
personKey,
|
||||||
|
personFromWorkplace,
|
||||||
|
workplaceKey
|
||||||
|
} from './employeeFullBase.service';
|
||||||
|
export type { IEmployeeFullBaseRecord } from './models/IEmployeeFullBaseRecord';
|
||||||
|
export type { IEmployeeFullBasePerson } from './models/IEmployeeFullBasePerson';
|
||||||
|
export { EmployeeFullBasePicker } from './ui/EmployeeFullBasePicker';
|
||||||
|
export type { IEmployeeFullBasePickerProps } from './ui/EmployeeFullBasePicker';
|
||||||
export { getNomenclature } from './nomenclature.service';
|
export { getNomenclature } from './nomenclature.service';
|
||||||
export type { INomenclatureRow } from './models/INomenclatureRow';
|
export type { INomenclatureRow } from './models/INomenclatureRow';
|
||||||
export { searchPositions } from './positions.service';
|
export { searchPositions } from './positions.service';
|
||||||
@@ -33,5 +44,9 @@ export {
|
|||||||
} from './employeeLookup.service';
|
} from './employeeLookup.service';
|
||||||
export { CityPicker } from './ui/CityPicker';
|
export { CityPicker } from './ui/CityPicker';
|
||||||
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
|
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
|
||||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
export {
|
||||||
|
formatEmployeeOrganizationsWithDepartment,
|
||||||
|
formatOrganizationLine,
|
||||||
|
parseOrganizationLine
|
||||||
|
} from './organizationLines';
|
||||||
export type { IOrganizationLine } from './organizationLines';
|
export type { IOrganizationLine } from './organizationLines';
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IEmployeeFullBaseRecord } from './IEmployeeFullBaseRecord';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человек из Employee/FullBase — все его записи (места работы), собранные по ИИН.
|
||||||
|
* Пикер выбирает человека, а конкретное место работы форма выбирает отдельно
|
||||||
|
* (у совместителя их несколько) — как «Командирующая организация» в командировке.
|
||||||
|
*/
|
||||||
|
export interface IEmployeeFullBasePerson {
|
||||||
|
/** Ключ группировки: ИИН, а если его нет — id записи (см. personKey). */
|
||||||
|
key: string;
|
||||||
|
fio: string;
|
||||||
|
iin: string;
|
||||||
|
/** Первый непустой e-mail среди мест работы; '' — нет учётной записи. */
|
||||||
|
email: string;
|
||||||
|
/** Места работы, минимум одно. */
|
||||||
|
workplaces: IEmployeeFullBaseRecord[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Одна запись из GET /Employee/FullBase — полная база сотрудников из кадровой
|
||||||
|
* системы, ВКЛЮЧАЯ уволенных и рабочих без учётной записи M365.
|
||||||
|
*
|
||||||
|
* Запись = одно место работы, а не человек: совместитель приходит несколькими
|
||||||
|
* записями с одним iin и разными company/position/department.
|
||||||
|
*
|
||||||
|
* В отличие от Employee/ByEmail (поля с большой буквы), этот эндпоинт отдаёт
|
||||||
|
* camelCase — так в ответе API. null из API приводится к '' (как в
|
||||||
|
* IEmployeeOrganization): «нет значения» везде — пустая строка.
|
||||||
|
*/
|
||||||
|
export interface IEmployeeFullBaseRecord {
|
||||||
|
id: string;
|
||||||
|
/** «Фамилия Имя Отчество». */
|
||||||
|
fio: string;
|
||||||
|
/** ИИН, 12 цифр. */
|
||||||
|
iin: string;
|
||||||
|
/** Табельный номер в 1С (напр. «ЗК-0073429»). */
|
||||||
|
code: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
department: string;
|
||||||
|
/** Дата приёма, «YYYY-MM-DDT00:00:00»; '' — не указана. */
|
||||||
|
admissionDate: string;
|
||||||
|
/** Дата увольнения; '' — работает. */
|
||||||
|
dismissalDate: string;
|
||||||
|
/** Рабочий e-mail; '' — у сотрудника нет учётной записи (рабочие на объектах). */
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
@@ -8,12 +8,17 @@ import { IEmployeeOrganization } from './models/IEmployeeOrganization';
|
|||||||
* формат прежний, как у formatEmployeeOrganizations.
|
* формат прежний, как у formatEmployeeOrganizations.
|
||||||
*/
|
*/
|
||||||
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string {
|
||||||
return records
|
return records.map((r) => formatOrganizationLine(r.company, r.position, r.section)).join('\n');
|
||||||
.map((r) => {
|
}
|
||||||
if (r.section) return [r.company, r.position, r.section].join(' — ');
|
|
||||||
return r.position ? `${r.company} — ${r.position}` : r.company;
|
/**
|
||||||
})
|
* Одна строка «Компания — должность[ — подразделение]» — тот же формат, что у
|
||||||
.join('\n');
|
* formatEmployeeOrganizationsWithDepartment, для источников кроме Employee/ByEmail
|
||||||
|
* (напр. запись Employee/FullBase).
|
||||||
|
*/
|
||||||
|
export function formatOrganizationLine(company: string, position: string, department: string): string {
|
||||||
|
if (department) return [company, position, department].join(' — ');
|
||||||
|
return position ? `${company} — ${position}` : company;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOrganizationLine {
|
export interface IOrganizationLine {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Поле и выпадающий список — в той же стилистике, что PositionPicker.
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
// справа место под крестик-очистку
|
||||||
|
padding: 11px 36px 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;
|
||||||
|
@include readonly-field(':read-only');
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $sp-1;
|
||||||
|
max-height: 300px;
|
||||||
|
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;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $navy-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status,
|
||||||
|
.error {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import styles from './EmployeeFullBasePicker.module.scss';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { searchActiveEmployees, groupEmployeesByPerson } from '../employeeFullBase.service';
|
||||||
|
import { IEmployeeFullBasePerson } from '../models/IEmployeeFullBasePerson';
|
||||||
|
|
||||||
|
export interface IEmployeeFullBasePickerProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
id?: string;
|
||||||
|
/** Выбранный человек (со всеми местами работы); undefined — не выбран. */
|
||||||
|
value: IEmployeeFullBasePerson | undefined;
|
||||||
|
onChange: (value: IEmployeeFullBasePerson | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Только чтение (режим согласующего). */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
// Записей у совместителя несколько на человека — берём с запасом, людей в списке потом меньше.
|
||||||
|
const MAX_RECORDS = 60;
|
||||||
|
// По 1–2 буквам совпадений тысячи — запрос тяжёлый, а выбрать из такого списка всё равно нельзя.
|
||||||
|
const MIN_QUERY_LENGTH = 3;
|
||||||
|
|
||||||
|
const TEXT_SEARCHING = 'Поиск…';
|
||||||
|
const TEXT_TYPE_MORE = `Введите не меньше ${MIN_QUERY_LENGTH} букв Ф.И.О.`;
|
||||||
|
const TEXT_NOT_FOUND = 'Работающих сотрудников с таким Ф.И.О. не найдено';
|
||||||
|
const TEXT_LOAD_ERROR = 'Не удалось загрузить список сотрудников. Попробуйте ещё раз.';
|
||||||
|
|
||||||
|
/** Компании человека через запятую — чтобы различать тёзок. */
|
||||||
|
function describePerson(person: IEmployeeFullBasePerson): string {
|
||||||
|
return person.workplaces.map((w) => w.company).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор сотрудника из полной кадровой базы (GET /Employee/FullBase) — в отличие
|
||||||
|
* от PeoplePicker находит и тех, у кого нет учётной записи M365.
|
||||||
|
*
|
||||||
|
* Значение — человек целиком (все места работы, собранные по ИИН), а не текст:
|
||||||
|
* ручной ввод не принимается, иначе у заявки не будет ИИН, компании и e-mail.
|
||||||
|
* Начали править текст после выбора — выбор сбрасывается (onChange(undefined)).
|
||||||
|
* Конкретное место работы (организацию) форма выбирает отдельным полем.
|
||||||
|
*/
|
||||||
|
export const EmployeeFullBasePicker: React.FC<IEmployeeFullBasePickerProps> = ({
|
||||||
|
context,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
readOnly
|
||||||
|
}) => {
|
||||||
|
const contextReadOnly = useFormReadOnly();
|
||||||
|
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||||
|
|
||||||
|
const [query, setQuery] = React.useState<string>('');
|
||||||
|
const [suggestions, setSuggestions] = React.useState<IEmployeeFullBasePerson[]>([]);
|
||||||
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
const [loadError, setLoadError] = React.useState<boolean>(false);
|
||||||
|
// Счётчик запросов — ответ на устаревший запрос не перезаписывает более свежий (как в PositionPicker).
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
|
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
const isQueryTooShort = trimmedQuery.length < MIN_QUERY_LENGTH;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isQueryTooShort) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setLoading(false);
|
||||||
|
setLoadError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
searchActiveEmployees(context, trimmedQuery, MAX_RECORDS)
|
||||||
|
.then((records) => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setSuggestions(groupEmployeesByPerson(records));
|
||||||
|
setLoadError(false);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
|
setSuggestions([]);
|
||||||
|
setLoadError(true);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [context, trimmedQuery, isQueryTooShort]);
|
||||||
|
|
||||||
|
const handleInput = (text: string): void => {
|
||||||
|
if (value) onChange(undefined);
|
||||||
|
setQuery(text);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePick = (person: IEmployeeFullBasePerson): void => {
|
||||||
|
onChange(person);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (): void => {
|
||||||
|
onChange(undefined);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = getStatusText(loading, loadError, isQueryTooShort, suggestions.length);
|
||||||
|
const isListShown = !effectiveReadOnly && open && !value && trimmedQuery.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
value={value ? value.fio : query}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => handleInput(e.target.value)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в PositionPicker). */}
|
||||||
|
{!effectiveReadOnly && (value || query) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clear}
|
||||||
|
aria-label="Очистить"
|
||||||
|
title="Очистить"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isListShown && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{statusText && <div className={loadError ? styles.error : styles.status}>{statusText}</div>}
|
||||||
|
{!statusText &&
|
||||||
|
suggestions.map((person) => (
|
||||||
|
<button
|
||||||
|
key={person.key}
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handlePick(person)}
|
||||||
|
>
|
||||||
|
<span className={styles.itemName}>{person.fio}</span>
|
||||||
|
<span className={styles.itemMeta}>{describePerson(person)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Текст служебной строки списка; '' — показать сами подсказки. */
|
||||||
|
function getStatusText(loading: boolean, loadError: boolean, isQueryTooShort: boolean, found: number): string {
|
||||||
|
if (isQueryTooShort) return TEXT_TYPE_MORE;
|
||||||
|
if (loading) return TEXT_SEARCHING;
|
||||||
|
if (loadError) return TEXT_LOAD_ERROR;
|
||||||
|
if (found === 0) return TEXT_NOT_FOUND;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ const LIST_TITLE_BASE: Record<RequestTypeId, string> = {
|
|||||||
'hr-unpaid-leave': 'Отпуск без содержания',
|
'hr-unpaid-leave': 'Отпуск без содержания',
|
||||||
'hr-skud-explanation': 'Объяснительная по СКУД',
|
'hr-skud-explanation': 'Объяснительная по СКУД',
|
||||||
'hr-business-trip': 'Заявка на командировку',
|
'hr-business-trip': 'Заявка на командировку',
|
||||||
|
'hr-contract-change': 'Изменение условий трудового договора',
|
||||||
'it-1c-ticket': 'Заявка в отдел 1С',
|
'it-1c-ticket': 'Заявка в отдел 1С',
|
||||||
'it-workplace-setup': 'Организация рабочего места',
|
'it-workplace-setup': 'Организация рабочего места',
|
||||||
'recruiting-hire': 'Прием на работу',
|
'recruiting-hire': 'Прием на работу',
|
||||||
|
|||||||
Reference in New Issue
Block a user