Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad2a19a70 | |||
| 95b0f8f21e | |||
| e8b060add3 | |||
| ad1234b84a | |||
| 353a1aa543 | |||
| 7f641b36c6 | |||
| 9cacb6b271 | |||
| 72e330e659 | |||
| 975681105b | |||
| ddf313c951 | |||
| c59b0ad208 | |||
| 6aef9a5ba9 | |||
| 4c018154d3 | |||
| 17f9dec39b | |||
| 4eece061d1 | |||
| 9f97a19cab | |||
| 82a9a68fbc | |||
| ac633c767e | |||
| a750db9d7f | |||
| ea04602606 | |||
| 57feeddcfe | |||
| fa2e9593f1 | |||
| 8d162db4ba | |||
| 51f3c6fb9a | |||
| 63448e60aa | |||
| 50a1ae2ce3 | |||
| d07056937a | |||
| 17f448a1c2 | |||
| 6122370bbf | |||
| cd7b27daa2 | |||
| fa84dd6ad5 | |||
| 08dd151c56 | |||
| c892fb26a9 | |||
| 513f6c1d30 |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -49,10 +49,10 @@
|
|||||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||||
| [`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) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`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 × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
|
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
**Просмотр** — есть режим согласующего (`<Форма>View`).
|
||||||
|
|||||||
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
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Собирает справочник городов и посёлков РК для выпадающих списков портала из
|
||||||
|
официального КАТО (Бюро национальной статистики, stat.gov.kz) и пишет его
|
||||||
|
TS-модулем src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Берём только города («г.») и посёлки («п.»): ~180 записей, несколько KB в бандле.
|
||||||
|
Сёла (~10 тыс.) не берём — тяжело для фронта; их вводят вручную в то же поле.
|
||||||
|
Административные единицы («Г.А.», «п.а.», «г.а.» — администрации, не населённые
|
||||||
|
пункты) отбрасываются. КАТО обновляется редко: перезапустить скрипт со ссылкой
|
||||||
|
на свежий файл со страницы https://stat.gov.kz/ru/classifiers/statistical/21/
|
||||||
|
и закоммитить сгенерированный модуль.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/Build-KatoCities.ps1
|
||||||
|
./scripts/Build-KatoCities.ps1 -XlsxUrl "https://stat.gov.kz/upload/iblock/.../КАТО_ДД.ММ.ГГГГ.xlsx"
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$XlsxUrl = 'https://stat.gov.kz/upload/iblock/a7d/eqogm92xc0udumlzfzffhcrutk0fjhb4/КАТО_18.09.2026.xlsx',
|
||||||
|
[string]$OutFile = (Join-Path $PSScriptRoot '..\src\webparts\requestPortal\shared\dictionary\kato\katoCities.ts')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$work = Join-Path ([System.IO.Path]::GetTempPath()) "kato-$([guid]::NewGuid())"
|
||||||
|
New-Item -ItemType Directory -Path $work | Out-Null
|
||||||
|
try {
|
||||||
|
# xlsx — это zip: читаем XML листа напрямую, без Excel и сторонних модулей.
|
||||||
|
$zip = Join-Path $work 'kato.zip'
|
||||||
|
Invoke-WebRequest -Uri $XlsxUrl -OutFile $zip -UseBasicParsing
|
||||||
|
Expand-Archive -Path $zip -DestinationPath $work
|
||||||
|
|
||||||
|
[xml]$sharedXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\sharedStrings.xml')
|
||||||
|
$shared = @($sharedXml.sst.si | ForEach-Object { $_.InnerText })
|
||||||
|
|
||||||
|
[xml]$sheetXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\worksheets\sheet1.xml')
|
||||||
|
|
||||||
|
# Колонки листа: A — код КАТО, B — код области (ab), C/D/E — нижние уровни, G/H — название на казахском/русском.
|
||||||
|
$rows = foreach ($row in $sheetXml.worksheet.sheetData.row) {
|
||||||
|
$cells = @{}
|
||||||
|
foreach ($c in $row.c) {
|
||||||
|
$column = $c.r -replace '\d', ''
|
||||||
|
$value = if ($c.t -eq 's') { $shared[[int]$c.v] } elseif ($c.t -eq 'inlineStr') { $c.is.InnerText } else { $c.v }
|
||||||
|
$cells[$column] = "$value".Trim()
|
||||||
|
}
|
||||||
|
[pscustomobject]@{ Kato = $cells['A']; Region = $cells['B']; Level2 = $cells['C']; Level3 = $cells['D']; Level4 = $cells['E']; Name = $cells['H']; NameKz = $cells['G'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Области — строки без нижних уровней. Города республиканского значения (Астана,
|
||||||
|
# Алматы, Шымкент) сами себе «область» — для них регион не показываем.
|
||||||
|
$regions = @{}
|
||||||
|
$rows | Where-Object { $_.Level2 -eq '00' -and $_.Level3 -eq '00' -and $_.Level4 -eq '000' } | ForEach-Object {
|
||||||
|
$regions[$_.Region] = if ($_.Name -match '^г\.') { '' } else { $_.Name }
|
||||||
|
}
|
||||||
|
|
||||||
|
# «г.Атырау», «п. Асыката» — населённые пункты; «п.а.», «г.а.» — администрации.
|
||||||
|
$cities = $rows |
|
||||||
|
Where-Object { $_.Name -match '^(г|п)\.\s*[^\s.]' -and $_.Name -notmatch '^(г|п)\.а\.' } |
|
||||||
|
ForEach-Object {
|
||||||
|
[pscustomobject]@{
|
||||||
|
Kato = $_.Kato
|
||||||
|
Name = ($_.Name -replace '^(г|п)\.\s*', '')
|
||||||
|
# «Құлсары қ.» / «Шаған к.» — тип пункта последним словом с точкой (в источнике
|
||||||
|
# встречается и опечатка «ќ.»), поэтому отрезаем любое такое слово.
|
||||||
|
NameKz = ($_.NameKz -replace '\s+\S+\.$', '')
|
||||||
|
Kind = if ($_.Name -match '^г\.') { 'city' } else { 'settlement' }
|
||||||
|
Region = $regions[$_.Region]
|
||||||
|
}
|
||||||
|
} |
|
||||||
|
Sort-Object Name, Region
|
||||||
|
|
||||||
|
$esc = { param($s) $s -replace '\\', '\\' -replace "'", "\'" }
|
||||||
|
$lines = $cities | ForEach-Object {
|
||||||
|
" { kato: '$($_.Kato)', name: '$(& $esc $_.Name)', nameKz: '$(& $esc $_.NameKz)', kind: '$($_.Kind)', region: '$(& $esc $_.Region)' },"
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = [System.Uri]::UnescapeDataString(($XlsxUrl -split '/')[-1])
|
||||||
|
$content = @"
|
||||||
|
// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт.
|
||||||
|
// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «$source».
|
||||||
|
import { IKatoCity } from './IKatoCity';
|
||||||
|
|
||||||
|
export const KATO_CITIES: ReadonlyArray<IKatoCity> = [
|
||||||
|
$($lines -join "`n")
|
||||||
|
];
|
||||||
|
"@
|
||||||
|
|
||||||
|
$outPath = [System.IO.Path]::GetFullPath($OutFile)
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path $outPath) | Out-Null
|
||||||
|
# Без BOM и с LF — как остальные исходники репозитория.
|
||||||
|
[System.IO.File]::WriteAllText($outPath, $content.Replace("`r`n", "`n") + "`n", (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
|
||||||
|
$cityCount = @($cities | Where-Object Kind -eq 'city').Count
|
||||||
|
Write-Host "Готово: $($cities.Count) записей (городов $cityCount, посёлков $($cities.Count - $cityCount)) → $OutFile"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Recurse -Force $work
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
|
||||||
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
||||||
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
||||||
@@ -53,12 +61,6 @@ export const REQUEST_TYPES: IRequestType[] = [
|
|||||||
title: 'Заявка на услуги',
|
title: 'Заявка на услуги',
|
||||||
icon: 'document'
|
icon: 'document'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'acc-payment-request',
|
|
||||||
categoryId: 'accounting',
|
|
||||||
title: 'Заявка на оплату',
|
|
||||||
icon: 'calculator'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'wh-concrete-request',
|
id: 'wh-concrete-request',
|
||||||
categoryId: 'warehouse',
|
categoryId: 'warehouse',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
|
|||||||
import { submitDateOnOpen } from '../../shared/submitDate';
|
import { submitDateOnOpen } from '../../shared/submitDate';
|
||||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { draftIssues, submitIssues } from './validation';
|
import { draftIssues, submitIssues } from './validation';
|
||||||
import { syncPerDiem } from './estimate';
|
import { syncRouteItems } from './estimate';
|
||||||
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
||||||
import { loadBusinessTripRequest } from './load';
|
import { loadBusinessTripRequest } from './load';
|
||||||
import { useTripEmployee } from './useTripEmployee';
|
import { useTripEmployee } from './useTripEmployee';
|
||||||
@@ -86,10 +86,10 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
|
// Суточные и проживание строятся по маршруту (см. syncRouteItems): секции, проверки и сохранение
|
||||||
// работают со сметой, уже приведённой к текущим точкам и датам.
|
// работают со сметой, уже приведённой к текущим точкам и датам.
|
||||||
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
|
const formData: IFormData = { ...data, Estimate: syncRouteItems(data.Estimate, data.Route) };
|
||||||
const handleEmployeeChange = useTripEmployee(context, editId, setData);
|
const employee = useTripEmployee(context, editId, data.Employee, setData);
|
||||||
|
|
||||||
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||||
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
||||||
const draftMissing = draftIssues(formData);
|
const draftMissing = draftIssues(formData);
|
||||||
const submitMissing = submitIssues(formData);
|
const submitMissing = submitIssues(formData, employee.organizations);
|
||||||
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
||||||
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
onDeleted: onCancel
|
onDeleted: onCancel
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
|
const sectionProps = { data: formData, set, context, employee };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -18,13 +18,46 @@
|
|||||||
## Особое поведение
|
## Особое поведение
|
||||||
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
|
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
|
||||||
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
|
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
|
||||||
|
- **Командирующая организация** (с 10-23, логика — `tripCompany.ts`). Сотрудник может работать
|
||||||
|
в нескольких компаниях — выбирают, от какой он едет (`Select` «Компания — должность»).
|
||||||
|
Одна организация — выбирается сама, поле заблокировано. Несколько — обязательна при отправке,
|
||||||
|
должность пустая до выбора и подставляется из выбранной компании. Сотрудника нет в
|
||||||
|
справочнике — поля нет, отправку не блокирует. Смена сотрудника сбрасывает выбор. Хранение:
|
||||||
|
`TripCompany` (название) + `TripCompanyId` (CompanyId — под будущий маршрут по компании);
|
||||||
|
`Organization` — по-прежнему все организации сотрудника. Заявки до 10-23 в просмотре
|
||||||
|
показывают прежний список организаций.
|
||||||
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
|
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
|
||||||
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
|
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
|
||||||
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
|
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
|
||||||
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
|
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
|
||||||
|
- **Города — из справочника КАТО** (`shared/dictionary/kato`, поле `CityPicker`): 178 городов
|
||||||
|
и посёлков РК из официального КАТО (stat.gov.kz), сгенерированы `scripts/Build-KatoCities.ps1`.
|
||||||
|
Поиск по русскому и казахскому названию с учётом раскладки (Құлсары = Кулсары = Кульсары);
|
||||||
|
набранное целиком однозначное название подставляется само. Выбор сохраняет оба названия и
|
||||||
|
код КАТО (`city`/`cityKz`/`cityKato`, у города отправления — `origin`/`originKz`/`originKato`,
|
||||||
|
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
|
||||||
|
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
|
||||||
|
введённый текст без казахского названия.
|
||||||
|
- **Казахстан / другая страна — у каждой точки** (с 10-23, `SegmentedSwitch` в карточке точки):
|
||||||
|
признак у точки, а не у поездки — бывают смешанные маршруты (Алматы → Москва → Атырау).
|
||||||
|
За рубежом — «Страна» и «Город» вручную (`abroad`/`country` в `TripRouteJson`), подпись
|
||||||
|
«Китай, Пекин» (страна первой). Смена признака: город остаётся, за рубежом сбрасываются казахское название
|
||||||
|
и код КАТО, у точки по Казахстану — страна (`stopLocationPatch`). Новая точка — в той же
|
||||||
|
стране, что предыдущая. Город отправления — всегда из справочника (можно и свой текст).
|
||||||
|
Колонка `TripKind` («По Казахстану» / «Зарубежная»): хотя бы одна точка за рубежом —
|
||||||
|
зарубежная; пригодится для маршрута согласования и отчётов. Старые заявки — по Казахстану.
|
||||||
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
|
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
|
||||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
|
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
|
||||||
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
|
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
|
||||||
|
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
|
||||||
|
(`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под
|
||||||
|
билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
|
||||||
|
- **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы →
|
||||||
|
Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение».
|
||||||
|
Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении.
|
||||||
|
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
|
||||||
|
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
|
||||||
|
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
|
||||||
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
|
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
|
||||||
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
|
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
|
||||||
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
|
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
|
||||||
@@ -33,24 +66,40 @@
|
|||||||
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
|
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
|
||||||
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
|
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
|
||||||
прибытие раньше отъезда из предыдущей точки — ошибка.
|
прибытие раньше отъезда из предыдущей точки — ошибка.
|
||||||
|
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
|
||||||
|
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
|
||||||
|
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
|
||||||
|
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
|
||||||
|
последней включительно; при отправке подсвечивается отъезд последней точки.
|
||||||
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
|
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
|
||||||
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
|
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
|
||||||
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
||||||
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
||||||
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
||||||
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
||||||
суточных:
|
суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute`
|
||||||
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
|
в `ESTIMATE_KINDS`), по позиции на точку:
|
||||||
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
|
- **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки,
|
||||||
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
||||||
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
||||||
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
|
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
|
||||||
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется
|
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncRouteItems`
|
||||||
только в форме.
|
применяется только в форме. **Лимит** (с 10-23): не больше 6 МРП в день по Казахстану и 8 за
|
||||||
|
рубежом (`MAX_MRP_PER_DAY`, по признаку точки) — подпись у поля, ошибка сразу под строкой
|
||||||
|
и при отправке.
|
||||||
|
- **Проживание** (с 10-23): ночи — из дат точки (`stopNights`), человек вводит только цену за
|
||||||
|
ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по
|
||||||
|
Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать,
|
||||||
|
когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет.
|
||||||
|
- **Дни/ночи можно поправить вручную** (с 10-23, `AutoQuantityField`): карандаш у поля
|
||||||
|
открывает ввод, число любое (форс-мажор — задержался в точке). Хранится в
|
||||||
|
`quantityManual` (`''` — по датам); под полем «вручную · по датам: N · вернуть». Даты
|
||||||
|
потом поменяли — ручное число остаётся, расхождение видно по подписи. В расчёте для
|
||||||
|
просмотра/печати/колонки SP — пометка «(вручную)».
|
||||||
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
|
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
|
||||||
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
||||||
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
||||||
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных
|
- **Внутри статьи — позиции** («+ Ещё участок пути»; у суточных и проживания
|
||||||
позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
|
позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
|
||||||
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
|
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
|
||||||
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
||||||
@@ -64,7 +113,10 @@
|
|||||||
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
|
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
|
||||||
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
||||||
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
||||||
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
|
- **Комментарий** (с 10-23, `TripComment`, Note) — необязательное поле после итога сметы; в
|
||||||
|
просмотре пустой не показывается.
|
||||||
|
- **Обязательство и согласие** (так названа секция с 10-23, по требованию кадров) — обязательство
|
||||||
|
по авансовому отчёту (текст бланка) и флажок согласия, без него не отправить.
|
||||||
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
|
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
|
||||||
можно править (см. shared/submitDate.ts).
|
можно править (см. shared/submitDate.ts).
|
||||||
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
|
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
|
||||||
@@ -76,8 +128,8 @@
|
|||||||
заголовком (см. грабли коллизий internalName).
|
заголовком (см. грабли коллизий internalName).
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
|
EmployeeSection (сотрудник · должность · дата подачи · командирующая организация) → RouteSection (маршрут
|
||||||
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
|
лентой; поле «Цель» убрано 10-23, колонка `TripPurpose` в SP осталась от тестовых заявок) → TasksSection (задачи) → EstimateSection (смета + итог) →
|
||||||
ObligationSection (текст обязательства + согласие).
|
ObligationSection (текст обязательства + согласие).
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
|
|||||||
+1
@@ -1,4 +1,5 @@
|
|||||||
@import '../../../../../styles/design-tokens';
|
@import '../../../../../styles/design-tokens';
|
||||||
|
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './ChoiceChips.module.scss';
|
||||||
|
import { useFormReadOnly } from '../../../shared/ui';
|
||||||
|
import { DEPARTURE_TIMES } from '../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Значение из DEPARTURE_TIMES; '' — не указано. */
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Подпись группы для экранных дикторов, напр. «Время выезда: Алматы → Атырау». */
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предпочтительное время выезда плашками (обязательно — см. validation.ts).
|
||||||
|
* Повторный клик по выбранной плашке снимает выбор. В режиме чтения — только
|
||||||
|
* выбранная плашка.
|
||||||
|
*/
|
||||||
|
export const DepartureTimeChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value || 'не указано'}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
|
||||||
|
{DEPARTURE_TIMES.map((time) => {
|
||||||
|
const selected = time === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={time}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
|
||||||
|
onClick={() => onChange(selected ? '' : time)}
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
@import '../../../../../../styles/design-tokens';
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете.
|
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields).
|
||||||
.switch {
|
.switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './SegmentedSwitch.module.scss';
|
||||||
|
|
||||||
|
export interface ISegmentedOption<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T extends string> {
|
||||||
|
value: T;
|
||||||
|
options: ReadonlyArray<ISegmentedOption<T>>;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
/** Подпись группы для экранных дикторов. */
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */
|
||||||
|
export function SegmentedSwitch<T extends string>({ value, options, onChange, ariaLabel, className }: Props<T>): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={className ? `${styles.switch} ${className}` : styles.switch} role="radiogroup" aria-label={ariaLabel}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === option.value}
|
||||||
|
className={value === option.value ? `${styles.option} ${styles.active}` : styles.option}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './TransportChips.module.scss';
|
import styles from './ChoiceChips.module.scss';
|
||||||
import { useFormReadOnly } from '../../../shared/ui';
|
import { useFormReadOnly } from '../../../shared/ui';
|
||||||
import { TRANSPORT_TYPES } from '../model';
|
import { TRANSPORT_TYPES } from '../model';
|
||||||
import { TransportIcon } from './TransportIcon';
|
import { TransportIcon } from './TransportIcon';
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ const PLANE =
|
|||||||
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
|
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
|
||||||
const TRAIN =
|
const TRAIN =
|
||||||
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
|
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
|
||||||
const BUS =
|
// local_taxi: та же машина, что CAR, но с шашкой на крыше — чтобы такси отличалось от служебного/личного авто.
|
||||||
'M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z';
|
const TAXI =
|
||||||
|
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5H15V3H9v2H6.5c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
|
||||||
const CAR =
|
const CAR =
|
||||||
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
|
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const CAR =
|
|||||||
const PATH_BY_TYPE: Record<string, string> = {
|
const PATH_BY_TYPE: Record<string, string> = {
|
||||||
Авиа: PLANE,
|
Авиа: PLANE,
|
||||||
Железнодорожный: TRAIN,
|
Железнодорожный: TRAIN,
|
||||||
Автобус: BUS,
|
Такси: TAXI,
|
||||||
'Служебный автотранспорт': CAR,
|
'Служебный автотранспорт': CAR,
|
||||||
'Личный автотранспорт': CAR
|
'Личный автотранспорт': CAR
|
||||||
};
|
};
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './StopItemFields.module.scss';
|
||||||
|
import { FormField, MoneyInput } from '../../../../shared/ui';
|
||||||
|
import { IEstimateItem, IRouteStop } from '../../model';
|
||||||
|
import { formatAmount, itemAmount, nightPriceLimit } from '../../estimate';
|
||||||
|
import { stopCityLabel } from '../../route';
|
||||||
|
import { AutoQuantityField } from './AutoQuantityField';
|
||||||
|
import { fieldKey } from '../../validation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: IEstimateItem;
|
||||||
|
/** Точка маршрута позиции; проживание строится по точкам (см. syncRouteItems). */
|
||||||
|
stop: IRouteStop | undefined;
|
||||||
|
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
|
||||||
|
autoQuantity: string;
|
||||||
|
onChange: (patch: Partial<IEstimateItem>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проживание в одной точке маршрута: ночи — из дат точки (или вручную — карандаш,
|
||||||
|
* см. AutoQuantityField), человек вводит цену за ночь (не выше лимита — см.
|
||||||
|
* nightPriceLimit). Цену можно не вводить — в точке могут жить на объекте, тогда
|
||||||
|
* позиция просто 0.
|
||||||
|
*/
|
||||||
|
export const AccommodationFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
|
||||||
|
const hasNights = item.quantity !== '';
|
||||||
|
const limit = nightPriceLimit(stop);
|
||||||
|
const isOverLimit = limit !== undefined && Number(item.price) > limit;
|
||||||
|
const priceLabel = limit === undefined ? 'Цена за ночь' : `Цена за ночь (до ${formatAmount(limit)} ₸)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.place}>
|
||||||
|
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.calcRow} ${styles.calcRowShort}`}>
|
||||||
|
<AutoQuantityField
|
||||||
|
id={fieldKey.itemQuantity(item)}
|
||||||
|
label="Ночей"
|
||||||
|
autoValue={autoQuantity}
|
||||||
|
manualValue={item.quantityManual}
|
||||||
|
onManualChange={(v) => onChange({ quantityManual: v })}
|
||||||
|
/>
|
||||||
|
<span className={styles.sign} aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<FormField label={priceLabel} htmlFor={fieldKey.itemPrice(item)}>
|
||||||
|
<div className={styles.number}>
|
||||||
|
<MoneyInput id={fieldKey.itemPrice(item)} value={item.price} onChange={(v) => onChange({ price: v })} />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<span className={styles.sign} aria-hidden>
|
||||||
|
=
|
||||||
|
</span>
|
||||||
|
<div className={styles.result}>
|
||||||
|
<span className={styles.resultLabel}>Итого</span>
|
||||||
|
<span className={styles.resultValue}>{formatAmount(itemAmount(item))} ₸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOverLimit && limit !== undefined && (
|
||||||
|
<div className={styles.limitError}>Не больше {formatAmount(limit)} ₸ за ночь по Казахстану.</div>
|
||||||
|
)}
|
||||||
|
{!hasNights && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются ночи.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import styles from './StopItemFields.module.scss';
|
||||||
|
import { FormField, Icon, MoneyInput, useFormReadOnly } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
/** «Дней» / «Ночей». */
|
||||||
|
label: string;
|
||||||
|
/** Посчитано по датам точки; '' — даты не заданы. */
|
||||||
|
autoValue: string;
|
||||||
|
/** Введено вручную; '' — берём autoValue. */
|
||||||
|
manualValue: string;
|
||||||
|
onManualChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дни (суточные) или ночи (проживание): по умолчанию считаются по датам точки и
|
||||||
|
* не редактируются; карандаш открывает ручной ввод — на случай форс-мажора
|
||||||
|
* (задержался в точке). Ручное число любое; рядом видно, сколько по датам, и
|
||||||
|
* его можно сбросить обратно к расчёту.
|
||||||
|
*/
|
||||||
|
export const AutoQuantityField: React.FC<Props> = ({ id, label, autoValue, manualValue, onManualChange }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
// Отдельно от manualValue: пока человек стёр число и печатает новое, поле не должно схлопываться.
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const isManual = manualValue !== '';
|
||||||
|
const showInput = !readOnly && (editing || isManual);
|
||||||
|
|
||||||
|
// Карандаш нажали — сразу ставим курсор в поле.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) document.getElementById(id)?.focus();
|
||||||
|
}, [editing, id]);
|
||||||
|
|
||||||
|
const startEditing = (): void => {
|
||||||
|
onManualChange(autoValue);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
const resetToAuto = (): void => {
|
||||||
|
onManualChange('');
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField label={label} htmlFor={id}>
|
||||||
|
{showInput ? (
|
||||||
|
<div className={styles.number} onBlur={() => !isManual && setEditing(false)}>
|
||||||
|
<MoneyInput id={id} value={manualValue} onChange={onManualChange} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.autoValue}>
|
||||||
|
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||||
|
{(isManual ? manualValue : autoValue) || '—'}
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<button type="button" className={styles.iconButton} title="Изменить вручную" onClick={startEditing}>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isManual && (
|
||||||
|
<span className={styles.manualNote}>
|
||||||
|
вручную · по датам: {autoValue || '—'}
|
||||||
|
{!readOnly && (
|
||||||
|
<button type="button" className={styles.resetButton} onClick={resetToAuto}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
вернуть
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-7
@@ -3,10 +3,11 @@ import styles from './EstimateItemRow.module.scss';
|
|||||||
import { FormField, Icon, Select, TextInput } from '../../../../shared/ui';
|
import { FormField, Icon, Select, TextInput } from '../../../../shared/ui';
|
||||||
import type { ISelectOption } from '../../../../shared/ui';
|
import type { ISelectOption } from '../../../../shared/ui';
|
||||||
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
|
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
|
||||||
import { linkOptions, resolveLinkId } from '../../estimate';
|
import { linkOptions, resolveLinkId, routeItemAutoQuantity } from '../../estimate';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
import { AmountFields } from './AmountFields';
|
import { AmountFields } from './AmountFields';
|
||||||
import { PerDiemFields } from './PerDiemFields';
|
import { PerDiemFields } from './PerDiemFields';
|
||||||
|
import { AccommodationFields } from './AccommodationFields';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
kind: EstimateKind;
|
kind: EstimateKind;
|
||||||
@@ -21,13 +22,15 @@ interface Props {
|
|||||||
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
||||||
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
||||||
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
||||||
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
* (и проживание) привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
||||||
*/
|
*/
|
||||||
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
||||||
const info = ESTIMATE_KINDS[kind];
|
const info = ESTIMATE_KINDS[kind];
|
||||||
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
||||||
const showLink = !info.itemsFromRoute && options.length > 1;
|
const showLink = !info.itemsFromRoute && options.length > 1;
|
||||||
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
||||||
|
const stop = route.stops.find((s) => s.id === item.linkId);
|
||||||
|
const autoQuantity = routeItemAutoQuantity(kind, route, item.linkId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
@@ -53,11 +56,9 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{info.itemsFromRoute ? (
|
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||||
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
|
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||||
) : (
|
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
|
||||||
<AmountFields kind={kind} item={item} onChange={onChange} />
|
|
||||||
)}
|
|
||||||
<TextInput
|
<TextInput
|
||||||
value={item.comment}
|
value={item.comment}
|
||||||
onChange={(v) => onChange({ comment: v })}
|
onChange={(v) => onChange({ comment: v })}
|
||||||
|
|||||||
+27
-13
@@ -1,42 +1,51 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './PerDiemFields.module.scss';
|
import styles from './StopItemFields.module.scss';
|
||||||
import { FormField, MoneyInput } from '../../../../shared/ui';
|
import { FormField, MoneyInput } from '../../../../shared/ui';
|
||||||
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
|
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
|
||||||
import { formatAmount, itemAmount } from '../../estimate';
|
import { formatAmount, itemAmount, mrpLimit } from '../../estimate';
|
||||||
|
import { stopCityLabel } from '../../route';
|
||||||
|
import { AutoQuantityField } from './AutoQuantityField';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: IEstimateItem;
|
item: IEstimateItem;
|
||||||
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
|
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */
|
||||||
stop: IRouteStop | undefined;
|
stop: IRouteStop | undefined;
|
||||||
|
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
|
||||||
|
autoQuantity: string;
|
||||||
onChange: (patch: Partial<IEstimateItem>) => void;
|
onChange: (patch: Partial<IEstimateItem>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
|
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат (или
|
||||||
* человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE.
|
* вручную — карандаш, см. AutoQuantityField), человек вводит количество МРП в
|
||||||
|
* день (не больше лимита — см. mrpLimit). Итог = дни × МРП × MRP_VALUE.
|
||||||
*/
|
*/
|
||||||
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
export const PerDiemFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
|
||||||
const hasDays = item.quantity !== '';
|
const hasDays = item.quantity !== '';
|
||||||
const project = stop?.project.trim();
|
const project = stop?.project.trim();
|
||||||
|
const limit = mrpLimit(stop);
|
||||||
|
const isOverLimit = Number(item.mrpCount) > limit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<div className={styles.place}>
|
<div className={styles.place}>
|
||||||
<span className={styles.city}>{stop?.city.trim() || 'Точка маршрута'}</span>
|
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
|
||||||
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
|
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.calcRow}>
|
<div className={styles.calcRow}>
|
||||||
<FormField label="Дней">
|
<AutoQuantityField
|
||||||
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
id={fieldKey.itemQuantity(item)}
|
||||||
{hasDays ? item.quantity : '—'}
|
label="Дней"
|
||||||
</div>
|
autoValue={autoQuantity}
|
||||||
</FormField>
|
manualValue={item.quantityManual}
|
||||||
|
onManualChange={(v) => onChange({ quantityManual: v })}
|
||||||
|
/>
|
||||||
<span className={styles.sign} aria-hidden>
|
<span className={styles.sign} aria-hidden>
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
<FormField label="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
|
<FormField label={`Кол-во МРП в день (до ${limit})`} htmlFor={fieldKey.itemMrpCount(item)}>
|
||||||
<div className={styles.number}>
|
<div className={styles.number}>
|
||||||
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -56,6 +65,11 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOverLimit && (
|
||||||
|
<div className={styles.limitError}>
|
||||||
|
Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+76
@@ -1,5 +1,6 @@
|
|||||||
@import '../../../../../../styles/design-tokens';
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields).
|
||||||
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
||||||
$calc-inline-breakpoint: 640px;
|
$calc-inline-breakpoint: 640px;
|
||||||
|
|
||||||
@@ -115,3 +116,78 @@ $calc-inline-breakpoint: 640px;
|
|||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Превышен лимит (МРП, цена за ночь) — сразу под строкой, не дожидаясь отправки.
|
||||||
|
.limitError {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проживание: «ночи × цена = итог» — на две колонки короче, чем у суточных.
|
||||||
|
@media (min-width: $calc-inline-breakpoint) {
|
||||||
|
.calcRowShort {
|
||||||
|
grid-template-columns: minmax(0, 0.6fr) auto minmax(0, 1fr) auto minmax(120px, auto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни/ночи по датам + карандаш «изменить вручную» (AutoQuantityField).
|
||||||
|
.autoValue {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1;
|
||||||
|
|
||||||
|
.readonly {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: transparent;
|
||||||
|
color: $gray-500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .18s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $gray-100;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $gold-500;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// «вручную · по датам: 4 · вернуть» — под полем, мелко: число правили руками.
|
||||||
|
.manualNote {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1 $sp-2;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resetButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $navy-700;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Переезд внутри карточки точки — отдельная плашка с фоном, чтобы её не пропускали.
|
||||||
|
.leg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
padding: $sp-3;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
background: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './LegFields.module.scss';
|
||||||
|
import { FormField } from '../../../../shared/ui';
|
||||||
|
import { IRouteLeg, legLabel } from '../../route';
|
||||||
|
import { fieldKey } from '../../validation';
|
||||||
|
import { TransportChips } from '../TransportChips';
|
||||||
|
import { DepartureTimeChips } from '../DepartureTimeChips';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
leg: IRouteLeg;
|
||||||
|
transport: string;
|
||||||
|
onTransportChange: (value: string) => void;
|
||||||
|
departureTime: string;
|
||||||
|
onDepartureTimeChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переезд «как добраться»: вид транспорта и время выезда (оба обязательны).
|
||||||
|
* Живёт внутри карточки точки, куда едут (обратный — в блоке «Возвращение»):
|
||||||
|
* отдельной строкой на линии маршрута его пропускали при заполнении.
|
||||||
|
*/
|
||||||
|
export const LegFields: React.FC<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => (
|
||||||
|
<div className={styles.leg}>
|
||||||
|
<span className={styles.title}>Как добраться: {legLabel(leg)}</span>
|
||||||
|
<FormField label="Вид транспорта" fieldKey={fieldKey.legTransport(leg.id)} required>
|
||||||
|
<TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Время выезда" fieldKey={fieldKey.legDepartureTime(leg.id)} required>
|
||||||
|
<DepartureTimeChips value={departureTime} onChange={onDepartureTimeChange} ariaLabel={`Время выезда: ${legLabel(leg)}`} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -108,7 +108,11 @@ $marker-size: 26px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Возвращение: переключатель, а под ним — обратный переезд (транспорт, время выезда).
|
||||||
.end {
|
.end {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
padding-top: $sp-2;
|
padding-top: $sp-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RouteEditor.module.scss';
|
import styles from './RouteEditor.module.scss';
|
||||||
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
|
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
|
||||||
import { IRouteStop, ITripRoute } from '../../model';
|
import { CityPicker } from '../../../../shared/dictionary';
|
||||||
import { IRouteLeg, createStop, routeLegs } from '../../route';
|
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
|
||||||
|
import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
import { RouteStopCard } from './RouteStopCard';
|
import { RouteStopCard } from './RouteStopCard';
|
||||||
import { RouteLegRow } from './RouteLegRow';
|
import { LegFields } from './LegFields';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
route: ITripRoute;
|
route: ITripRoute;
|
||||||
@@ -32,9 +33,10 @@ const TimelineRow: React.FC<{ marker: MarkerKind; label?: string; children: Reac
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Маршрут командировки лентой: город отправления → точки (город + объект +
|
* Маршрут командировки лентой: город отправления → точки (город + объект +
|
||||||
* даты) → обратно. Между точками — участки пути с видом транспорта; участки
|
* даты) → обратно. Переезд (транспорт, время выезда) — внутри карточки точки, куда
|
||||||
* не хранятся, а выводятся из точек (route.ts/routeLegs). Простая поездка — одна
|
* едут, обратный — в блоке «Возвращение»: отдельной строкой на линии его
|
||||||
* точка; «+ Добавить точку» — для поездок по нескольким объектам.
|
* пропускали. Участки не хранятся, а выводятся из точек (route.ts/routeLegs).
|
||||||
|
* Простая поездка — одна точка; «+ Добавить точку» — для поездок по нескольким объектам.
|
||||||
*/
|
*/
|
||||||
export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
@@ -46,24 +48,28 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
|||||||
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
|
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
|
||||||
const addStop = (): void =>
|
const addStop = (): void =>
|
||||||
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
|
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
|
||||||
const removeStop = (id: string): void => {
|
// Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
|
||||||
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся.
|
const removeStop = (id: string): void =>
|
||||||
const legTransport: Record<string, string> = {};
|
onChange({
|
||||||
Object.keys(route.legTransport)
|
...route,
|
||||||
.filter((key) => key.split('>').indexOf(id) === -1)
|
stops: route.stops.filter((s) => s.id !== id),
|
||||||
.forEach((key) => {
|
legTransport: withoutStopLegs(route.legTransport, id),
|
||||||
legTransport[key] = route.legTransport[key];
|
legDepartureTime: withoutStopLegs(route.legDepartureTime, id)
|
||||||
});
|
});
|
||||||
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
|
|
||||||
};
|
|
||||||
const setTransport = (id: string, value: string): void =>
|
const setTransport = (id: string, value: string): void =>
|
||||||
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
|
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
|
||||||
|
const setDepartureTime = (id: string, value: string): void =>
|
||||||
|
onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } });
|
||||||
|
|
||||||
const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
|
const legFields = (leg: IRouteLeg | undefined): React.ReactNode =>
|
||||||
leg && (
|
leg && (
|
||||||
<TimelineRow marker="line">
|
<LegFields
|
||||||
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
|
leg={leg}
|
||||||
</TimelineRow>
|
transport={route.legTransport[leg.id] || ''}
|
||||||
|
onTransportChange={(v) => setTransport(leg.id, v)}
|
||||||
|
departureTime={route.legDepartureTime[leg.id] || ''}
|
||||||
|
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -71,29 +77,27 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
|||||||
<TimelineRow marker="endpoint">
|
<TimelineRow marker="endpoint">
|
||||||
<div className={styles.origin}>
|
<div className={styles.origin}>
|
||||||
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
|
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
|
||||||
<TextInput
|
<CityPicker
|
||||||
id={fieldKey.origin}
|
id={fieldKey.origin}
|
||||||
value={route.origin}
|
value={{ name: route.origin, nameKz: route.originKz, kato: route.originKato }}
|
||||||
onChange={(v) => onChange({ ...route, origin: v })}
|
onChange={(city) => onChange({ ...route, origin: city.name, originKz: city.nameKz, originKato: city.kato })}
|
||||||
placeholder="Напр.: Алматы"
|
placeholder={CITY_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRow>
|
</TimelineRow>
|
||||||
|
|
||||||
{route.stops.map((stop, i) => (
|
{route.stops.map((stop, i) => (
|
||||||
<React.Fragment key={stop.id}>
|
<TimelineRow key={stop.id} marker="stop" label={String(i + 1)}>
|
||||||
{legRow(legTo(stop.id))}
|
|
||||||
<TimelineRow marker="stop" label={String(i + 1)}>
|
|
||||||
<RouteStopCard
|
<RouteStopCard
|
||||||
stop={stop}
|
stop={stop}
|
||||||
index={i}
|
index={i}
|
||||||
context={context}
|
context={context}
|
||||||
onChange={(patch) => updateStop(stop.id, patch)}
|
onChange={(patch) => updateStop(stop.id, patch)}
|
||||||
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
|
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
|
||||||
|
inbound={legFields(legTo(stop.id))}
|
||||||
/>
|
/>
|
||||||
</TimelineRow>
|
</TimelineRow>
|
||||||
</React.Fragment>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -105,21 +109,21 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
|||||||
</TimelineRow>
|
</TimelineRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{legRow(returnLeg)}
|
|
||||||
|
|
||||||
<TimelineRow marker="endpoint">
|
<TimelineRow marker="endpoint">
|
||||||
<div className={styles.end}>
|
<div className={styles.end}>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className={styles.endText}>
|
<span className={styles.endText}>
|
||||||
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
|
{route.returnToOrigin ? `Возвращение: ${originLabel(route) || 'город отправления'}` : 'Без возвращения в город отправления'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={route.returnToOrigin}
|
checked={route.returnToOrigin}
|
||||||
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
|
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
|
||||||
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
|
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Обратный переезд — здесь: своей карточки точки у него нет. */}
|
||||||
|
{legFields(returnLeg)}
|
||||||
</div>
|
</div>
|
||||||
</TimelineRow>
|
</TimelineRow>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
@import '../../../../../../styles/design-tokens';
|
|
||||||
|
|
||||||
// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута.
|
|
||||||
.leg {
|
|
||||||
padding-block: $sp-2;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import styles from './RouteLegRow.module.scss';
|
|
||||||
import { FormField } from '../../../../shared/ui';
|
|
||||||
import { IRouteLeg, legLabel } from '../../route';
|
|
||||||
import { fieldKey } from '../../validation';
|
|
||||||
import { TransportChips } from '../TransportChips';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
leg: IRouteLeg;
|
|
||||||
transport: string;
|
|
||||||
onTransportChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Участок пути между точками на линии маршрута: «Алматы → Атырау» и вид транспорта. */
|
|
||||||
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange }) => (
|
|
||||||
<div className={styles.leg}>
|
|
||||||
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
|
|
||||||
<TransportChips
|
|
||||||
value={transport}
|
|
||||||
onChange={onTransportChange}
|
|
||||||
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './RouteScheme.module.scss';
|
import styles from './RouteScheme.module.scss';
|
||||||
import { IEstimateArticle, ITripRoute } from '../../model';
|
import { IEstimateArticle, ITripRoute } from '../../model';
|
||||||
import { routeLegs, routePeriod, routeProjects } from '../../route';
|
import { originLabel, routeLegs, routePeriod, routeProjects, stopCityLabel } from '../../route';
|
||||||
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
|
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
|
||||||
import { plural, tripDays } from '../../tripData';
|
import { plural, tripDays } from '../../tripData';
|
||||||
import { ISchemeNode } from './schemeNode';
|
import { ISchemeNode } from './schemeNode';
|
||||||
@@ -18,14 +18,14 @@ interface Props {
|
|||||||
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
|
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
|
||||||
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
|
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
|
||||||
const period = routePeriod(route);
|
const period = routePeriod(route);
|
||||||
const origin = route.origin.trim();
|
const origin = originLabel(route);
|
||||||
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
|
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
|
||||||
|
|
||||||
const nodes: ISchemeNode[] = [
|
const nodes: ISchemeNode[] = [
|
||||||
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
|
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
|
||||||
];
|
];
|
||||||
route.stops.forEach((stop, i) =>
|
route.stops.forEach((stop, i) =>
|
||||||
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stop.city.trim(), amount: amounts.get(stop.id) || 0 })
|
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stopCityLabel(stop), amount: amounts.get(stop.id) || 0 })
|
||||||
);
|
);
|
||||||
if (route.returnToOrigin) {
|
if (route.returnToOrigin) {
|
||||||
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
|
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
|
||||||
@@ -56,7 +56,7 @@ export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.scheme}>
|
<div className={styles.scheme}>
|
||||||
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} onSelectStop={onSelectStop} />
|
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} legDepartureTime={route.legDepartureTime} onSelectStop={onSelectStop} />
|
||||||
<div className={styles.stats}>{stats.join(' · ')}</div>
|
<div className={styles.stats}>{stats.join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './RouteStopCard.module.scss';
|
import styles from './RouteStopCard.module.scss';
|
||||||
import { FormField, Icon, TextInput } from '../../../../shared/ui';
|
import { FormField, Icon, TextInput, useFormReadOnly } from '../../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../../shared/dictionary';
|
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||||
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
|
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
|
||||||
import { stopNights } from '../../route';
|
import { stopLocationPatch, stopNights } from '../../route';
|
||||||
|
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
|
||||||
|
import { StopPlaceFields } from './StopPlaceFields';
|
||||||
import { plural } from '../../tripData';
|
import { plural } from '../../tripData';
|
||||||
|
import { todaySubmitDate } from '../../../../shared/submitDate';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,6 +18,8 @@ interface Props {
|
|||||||
onChange: (patch: Partial<IRouteStop>) => void;
|
onChange: (patch: Partial<IRouteStop>) => void;
|
||||||
/** Не передан — точку удалить нельзя (она единственная). */
|
/** Не передан — точку удалить нельзя (она единственная). */
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
/** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */
|
||||||
|
inbound?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
|
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
|
||||||
@@ -22,8 +27,20 @@ export function stopCardDomId(stopId: string): string {
|
|||||||
return `route-stop-${stopId}`;
|
return `route-stop-${stopId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Точка маршрута: город, объект из справочника и даты пребывания. */
|
type StopLocation = 'domestic' | 'abroad';
|
||||||
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
|
|
||||||
|
const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
|
||||||
|
{ value: 'domestic', label: 'Казахстан' },
|
||||||
|
{ value: 'abroad', label: 'Другая страна' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка маршрута: Казахстан или другая страна, город, объект из справочника, даты
|
||||||
|
* пребывания и переезд сюда (транспорт, время выезда). В режиме чтения переключатель
|
||||||
|
* не нужен — страна видна в полях.
|
||||||
|
*/
|
||||||
|
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove, inbound }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
const nights = stopNights(stop);
|
const nights = stopNights(stop);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,11 +56,23 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<SegmentedSwitch
|
||||||
|
value={stop.abroad ? 'abroad' : 'domestic'}
|
||||||
|
options={LOCATIONS}
|
||||||
|
onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
|
||||||
|
ariaLabel={`Где точка ${index + 1}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
|
<StopPlaceFields stop={stop} onChange={onChange} />
|
||||||
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
|
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
|
||||||
</FormField>
|
<FormField
|
||||||
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
|
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
|
||||||
|
htmlFor={fieldKey.stopProject(stop)}
|
||||||
|
required
|
||||||
|
full={stop.abroad}
|
||||||
|
>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id={fieldKey.stopProject(stop)}
|
id={fieldKey.stopProject(stop)}
|
||||||
context={context}
|
context={context}
|
||||||
@@ -58,6 +87,8 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
|||||||
type="date"
|
type="date"
|
||||||
value={stop.dateFrom}
|
value={stop.dateFrom}
|
||||||
onChange={(v) => onChange({ dateFrom: v })}
|
onChange={(v) => onChange({ dateFrom: v })}
|
||||||
|
// Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts.
|
||||||
|
min={todaySubmitDate()}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
|
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
|
||||||
@@ -66,10 +97,12 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
|||||||
type="date"
|
type="date"
|
||||||
value={stop.dateTo}
|
value={stop.dateTo}
|
||||||
onChange={(v) => onChange({ dateTo: v })}
|
onChange={(v) => onChange({ dateTo: v })}
|
||||||
min={stop.dateFrom || undefined}
|
min={stop.dateFrom || todaySubmitDate()}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
{/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */}
|
||||||
|
{inbound}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './RouteViewSwitch.module.scss';
|
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
|
||||||
|
|
||||||
export type RouteViewMode = 'form' | 'scheme';
|
export type RouteViewMode = 'form' | 'scheme';
|
||||||
|
|
||||||
@@ -8,25 +8,12 @@ interface Props {
|
|||||||
onChange: (mode: RouteViewMode) => void;
|
onChange: (mode: RouteViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
|
const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
|
||||||
{ mode: 'form', label: 'Форма' },
|
{ value: 'form', label: 'Форма' },
|
||||||
{ mode: 'scheme', label: 'Схема' }
|
{ value: 'scheme', label: 'Схема' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
|
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
|
||||||
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
|
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
|
||||||
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
|
<SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
|
||||||
{MODES.map(({ mode, label }) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={value === mode}
|
|
||||||
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
|
|
||||||
onClick={() => onChange(mode)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
|
|||||||
const dates = stopDates(stop);
|
const dates = stopDates(stop);
|
||||||
return (
|
return (
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
|
<span className={styles.city}>{node.city || `Город ${NOT_SET}`}</span>
|
||||||
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
|
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
|
||||||
<span className={styles.project} title={stop.project.trim() || undefined}>
|
<span className={styles.project} title={stop.project.trim() || undefined}>
|
||||||
{stop.project.trim() || 'объект не выбран'}
|
{stop.project.trim() || 'объект не выбран'}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface Props {
|
|||||||
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
|
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
|
||||||
legs: IRouteLeg[];
|
legs: IRouteLeg[];
|
||||||
legTransport: Record<string, string>;
|
legTransport: Record<string, string>;
|
||||||
|
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
|
||||||
|
legDepartureTime: Record<string, string>;
|
||||||
onSelectStop?: (stopId: string) => void;
|
onSelectStop?: (stopId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
|
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
|
||||||
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
|
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
|
||||||
const count = nodes.length;
|
const count = nodes.length;
|
||||||
return (
|
return (
|
||||||
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
|
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
|
||||||
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
|
|||||||
</svg>
|
</svg>
|
||||||
{legs.map((leg, i) => (
|
{legs.map((leg, i) => (
|
||||||
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
|
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
|
||||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
|
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +132,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
|
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
|
||||||
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => (
|
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => (
|
||||||
<div className={styles.vTrack}>
|
<div className={styles.vTrack}>
|
||||||
{nodes.map((node, i) => {
|
{nodes.map((node, i) => {
|
||||||
const leg = legs[i];
|
const leg = legs[i];
|
||||||
@@ -151,7 +153,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectSto
|
|||||||
<div className={styles.vRow}>
|
<div className={styles.vRow}>
|
||||||
<span className={styles.vRail} />
|
<span className={styles.vRail} />
|
||||||
<span className={styles.vLeg}>
|
<span className={styles.vLeg}>
|
||||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
|
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormField, TextInput } from '../../../../shared/ui';
|
||||||
|
import { CityPicker } from '../../../../shared/dictionary';
|
||||||
|
import { ABROAD_CITY_PLACEHOLDER, CITY_PLACEHOLDER, COUNTRY_PLACEHOLDER, IRouteStop } from '../../model';
|
||||||
|
import { fieldKey } from '../../validation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stop: IRouteStop;
|
||||||
|
onChange: (patch: Partial<IRouteStop>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код);
|
||||||
|
* за рубежом — страна и город вручную: справочник только по РК.
|
||||||
|
* Рендерит ячейки сетки карточки точки (без своей обёртки).
|
||||||
|
*/
|
||||||
|
export const StopPlaceFields: React.FC<Props> = ({ stop, onChange }) => {
|
||||||
|
if (!stop.abroad) {
|
||||||
|
return (
|
||||||
|
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
|
||||||
|
<CityPicker
|
||||||
|
id={fieldKey.stopCity(stop)}
|
||||||
|
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
|
||||||
|
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
|
||||||
|
placeholder={CITY_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Страна" htmlFor={fieldKey.stopCountry(stop)} required>
|
||||||
|
<TextInput
|
||||||
|
id={fieldKey.stopCountry(stop)}
|
||||||
|
value={stop.country}
|
||||||
|
onChange={(v) => onChange({ country: v })}
|
||||||
|
placeholder={COUNTRY_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Город" htmlFor={fieldKey.stopCity(stop)} required>
|
||||||
|
<TextInput
|
||||||
|
id={fieldKey.stopCity(stop)}
|
||||||
|
value={stop.city}
|
||||||
|
onChange={(v) => onChange({ city: v })}
|
||||||
|
placeholder={ABROAD_CITY_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,13 +7,20 @@ import { TransportIcon } from '../TransportIcon';
|
|||||||
interface Props {
|
interface Props {
|
||||||
leg: IRouteLeg;
|
leg: IRouteLeg;
|
||||||
transport: string;
|
transport: string;
|
||||||
|
/** Время выезда; '' — не указано. */
|
||||||
|
departureTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
|
/**
|
||||||
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
|
* Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
|
||||||
|
* выезда — в подсказке (на линию не помещаются).
|
||||||
|
*/
|
||||||
|
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
|
||||||
<span
|
<span
|
||||||
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
|
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
|
||||||
title={`${leg.from} → ${leg.to}${transport ? `: ${transport}` : ''}`}
|
title={[`${leg.from} → ${leg.to}`, transport, departureTime && `выезд: ${departureTime.toLowerCase()}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
>
|
>
|
||||||
<TransportIcon type={transport} className={styles.transportIcon} />
|
<TransportIcon type={transport} className={styles.transportIcon} />
|
||||||
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
|
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface ISchemeNode {
|
|||||||
number?: number;
|
number?: number;
|
||||||
/** Сама точка — только у точки маршрута. */
|
/** Сама точка — только у точки маршрута. */
|
||||||
stop?: IRouteStop;
|
stop?: IRouteStop;
|
||||||
/** Город отправления — у endpoint. */
|
/** Город на двух языках (см. route.ts/stopCityLabel): отправления — у endpoint, точки — у stop. */
|
||||||
city: string;
|
city: string;
|
||||||
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
|
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model';
|
import {
|
||||||
|
EMPTY_ESTIMATE,
|
||||||
|
EMPTY_ROUTE,
|
||||||
|
IEstimateArticle,
|
||||||
|
IEstimateItem,
|
||||||
|
ITripRoute,
|
||||||
|
MAX_MRP_PER_DAY,
|
||||||
|
MAX_NIGHT_PRICE,
|
||||||
|
MRP_VALUE
|
||||||
|
} from './model';
|
||||||
import {
|
import {
|
||||||
addItem,
|
addItem,
|
||||||
articleAmount,
|
articleAmount,
|
||||||
@@ -7,22 +16,28 @@ import {
|
|||||||
estimateTotal,
|
estimateTotal,
|
||||||
formatEstimateText,
|
formatEstimateText,
|
||||||
itemCalculation,
|
itemCalculation,
|
||||||
|
mrpLimit,
|
||||||
|
nightPriceLimit,
|
||||||
parseEstimate,
|
parseEstimate,
|
||||||
projectShares,
|
projectShares,
|
||||||
resolveLinkId,
|
resolveLinkId,
|
||||||
|
routeItemAutoQuantity,
|
||||||
serializeEstimate,
|
serializeEstimate,
|
||||||
stopTotals,
|
stopTotals,
|
||||||
syncPerDiem
|
syncRouteItems
|
||||||
} from './estimate';
|
} from './estimate';
|
||||||
|
|
||||||
const ROUTE: ITripRoute = {
|
const ROUTE: ITripRoute = {
|
||||||
origin: 'Алматы',
|
origin: 'Алматы',
|
||||||
|
originKz: '',
|
||||||
|
originKato: '',
|
||||||
stops: [
|
stops: [
|
||||||
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||||
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||||
],
|
],
|
||||||
returnToOrigin: true,
|
returnToOrigin: true,
|
||||||
legTransport: {}
|
legTransport: {},
|
||||||
|
legDepartureTime: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
||||||
@@ -34,6 +49,7 @@ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
|||||||
price: '',
|
price: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
mrpCount: '',
|
mrpCount: '',
|
||||||
|
quantityManual: '',
|
||||||
...patch
|
...patch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +137,7 @@ describe('суточные по маршруту', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('по позиции на точку; день переезда считается один раз', () => {
|
it('по позиции на точку; день переезда считается один раз', () => {
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
||||||
['s1', '4'],
|
['s1', '4'],
|
||||||
['s2', '2']
|
['s2', '2']
|
||||||
@@ -129,32 +145,58 @@ describe('суточные по маршруту', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
|
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
|
expect(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
|
||||||
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
|
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
|
||||||
const [oneStop] = syncPerDiem([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
|
const [oneStop] = syncRouteItems([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
|
||||||
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
|
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без дат дней нет — и суммы нет', () => {
|
it('без дат дней нет — и суммы нет', () => {
|
||||||
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
|
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
|
||||||
const [synced] = syncPerDiem([perDiem], noDates);
|
const [synced] = syncRouteItems([perDiem], noDates);
|
||||||
expect(synced.items[0].quantity).toBe('');
|
expect(synced.items[0].quantity).toBe('');
|
||||||
expect(articleAmount(synced)).toBe(0);
|
expect(articleAmount(synced)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('другие статьи не трогает', () => {
|
it('статьи с ручными позициями не трогает', () => {
|
||||||
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
|
expect(syncRouteItems([travel], ROUTE)[0]).toBe(travel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('проживание: позиция на точку, ночи из дат, введённая цена сохраняется', () => {
|
||||||
|
const [synced] = syncRouteItems([hotels], ROUTE);
|
||||||
|
expect(synced.items.map((i) => [i.linkId, i.quantity, i.price])).toEqual([
|
||||||
|
['s1', '3', '36000'],
|
||||||
|
['s2', '2', '35000']
|
||||||
|
]);
|
||||||
|
expect(synced.items[0]).toMatchObject({ id: 'h1', mode: 'calc', comment: 'Ренессанс' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дни/ночи вручную — любое число поверх расчёта по датам; пусто — снова по датам', () => {
|
||||||
|
const manual = { ...perDiem, items: [{ ...perDiem.items[0], quantityManual: '7' }] };
|
||||||
|
const [synced] = syncRouteItems([manual], ROUTE);
|
||||||
|
expect(synced.items[0].quantity).toBe('7');
|
||||||
|
expect(articleAmount({ ...synced, items: [synced.items[0]] })).toBe(7 * 6 * MRP_VALUE);
|
||||||
|
expect(itemCalculation('perDiem', synced.items[0])).toContain('7 дней (вручную)');
|
||||||
|
const [auto] = syncRouteItems([{ ...manual, items: [{ ...manual.items[0], quantityManual: '' }] }], ROUTE);
|
||||||
|
expect(auto.items[0].quantity).toBe('4');
|
||||||
|
expect(routeItemAutoQuantity('accommodation', ROUTE, 's1')).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('лимиты зависят от страны точки', () => {
|
||||||
|
const abroad = { ...ROUTE.stops[0], abroad: true };
|
||||||
|
expect([mrpLimit(ROUTE.stops[0]), mrpLimit(abroad)]).toEqual([MAX_MRP_PER_DAY.domestic, MAX_MRP_PER_DAY.abroad]);
|
||||||
|
expect([nightPriceLimit(ROUTE.stops[0]), nightPriceLimit(abroad)]).toEqual([MAX_NIGHT_PRICE.domestic, MAX_NIGHT_PRICE.abroad]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
||||||
const NBSP = String.fromCharCode(160);
|
const NBSP = String.fromCharCode(160);
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
|
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
|
||||||
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
EstimateKind,
|
EstimateKind,
|
||||||
IEstimateArticle,
|
IEstimateArticle,
|
||||||
IEstimateItem,
|
IEstimateItem,
|
||||||
|
IRouteStop,
|
||||||
ITripRoute,
|
ITripRoute,
|
||||||
|
MAX_MRP_PER_DAY,
|
||||||
|
MAX_NIGHT_PRICE,
|
||||||
MRP_VALUE
|
MRP_VALUE
|
||||||
} from './model';
|
} from './model';
|
||||||
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
|
import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route';
|
||||||
import { plural } from './tripData';
|
import { plural } from './tripData';
|
||||||
|
|
||||||
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
||||||
@@ -54,10 +57,15 @@ function itemMrpValue(item: IEstimateItem): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
|
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
|
||||||
|
/** Пометка у дней/ночей, введённых вручную, — согласующему важно видеть, что их правили. */
|
||||||
|
function manualMark(item: IEstimateItem): string {
|
||||||
|
return item.quantityManual ? ' (вручную)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function perDiemCalculation(item: IEstimateItem): string {
|
function perDiemCalculation(item: IEstimateItem): string {
|
||||||
if (!item.quantity || !toNumber(item.mrpCount)) return '';
|
if (!item.quantity || !toNumber(item.mrpCount)) return '';
|
||||||
const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']);
|
const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']);
|
||||||
return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
|
return `${days}${manualMark(item)} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,9 +74,9 @@ function perDiemCalculation(item: IEstimateItem): string {
|
|||||||
* понятно из статьи. Суточные — исключение: там важно показать МРП.
|
* понятно из статьи. Суточные — исключение: там важно показать МРП.
|
||||||
*/
|
*/
|
||||||
export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string {
|
export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string {
|
||||||
if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item);
|
if (kind === 'perDiem') return perDiemCalculation(item);
|
||||||
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
|
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
|
||||||
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
|
return `${formatAmount(toNumber(item.quantity))}${manualMark(item)} × ${formatAmount(toNumber(item.price))} ₸`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -116,7 +124,8 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
|||||||
quantity: '',
|
quantity: '',
|
||||||
price: '',
|
price: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
mrpCount: ''
|
mrpCount: '',
|
||||||
|
quantityManual: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +141,22 @@ export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateA
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Суточные — строятся по маршруту
|
// Суточные и проживание — строятся по маршруту
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Сколько МРП в день можно поставить в точке: по Казахстану и за рубежом лимиты разные. */
|
||||||
|
export function mrpLimit(stop: IRouteStop | undefined): number {
|
||||||
|
return stop?.abroad ? MAX_MRP_PER_DAY.abroad : MAX_MRP_PER_DAY.domestic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Предельная цена ночи в точке, ₸; undefined — без лимита (за рубежом пока не задан). */
|
||||||
|
export function nightPriceLimit(stop: IRouteStop | undefined): number | undefined {
|
||||||
|
return stop?.abroad ? MAX_NIGHT_PRICE.abroad : MAX_NIGHT_PRICE.domestic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Число или пустая строка для поля: undefined (дат нет) — ''. */
|
||||||
|
const countText = (count: number | undefined): string => (count === undefined ? '' : String(count));
|
||||||
|
|
||||||
/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */
|
/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */
|
||||||
function perDiemRate(mrpCount: string): string {
|
function perDiemRate(mrpCount: string): string {
|
||||||
const count = toNumber(mrpCount);
|
const count = toNumber(mrpCount);
|
||||||
@@ -142,26 +164,39 @@ function perDiemRate(mrpCount: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна
|
* Дни/ночи позиции «по маршруту» по датам точки: суточные — дни (день переезда
|
||||||
* позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE.
|
* один раз), проживание — ночи. '' — даты точки не заданы или точки нет.
|
||||||
* Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой.
|
*/
|
||||||
|
export function routeItemAutoQuantity(kind: EstimateKind, route: ITripRoute, stopId: string): string {
|
||||||
|
const index = route.stops.findIndex((s) => s.id === stopId);
|
||||||
|
if (index === -1) return '';
|
||||||
|
return kind === 'accommodation' ? countText(stopNights(route.stops[index])) : countText(stopPerDiemDays(route, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Количество и цена позиции «по маршруту»: дни/ночи — введённые вручную, иначе по
|
||||||
|
* датам точки; цена — суточные: кол-во МРП × MRP_VALUE, проживание: что ввёл человек.
|
||||||
|
*/
|
||||||
|
function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, stopId: string): Partial<IEstimateItem> {
|
||||||
|
const quantity = base.quantityManual || routeItemAutoQuantity(kind, route, stopId);
|
||||||
|
return { quantity, price: kind === 'accommodation' ? base.price : perDiemRate(base.mrpCount) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиции статей «по маршруту» (суточные, проживание) приводятся к маршруту:
|
||||||
|
* ровно одна позиция на точку, дни/ночи — из дат точки. Человек вводит только
|
||||||
|
* кол-во МРП или цену за ночь (и пояснение) — они сохраняются за точкой.
|
||||||
* Применяется в форме при каждом рендере и перед сохранением; просмотр берёт
|
* Применяется в форме при каждом рендере и перед сохранением; просмотр берёт
|
||||||
* сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП.
|
* сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП.
|
||||||
*/
|
*/
|
||||||
export function syncPerDiem(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
|
export function syncRouteItems(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
|
||||||
return articles.map((article) => {
|
return articles.map((article) => {
|
||||||
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
|
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
|
||||||
const items = route.stops.map((stop, index): IEstimateItem => {
|
const items = route.stops.map((stop): IEstimateItem => {
|
||||||
const existing = article.items.find((item) => item.linkId === stop.id);
|
const existing = article.items.find((item) => item.linkId === stop.id);
|
||||||
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
||||||
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
||||||
const days = stopPerDiemDays(route, index);
|
return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, stop.id) };
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
mode: 'calc',
|
|
||||||
quantity: days === undefined ? '' : String(days),
|
|
||||||
price: perDiemRate(base.mrpCount)
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
return { ...article, items };
|
return { ...article, items };
|
||||||
});
|
});
|
||||||
@@ -314,7 +349,8 @@ function toItem(value: unknown): IEstimateItem | undefined {
|
|||||||
quantity: text(raw.quantity),
|
quantity: text(raw.quantity),
|
||||||
price: text(raw.price),
|
price: text(raw.price),
|
||||||
comment: text(raw.comment),
|
comment: text(raw.comment),
|
||||||
mrpCount: text(raw.mrpCount)
|
mrpCount: text(raw.mrpCount),
|
||||||
|
quantityManual: text(raw.quantityManual)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
'SubmitDate',
|
'SubmitDate',
|
||||||
'Position',
|
'Position',
|
||||||
'Organization',
|
'Organization',
|
||||||
|
'TripCompany',
|
||||||
|
'TripCompanyId',
|
||||||
'TripRouteJson',
|
'TripRouteJson',
|
||||||
'TripPurpose',
|
|
||||||
'TripTasksJson',
|
'TripTasksJson',
|
||||||
'TripEstimateJson',
|
'TripEstimateJson',
|
||||||
|
'TripComment',
|
||||||
'TripObligationAccepted',
|
'TripObligationAccepted',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
@@ -47,10 +49,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
Employee: employee?.EMail || '',
|
Employee: employee?.EMail || '',
|
||||||
Position: (item.Position as string) || '',
|
Position: (item.Position as string) || '',
|
||||||
Organization: (item.Organization as string) || '',
|
Organization: (item.Organization as string) || '',
|
||||||
|
TripCompany: (item.TripCompany as string) || '',
|
||||||
|
TripCompanyId: (item.TripCompanyId as string) || '',
|
||||||
Route: parseRoute(item.TripRouteJson),
|
Route: parseRoute(item.TripRouteJson),
|
||||||
TripPurpose: (item.TripPurpose as string) || '',
|
|
||||||
Tasks: parseTasks(item.TripTasksJson),
|
Tasks: parseTasks(item.TripTasksJson),
|
||||||
Estimate: parseEstimate(item.TripEstimateJson),
|
Estimate: parseEstimate(item.TripEstimateJson),
|
||||||
|
TripComment: (item.TripComment as string) || '',
|
||||||
TripObligationAccepted: item.TripObligationAccepted === true
|
TripObligationAccepted: item.TripObligationAccepted === true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
|
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import type { RequestTypeId } from '../../models/types';
|
import type { RequestTypeId } from '../../models/types';
|
||||||
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
@@ -19,10 +20,26 @@ export const FORM_TITLE = 'Заявка на командировку';
|
|||||||
export interface IRouteStop {
|
export interface IRouteStop {
|
||||||
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
|
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
|
||||||
|
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
|
||||||
|
* маршруты (Алматы → Москва → Атырау).
|
||||||
|
*/
|
||||||
|
abroad: boolean;
|
||||||
|
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
|
||||||
|
country: string;
|
||||||
|
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
|
||||||
city: string;
|
city: string;
|
||||||
|
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
|
||||||
|
cityKz: string;
|
||||||
|
/** Код КАТО города; '' — введён вручную. */
|
||||||
|
cityKato: string;
|
||||||
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
|
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
|
||||||
project: string;
|
project: string;
|
||||||
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
|
/**
|
||||||
|
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
|
||||||
|
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
|
||||||
|
*/
|
||||||
projectId: string;
|
projectId: string;
|
||||||
/** YYYY-MM-DD — прибытие в точку. */
|
/** YYYY-MM-DD — прибытие в точку. */
|
||||||
dateFrom: string;
|
dateFrom: string;
|
||||||
@@ -37,18 +54,37 @@ export interface IRouteStop {
|
|||||||
* участка по его ключу.
|
* участка по его ключу.
|
||||||
*/
|
*/
|
||||||
export interface ITripRoute {
|
export interface ITripRoute {
|
||||||
/** Город отправления (обычно город работы). */
|
/** Город отправления (обычно город работы), на русском. */
|
||||||
origin: string;
|
origin: string;
|
||||||
|
/** Город отправления на казахском; '' — введён вручную. */
|
||||||
|
originKz: string;
|
||||||
|
/** Код КАТО города отправления; '' — введён вручную. */
|
||||||
|
originKato: string;
|
||||||
stops: IRouteStop[];
|
stops: IRouteStop[];
|
||||||
/** Обратный путь из последней точки в город отправления. */
|
/** Обратный путь из последней точки в город отправления. */
|
||||||
returnToOrigin: boolean;
|
returnToOrigin: boolean;
|
||||||
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
|
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
|
||||||
legTransport: Record<string, string>;
|
legTransport: Record<string, string>;
|
||||||
|
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
|
||||||
|
legDepartureTime: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
|
||||||
|
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
|
||||||
|
|
||||||
/** Ключ «точки» города отправления в ключах участков. */
|
/** Ключ «точки» города отправления в ключах участков. */
|
||||||
export const ORIGIN_ID = 'origin';
|
export const ORIGIN_ID = 'origin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вид поездки — значения Choice-колонки TripKind. Выводится из точек: хотя бы одна
|
||||||
|
* за рубежом — вся поездка зарубежная (по ней будут маршрут согласования и отчёты).
|
||||||
|
*/
|
||||||
|
export const TRIP_KIND = {
|
||||||
|
domestic: 'По Казахстану',
|
||||||
|
abroad: 'Зарубежная'
|
||||||
|
} as const;
|
||||||
|
export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
|
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
|
||||||
* взяты типовые; поправить здесь, если кадры используют другие.
|
* взяты типовые; поправить здесь, если кадры используют другие.
|
||||||
@@ -56,7 +92,7 @@ export const ORIGIN_ID = 'origin';
|
|||||||
export const TRANSPORT_TYPES: ReadonlyArray<string> = [
|
export const TRANSPORT_TYPES: ReadonlyArray<string> = [
|
||||||
'Авиа',
|
'Авиа',
|
||||||
'Железнодорожный',
|
'Железнодорожный',
|
||||||
'Автобус',
|
'Такси',
|
||||||
'Служебный автотранспорт',
|
'Служебный автотранспорт',
|
||||||
'Личный автотранспорт'
|
'Личный автотранспорт'
|
||||||
];
|
];
|
||||||
@@ -65,7 +101,7 @@ export const TRANSPORT_TYPES: ReadonlyArray<string> = [
|
|||||||
export const TRANSPORT_SHORT: Record<string, string> = {
|
export const TRANSPORT_SHORT: Record<string, string> = {
|
||||||
Авиа: 'Авиа',
|
Авиа: 'Авиа',
|
||||||
Железнодорожный: 'Ж/д',
|
Железнодорожный: 'Ж/д',
|
||||||
Автобус: 'Автобус',
|
Такси: 'Такси',
|
||||||
'Служебный автотранспорт': 'Служебное авто',
|
'Служебный автотранспорт': 'Служебное авто',
|
||||||
'Личный автотранспорт': 'Личное авто'
|
'Личный автотранспорт': 'Личное авто'
|
||||||
};
|
};
|
||||||
@@ -101,9 +137,15 @@ export interface IEstimateItem {
|
|||||||
/**
|
/**
|
||||||
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
||||||
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
||||||
* estimate.ts/syncPerDiem. У остальных статей — ''.
|
* estimate.ts/syncRouteItems. У остальных статей — ''.
|
||||||
*/
|
*/
|
||||||
mrpCount: string;
|
mrpCount: string;
|
||||||
|
/**
|
||||||
|
* Только у позиций «по маршруту» (суточные, проживание): дни/ночи, введённые
|
||||||
|
* вручную поверх расчёта по датам — бывает форс-мажор (задержался в точке).
|
||||||
|
* Любое число, без ограничений. '' — считать по датам точки.
|
||||||
|
*/
|
||||||
|
quantityManual: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Статья сметы — заголовок с позициями внутри. */
|
/** Статья сметы — заголовок с позициями внутри. */
|
||||||
@@ -130,7 +172,7 @@ export interface IEstimateKindInfo {
|
|||||||
link: EstimateLinkTarget;
|
link: EstimateLinkTarget;
|
||||||
/**
|
/**
|
||||||
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
||||||
* одной на точку) и считаются сами — так устроены суточные.
|
* одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи).
|
||||||
*/
|
*/
|
||||||
itemsFromRoute: boolean;
|
itemsFromRoute: boolean;
|
||||||
quantityLabel: string;
|
quantityLabel: string;
|
||||||
@@ -168,7 +210,8 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: true,
|
core: true,
|
||||||
defaultMode: 'calc',
|
defaultMode: 'calc',
|
||||||
link: 'stop',
|
link: 'stop',
|
||||||
itemsFromRoute: false,
|
// Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров).
|
||||||
|
itemsFromRoute: true,
|
||||||
quantityLabel: 'Ночей',
|
quantityLabel: 'Ночей',
|
||||||
priceLabel: 'Цена за ночь',
|
priceLabel: 'Цена за ночь',
|
||||||
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
||||||
@@ -228,6 +271,21 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
|
|||||||
export const MRP_VALUE = 4325;
|
export const MRP_VALUE = 4325;
|
||||||
export const MRP_YEAR = 2026;
|
export const MRP_YEAR = 2026;
|
||||||
|
|
||||||
|
/** Самая длинная командировка, дней включительно (требования кадров 10-23). */
|
||||||
|
export const MAX_TRIP_DAYS = 40;
|
||||||
|
|
||||||
|
/** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */
|
||||||
|
export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лимит цены проживания за ночь, ₸. За рубежом кадры лимит пока не назвали —
|
||||||
|
* undefined значит «без лимита»; когда назовут, вписать число сюда.
|
||||||
|
*/
|
||||||
|
export const MAX_NIGHT_PRICE: { domestic: number; abroad: number | undefined } = {
|
||||||
|
domestic: 15000,
|
||||||
|
abroad: undefined
|
||||||
|
};
|
||||||
|
|
||||||
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
|
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
|
||||||
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
|
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
|
||||||
|
|
||||||
@@ -253,15 +311,23 @@ export interface IFormData {
|
|||||||
Position: string;
|
Position: string;
|
||||||
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
|
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
|
||||||
Organization: string;
|
Organization: string;
|
||||||
|
/**
|
||||||
|
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
|
||||||
|
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
|
||||||
|
*/
|
||||||
|
TripCompany: string;
|
||||||
|
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
|
||||||
|
TripCompanyId: string;
|
||||||
|
|
||||||
Route: ITripRoute;
|
Route: ITripRoute;
|
||||||
TripPurpose: string;
|
|
||||||
|
|
||||||
/** Задание на командировку — список задач по порядку. */
|
/** Задание на командировку — список задач по порядку. */
|
||||||
Tasks: string[];
|
Tasks: string[];
|
||||||
|
|
||||||
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
|
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
|
||||||
Estimate: IEstimateArticle[];
|
Estimate: IEstimateArticle[];
|
||||||
|
/** Комментарий к заявке — после итога сметы, необязательный. */
|
||||||
|
TripComment: string;
|
||||||
|
|
||||||
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
|
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
|
||||||
TripObligationAccepted: boolean;
|
TripObligationAccepted: boolean;
|
||||||
@@ -270,28 +336,40 @@ export interface IFormData {
|
|||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||||
|
|
||||||
|
/** Сотрудник заявки и выбор организации, от которой он едет (см. useTripEmployee). */
|
||||||
|
export interface ITripEmployee {
|
||||||
|
/** Организации текущего сотрудника — варианты «Командирующей организации». */
|
||||||
|
organizations: IEmployeeOrganization[];
|
||||||
|
onEmployeeChange: (login: string) => void;
|
||||||
|
/** key — значение варианта (см. tripCompany.ts/companyKey); '' — сбросить выбор. */
|
||||||
|
onCompanyChange: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
|
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
|
||||||
export interface ISectionProps {
|
export interface ISectionProps {
|
||||||
data: IFormData;
|
data: IFormData;
|
||||||
set: SetField;
|
set: SetField;
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
/**
|
/**
|
||||||
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
|
* Выбор сотрудника и организации подтягивает должность и организации — поэтому
|
||||||
* отдельный обработчик. Не передан (режим чтения) — правка через set.
|
* у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
|
||||||
*/
|
*/
|
||||||
onEmployeeChange?: (login: string) => void;
|
employee?: ITripEmployee;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PURPOSE = 'Производственная необходимость';
|
|
||||||
|
|
||||||
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
|
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
|
||||||
export const FIRST_STOP_ID = 'stop-1';
|
export const FIRST_STOP_ID = 'stop-1';
|
||||||
|
|
||||||
export const EMPTY_ROUTE: ITripRoute = {
|
export const EMPTY_ROUTE: ITripRoute = {
|
||||||
origin: '',
|
origin: '',
|
||||||
stops: [{ id: FIRST_STOP_ID, city: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
|
originKz: '',
|
||||||
|
originKato: '',
|
||||||
|
stops: [
|
||||||
|
{ id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
|
||||||
|
],
|
||||||
returnToOrigin: true,
|
returnToOrigin: true,
|
||||||
legTransport: {}
|
legTransport: {},
|
||||||
|
legDepartureTime: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
||||||
@@ -303,7 +381,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
|||||||
quantity: '',
|
quantity: '',
|
||||||
price: '',
|
price: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
mrpCount: ''
|
mrpCount: '',
|
||||||
|
quantityManual: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +406,13 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Employee: '',
|
Employee: '',
|
||||||
Position: '',
|
Position: '',
|
||||||
Organization: '',
|
Organization: '',
|
||||||
|
TripCompany: '',
|
||||||
|
TripCompanyId: '',
|
||||||
Route: EMPTY_ROUTE,
|
Route: EMPTY_ROUTE,
|
||||||
TripPurpose: DEFAULT_PURPOSE,
|
|
||||||
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
|
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
|
||||||
Tasks: [''],
|
Tasks: [''],
|
||||||
Estimate: EMPTY_ESTIMATE,
|
Estimate: EMPTY_ESTIMATE,
|
||||||
|
TripComment: '',
|
||||||
TripObligationAccepted: false
|
TripObligationAccepted: false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,4 +426,7 @@ export const OBLIGATION_TEXT =
|
|||||||
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
|
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
|
||||||
|
|
||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
export const PROJECT_PLACEHOLDER = 'Начните вводить название или код объекта…';
|
export const COUNTRY_PLACEHOLDER = 'Напр.: Китай';
|
||||||
|
export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин';
|
||||||
|
export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…';
|
||||||
|
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
{ "internalName": "Position", "title": "Должность", "type": "Text" },
|
||||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
|
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
|
||||||
|
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
|
||||||
|
|
||||||
|
{ "internalName": "TripKind", "title": "Вид командировки", "type": "Choice", "choices": ["По Казахстану", "Зарубежная"] },
|
||||||
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
|
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
|
||||||
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
|
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
|
||||||
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
|
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
|
||||||
@@ -18,7 +21,6 @@
|
|||||||
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
|
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
|
||||||
{ "internalName": "TripPurpose", "title": "Цель командировки", "type": "Note" },
|
|
||||||
|
|
||||||
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
|
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
|
||||||
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
|
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
|
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
|
||||||
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
|
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
|
||||||
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
|
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
|
||||||
|
{ "internalName": "TripComment", "title": "Комментарий", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
|
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EMPTY_ROUTE, ITripRoute } from './model';
|
import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
|
||||||
import {
|
import {
|
||||||
createStop,
|
createStop,
|
||||||
legLabel,
|
legLabel,
|
||||||
@@ -9,17 +9,23 @@ import {
|
|||||||
routeProjects,
|
routeProjects,
|
||||||
routeText,
|
routeText,
|
||||||
serializeRoute,
|
serializeRoute,
|
||||||
stopNights
|
stopLocationPatch,
|
||||||
|
stopNights,
|
||||||
|
tripKind,
|
||||||
|
withoutStopLegs
|
||||||
} from './route';
|
} from './route';
|
||||||
|
|
||||||
const ROUTE: ITripRoute = {
|
const ROUTE: ITripRoute = {
|
||||||
origin: 'Алматы',
|
origin: 'Алматы',
|
||||||
|
originKz: '',
|
||||||
|
originKato: '',
|
||||||
stops: [
|
stops: [
|
||||||
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||||
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||||
],
|
],
|
||||||
returnToOrigin: true,
|
returnToOrigin: true,
|
||||||
legTransport: { 'origin>s1': 'Авиа' }
|
legTransport: { 'origin>s1': 'Авиа' },
|
||||||
|
legDepartureTime: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('участки пути', () => {
|
describe('участки пути', () => {
|
||||||
@@ -48,10 +54,42 @@ describe('сводка маршрута', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('город из справочника — на двух языках, если названия различаются', () => {
|
||||||
|
const route: ITripRoute = {
|
||||||
|
...ROUTE,
|
||||||
|
origin: 'Уральск',
|
||||||
|
originKz: 'Орал',
|
||||||
|
stops: [{ ...ROUTE.stops[0], city: 'Кульсары', cityKz: 'Құлсары' }, { ...ROUTE.stops[1], cityKz: 'Актау' }],
|
||||||
|
returnToOrigin: false
|
||||||
|
};
|
||||||
|
expect(routeCities(route)).toBe('Кульсары (Құлсары), Актау');
|
||||||
|
expect(routeLegs(route).map(legLabel)).toEqual(['Уральск (Орал) → Кульсары (Құлсары)', 'Кульсары (Құлсары) → Актау']);
|
||||||
|
});
|
||||||
|
|
||||||
it('новая точка начинается с даты отъезда из предыдущей', () => {
|
it('новая точка начинается с даты отъезда из предыдущей', () => {
|
||||||
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
|
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
|
||||||
expect(createStop(undefined).dateFrom).toBe('');
|
expect(createStop(undefined).dateFrom).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('зарубежная точка: страна и город, поездка зарубежная', () => {
|
||||||
|
const beijing = { ...ROUTE.stops[1], abroad: true, country: 'Китай', city: 'Пекин' };
|
||||||
|
const route: ITripRoute = { ...ROUTE, stops: [ROUTE.stops[0], beijing] };
|
||||||
|
expect(routeCities(route)).toBe('Атырау, Китай, Пекин');
|
||||||
|
expect(tripKind(route)).toBe(TRIP_KIND.abroad);
|
||||||
|
expect(tripKind(ROUTE)).toBe(TRIP_KIND.domestic);
|
||||||
|
// Следующая точка — в той же стране, что предыдущая.
|
||||||
|
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('удалили точку — транспорт и время выезда её участков уходят', () => {
|
||||||
|
const times = { 'origin>s1': 'Утро', 's1>s2': 'Вечер', 's2>origin': 'Ночь' };
|
||||||
|
expect(withoutStopLegs(times, 's2')).toEqual({ 'origin>s1': 'Утро' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
|
||||||
|
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
|
||||||
|
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('хранение маршрута', () => {
|
describe('хранение маршрута', () => {
|
||||||
@@ -59,6 +97,15 @@ describe('хранение маршрута', () => {
|
|||||||
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
|
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('старая заявка (до КАТО и признака страны) — город как был, точка по Казахстану', () => {
|
||||||
|
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
|
||||||
|
const route = parseRoute(JSON.stringify(old));
|
||||||
|
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']);
|
||||||
|
expect([route.stops[0].abroad, route.stops[0].country]).toEqual([false, '']);
|
||||||
|
expect(route.legDepartureTime).toEqual({});
|
||||||
|
expect(routeCities(route)).toBe('Атырау');
|
||||||
|
});
|
||||||
|
|
||||||
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
|
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
|
||||||
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
|
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
|
||||||
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
|
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { formatIsoDate } from '../../shared/dateUtils';
|
import { formatIsoDate } from '../../shared/dateUtils';
|
||||||
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
|
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
|
||||||
|
import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
|
||||||
|
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID, TRIP_KIND, TripKind } from './model';
|
||||||
import { tripDays } from './tripData';
|
import { tripDays } from './tripData';
|
||||||
|
|
||||||
/** Участок пути (переезд) между двумя соседними точками маршрута. */
|
/** Участок пути (переезд) между двумя соседними точками маршрута. */
|
||||||
@@ -24,8 +26,36 @@ export function newId(prefix: string): string {
|
|||||||
const ORIGIN_FALLBACK = 'Город отправления';
|
const ORIGIN_FALLBACK = 'Город отправления';
|
||||||
const STOP_FALLBACK = 'Точка маршрута';
|
const STOP_FALLBACK = 'Точка маршрута';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Город точки: по Казахстану — на двух языках «Кульсары (Құлсары)», за рубежом —
|
||||||
|
* страна и город «Китай, Пекин». '' — не указан.
|
||||||
|
*/
|
||||||
|
export function stopCityLabel(stop: IRouteStop): string {
|
||||||
|
if (!stop.abroad) return cityLabel(stop.city, stop.cityKz);
|
||||||
|
return [stop.country.trim(), stop.city.trim()].filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля точки при смене «Казахстан / другая страна». Введённый город остаётся, но
|
||||||
|
* за рубежом он уже не из справочника КАТО (казахское название и код сбрасываются),
|
||||||
|
* а у точки по Казахстану страны нет.
|
||||||
|
*/
|
||||||
|
export function stopLocationPatch(abroad: boolean): Partial<IRouteStop> {
|
||||||
|
return abroad ? { abroad: true, cityKz: '', cityKato: '' } : { abroad: false, country: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вид поездки для колонки TripKind: хотя бы одна точка за рубежом — зарубежная. */
|
||||||
|
export function tripKind(route: ITripRoute): TripKind {
|
||||||
|
return route.stops.some((s) => s.abroad) ? TRIP_KIND.abroad : TRIP_KIND.domestic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Город отправления на двух языках; '' — не указан. */
|
||||||
|
export function originLabel(route: ITripRoute): string {
|
||||||
|
return cityLabel(route.origin, route.originKz);
|
||||||
|
}
|
||||||
|
|
||||||
function stopCity(stop: IRouteStop): string {
|
function stopCity(stop: IRouteStop): string {
|
||||||
return stop.city.trim() || STOP_FALLBACK;
|
return stopCityLabel(stop) || STOP_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
|
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
|
||||||
@@ -40,7 +70,7 @@ export function stopLabel(stop: IRouteStop): string {
|
|||||||
* участки пересобрались сами.
|
* участки пересобрались сами.
|
||||||
*/
|
*/
|
||||||
export function routeLegs(route: ITripRoute): IRouteLeg[] {
|
export function routeLegs(route: ITripRoute): IRouteLeg[] {
|
||||||
const origin = { id: ORIGIN_ID, name: route.origin.trim() || ORIGIN_FALLBACK };
|
const origin = { id: ORIGIN_ID, name: originLabel(route) || ORIGIN_FALLBACK };
|
||||||
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
|
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
|
||||||
if (route.returnToOrigin) points.push(origin);
|
if (route.returnToOrigin) points.push(origin);
|
||||||
|
|
||||||
@@ -91,7 +121,7 @@ const unique = (values: string[]): string[] =>
|
|||||||
|
|
||||||
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
|
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
|
||||||
export function routeCities(route: ITripRoute): string {
|
export function routeCities(route: ITripRoute): string {
|
||||||
return unique(route.stops.map((s) => s.city)).join(', ');
|
return unique(route.stops.map(stopCityLabel)).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Объекты точек (без повторов) — для колонки TripProjects. */
|
/** Объекты точек (без повторов) — для колонки TripProjects. */
|
||||||
@@ -106,23 +136,28 @@ function stopDates(stop: IRouteStop): string {
|
|||||||
|
|
||||||
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
|
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
|
||||||
export function routeText(route: ITripRoute): string {
|
export function routeText(route: ITripRoute): string {
|
||||||
const parts: string[] = [route.origin.trim() || ORIGIN_FALLBACK];
|
const parts: string[] = [originLabel(route) || ORIGIN_FALLBACK];
|
||||||
route.stops.forEach((stop) => {
|
route.stops.forEach((stop) => {
|
||||||
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
|
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
|
||||||
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
|
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
|
||||||
});
|
});
|
||||||
if (route.returnToOrigin) parts.push(route.origin.trim() || ORIGIN_FALLBACK);
|
if (route.returnToOrigin) parts.push(originLabel(route) || ORIGIN_FALLBACK);
|
||||||
return parts.join(' → ');
|
return parts.join(' → ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
|
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
|
||||||
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
|
* страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
|
||||||
|
* человек сам уже ввёл; поля остаются редактируемыми.
|
||||||
*/
|
*/
|
||||||
export function createStop(previous: IRouteStop | undefined): IRouteStop {
|
export function createStop(previous: IRouteStop | undefined): IRouteStop {
|
||||||
return {
|
return {
|
||||||
id: newId('stop'),
|
id: newId('stop'),
|
||||||
|
abroad: previous?.abroad ?? false,
|
||||||
|
country: previous?.abroad ? previous.country : '',
|
||||||
city: '',
|
city: '',
|
||||||
|
cityKz: '',
|
||||||
|
cityKato: '',
|
||||||
project: '',
|
project: '',
|
||||||
projectId: '',
|
projectId: '',
|
||||||
dateFrom: previous?.dateTo || '',
|
dateFrom: previous?.dateTo || '',
|
||||||
@@ -143,7 +178,13 @@ function toStop(value: unknown): IRouteStop | undefined {
|
|||||||
if (!id) return undefined;
|
if (!id) return undefined;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
// Заявки до признака «другая страна» — все точки по Казахстану.
|
||||||
|
abroad: raw.abroad === true,
|
||||||
|
country: text(raw.country),
|
||||||
city: text(raw.city),
|
city: text(raw.city),
|
||||||
|
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
|
||||||
|
cityKz: text(raw.cityKz),
|
||||||
|
cityKato: text(raw.cityKato),
|
||||||
project: text(raw.project),
|
project: text(raw.project),
|
||||||
projectId: text(raw.projectId),
|
projectId: text(raw.projectId),
|
||||||
dateFrom: text(raw.dateFrom),
|
dateFrom: text(raw.dateFrom),
|
||||||
@@ -151,12 +192,24 @@ function toStop(value: unknown): IRouteStop | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTransport(value: unknown): Record<string, string> {
|
/** Словарь «ключ участка → значение» (вид транспорта, время выезда); не строки отбрасываются. */
|
||||||
|
function toLegRecord(value: unknown): Record<string, string> {
|
||||||
if (!value || typeof value !== 'object') return {};
|
if (!value || typeof value !== 'object') return {};
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
Object.keys(value as object).forEach((key) => {
|
Object.keys(value as object).forEach((key) => {
|
||||||
const type = (value as Record<string, unknown>)[key];
|
const entry = (value as Record<string, unknown>)[key];
|
||||||
if (typeof type === 'string') result[key] = type;
|
if (typeof entry === 'string') result[key] = entry;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убрать из словаря участков те, что шли через точку stopId (её удалили из маршрута). */
|
||||||
|
export function withoutStopLegs(record: Record<string, string>, stopId: string): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
Object.keys(record)
|
||||||
|
.filter((key) => key.split('>').indexOf(stopId) === -1)
|
||||||
|
.forEach((key) => {
|
||||||
|
result[key] = record[key];
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -177,8 +230,12 @@ export function parseRoute(raw: unknown): ITripRoute {
|
|||||||
if (stops.length === 0) return EMPTY_ROUTE;
|
if (stops.length === 0) return EMPTY_ROUTE;
|
||||||
return {
|
return {
|
||||||
origin: text(rawRoute.origin),
|
origin: text(rawRoute.origin),
|
||||||
|
originKz: text(rawRoute.originKz),
|
||||||
|
originKato: text(rawRoute.originKato),
|
||||||
stops,
|
stops,
|
||||||
returnToOrigin: rawRoute.returnToOrigin !== false,
|
returnToOrigin: rawRoute.returnToOrigin !== false,
|
||||||
legTransport: toTransport(rawRoute.legTransport)
|
legTransport: toLegRecord(rawRoute.legTransport),
|
||||||
|
// Заявки до времени выезда — без этого поля: время просто не указано.
|
||||||
|
legDepartureTime: toLegRecord(rawRoute.legDepartureTime)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField, Select } from '../../../shared/ui';
|
||||||
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
|
||||||
|
import { companyOptions, selectedCompanyKey } from '../tripCompany';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
|
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
|
||||||
* сотрудника (руководитель оформляет за подчинённого) — должность и
|
* сотрудника (руководитель оформляет за подчинённого) — должность и
|
||||||
* организации подтянутся по выбранному. Дату подачи ставит форма.
|
* организации подтянутся по выбранному. Дату подачи ставит форма.
|
||||||
|
* Сотрудник может работать в нескольких компаниях — тогда выбирают, от какой он едет.
|
||||||
*/
|
*/
|
||||||
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
|
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, employee }) => {
|
||||||
|
const options = companyOptions(employee?.organizations ?? [], data);
|
||||||
|
// Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
|
||||||
|
const isLegacyRequest = !employee && !data.TripCompany;
|
||||||
|
|
||||||
|
return (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
value={data.Employee}
|
value={data.Employee}
|
||||||
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
|
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -23,6 +30,20 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, o
|
|||||||
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
||||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
||||||
</FormField>
|
</FormField>
|
||||||
<OrganizationField value={data.Organization} />
|
{isLegacyRequest && <OrganizationField value={data.Organization} />}
|
||||||
|
{!isLegacyRequest && options.length > 0 && (
|
||||||
|
<FormField label="Командирующая организация" htmlFor="TripCompany" required full>
|
||||||
|
<Select
|
||||||
|
id="TripCompany"
|
||||||
|
value={selectedCompanyKey(data)}
|
||||||
|
onChange={(key) => employee?.onCompanyChange(key)}
|
||||||
|
options={options}
|
||||||
|
placeholder="Выберите, от какой организации командировка"
|
||||||
|
// Единственная организация выбрана автоматически — выбирать не из чего.
|
||||||
|
readOnly={options.length === 1 && !!data.TripCompany}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './EstimateSection.module.scss';
|
import styles from './EstimateSection.module.scss';
|
||||||
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
import { EstimateEditor } from '../components/estimate/EstimateEditor';
|
import { EstimateEditor } from '../components/estimate/EstimateEditor';
|
||||||
import { EstimateTable } from '../components/estimate/EstimateTable';
|
import { EstimateTable } from '../components/estimate/EstimateTable';
|
||||||
@@ -9,7 +9,8 @@ import { EstimateSummary } from '../components/estimate/EstimateSummary';
|
|||||||
/**
|
/**
|
||||||
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
|
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
|
||||||
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
|
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
|
||||||
* Итог с долями и разбивкой по объектам — в обоих режимах.
|
* Итог с долями и разбивкой по объектам — в обоих режимах. После итога —
|
||||||
|
* необязательный комментарий к заявке.
|
||||||
*/
|
*/
|
||||||
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
|
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
@@ -26,6 +27,12 @@ export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
|
|||||||
<EstimateEditor data={data} set={set} />
|
<EstimateEditor data={data} set={set} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать. */}
|
||||||
|
{(!readOnly || data.TripComment.trim()) && (
|
||||||
|
<FormField label="Комментарий" htmlFor="TripComment" full>
|
||||||
|
<TextArea id="TripComment" value={data.TripComment} onChange={(v) => set('TripComment', v)} rows={3} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ISectionProps, OBLIGATION_LABEL, OBLIGATION_TEXT } from '../model';
|
|||||||
|
|
||||||
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
|
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
|
||||||
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title="Обязательство" icon="document">
|
<CollapseSection title="Обязательство и согласие" icon="document">
|
||||||
<p className={styles.text}>{OBLIGATION_TEXT}</p>
|
<p className={styles.text}>{OBLIGATION_TEXT}</p>
|
||||||
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
|
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import styles from './RouteSection.module.scss';
|
import styles from './RouteSection.module.scss';
|
||||||
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
|
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
import { RouteEditor } from '../components/route/RouteEditor';
|
import { RouteEditor } from '../components/route/RouteEditor';
|
||||||
import { RouteScheme } from '../components/route/RouteScheme';
|
import { RouteScheme } from '../components/route/RouteScheme';
|
||||||
@@ -9,8 +9,8 @@ import { RouteViewMode, RouteViewSwitch } from '../components/route/RouteViewSwi
|
|||||||
import { stopCardDomId } from '../components/route/RouteStopCard';
|
import { stopCardDomId } from '../components/route/RouteStopCard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Куда и зачем: маршрут (города + объекты + даты) и цель командировки. Маршрут —
|
* Куда едет сотрудник: маршрут (города + объекты + даты) в двух видах — «Форма»
|
||||||
* в двух видах: «Форма» для заполнения и «Схема» для просмотра. Заявитель
|
* для заполнения и «Схема» для просмотра. Заявитель
|
||||||
* начинает с формы (он пришёл заполнять), согласующий — со схемы (ему важнее
|
* начинает с формы (он пришёл заполнять), согласующий — со схемы (ему важнее
|
||||||
* увидеть маршрут целиком).
|
* увидеть маршрут целиком).
|
||||||
*/
|
*/
|
||||||
@@ -41,9 +41,6 @@ export const RouteSection: React.FC<ISectionProps> = ({ data, set, context }) =>
|
|||||||
<RouteScheme route={data.Route} articles={data.Estimate} onSelectStop={openStopInForm} />
|
<RouteScheme route={data.Route} articles={data.Estimate} onSelectStop={openStopInForm} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FormField label="Цель" htmlFor="TripPurpose" required full>
|
|
||||||
<TextArea id="TripPurpose" value={data.TripPurpose} onChange={(v) => set('TripPurpose', v)} rows={2} />
|
|
||||||
</FormField>
|
|
||||||
</CollapseSection>
|
</CollapseSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
|
|||||||
import { formatIsoDate } from '../../shared/dateUtils';
|
import { formatIsoDate } from '../../shared/dateUtils';
|
||||||
import { FORM_ID, FORM_TITLE, IFormData } from './model';
|
import { FORM_ID, FORM_TITLE, IFormData } from './model';
|
||||||
import { filledTasks, formatTasks, tripDays } from './tripData';
|
import { filledTasks, formatTasks, tripDays } from './tripData';
|
||||||
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route';
|
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute, tripKind } from './route';
|
||||||
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
|
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
|
||||||
|
|
||||||
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
|
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
|
||||||
@@ -34,7 +34,10 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
EmployeeId: employeeId,
|
EmployeeId: employeeId,
|
||||||
Position: data.Position || null,
|
Position: data.Position || null,
|
||||||
Organization: data.Organization || null,
|
Organization: data.Organization || null,
|
||||||
|
TripCompany: data.TripCompany || null,
|
||||||
|
TripCompanyId: data.TripCompanyId || null,
|
||||||
|
|
||||||
|
TripKind: tripKind(route),
|
||||||
TripRouteJson: serializeRoute(route),
|
TripRouteJson: serializeRoute(route),
|
||||||
TripRoute: routeText(route) || null,
|
TripRoute: routeText(route) || null,
|
||||||
TripCity: routeCities(route) || null,
|
TripCity: routeCities(route) || null,
|
||||||
@@ -44,7 +47,6 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
TripStartDate: period.start || null,
|
TripStartDate: period.start || null,
|
||||||
TripEndDate: period.end || null,
|
TripEndDate: period.end || null,
|
||||||
TripDaysCount: tripDays(period.start, period.end) ?? null,
|
TripDaysCount: tripDays(period.start, period.end) ?? null,
|
||||||
TripPurpose: data.TripPurpose || null,
|
|
||||||
|
|
||||||
// Читаемая строка — для представлений списка; источник для формы — JSON.
|
// Читаемая строка — для представлений списка; источник для формы — JSON.
|
||||||
TripTasks: formatTasks(data.Tasks) || null,
|
TripTasks: formatTasks(data.Tasks) || null,
|
||||||
@@ -54,6 +56,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
|||||||
TripEstimateJson: serializeEstimate(data.Estimate),
|
TripEstimateJson: serializeEstimate(data.Estimate),
|
||||||
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
|
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
|
||||||
TripEstimateTotal: estimateTotal(data.Estimate),
|
TripEstimateTotal: estimateTotal(data.Estimate),
|
||||||
|
TripComment: data.TripComment.trim() || null,
|
||||||
|
|
||||||
TripObligationAccepted: data.TripObligationAccepted
|
TripObligationAccepted: data.TripObligationAccepted
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
|
import { EMPTY_DATA, IFormData } from './model';
|
||||||
|
import { applyCompany, applyEmployeeCompany, companyOptions } from './tripCompany';
|
||||||
|
|
||||||
|
const org = (companyId: string, company: string, position: string): IEmployeeOrganization => ({
|
||||||
|
id: `${companyId}-${position}`,
|
||||||
|
fio: 'Нурумова Виктория',
|
||||||
|
email: 'nurumova@tnsu.kz',
|
||||||
|
iin: '',
|
||||||
|
company,
|
||||||
|
companyId,
|
||||||
|
section: '',
|
||||||
|
departmentId: '',
|
||||||
|
position,
|
||||||
|
mobile: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONTAZHNIK = org('c1', 'ТОО Монтажник-Т', 'Директор');
|
||||||
|
const ASTEL = org('c2', 'ТОО АСТЕЛ-К', 'Казначей');
|
||||||
|
|
||||||
|
describe('tripCompany: варианты', () => {
|
||||||
|
it('«Компания — должность», дубль компании отбрасывается', () => {
|
||||||
|
const options = companyOptions([MONTAZHNIK, ASTEL, org('c2', 'ТОО АСТЕЛ-К', 'Бухгалтер')], EMPTY_DATA);
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: 'c1', label: 'ТОО Монтажник-Т — Директор' },
|
||||||
|
{ value: 'c2', label: 'ТОО АСТЕЛ-К — Казначей' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сохранённая организация видна, даже если справочник её не вернул', () => {
|
||||||
|
const data: IFormData = { ...EMPTY_DATA, TripCompany: 'ТОО Старая', TripCompanyId: 'old' };
|
||||||
|
expect(companyOptions([], data)).toEqual([{ value: 'old', label: 'ТОО Старая' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tripCompany: выбор', () => {
|
||||||
|
it('выбор организации ставит должность в ней', () => {
|
||||||
|
const data = applyCompany({ ...EMPTY_DATA, Position: 'Директор' }, ASTEL);
|
||||||
|
expect([data.TripCompany, data.TripCompanyId, data.Position]).toEqual(['ТОО АСТЕЛ-К', 'c2', 'Казначей']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('одна организация выбирается сразу', () => {
|
||||||
|
const data = applyEmployeeCompany(EMPTY_DATA, [MONTAZHNIK]);
|
||||||
|
expect([data.TripCompanyId, data.Position]).toEqual(['c1', 'Директор']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('несколько — выбор и должность пустые', () => {
|
||||||
|
const prev = { ...EMPTY_DATA, TripCompany: 'ТОО Монтажник-Т', TripCompanyId: 'c1', Position: 'Директор' };
|
||||||
|
const data = applyEmployeeCompany(prev, [MONTAZHNIK, ASTEL]);
|
||||||
|
expect([data.TripCompanyId, data.Position]).toEqual(['', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет в справочнике — должность остаётся (введут руками)', () => {
|
||||||
|
const data = applyEmployeeCompany({ ...EMPTY_DATA, Position: 'Инженер' }, []);
|
||||||
|
expect([data.TripCompanyId, data.Position]).toEqual(['', 'Инженер']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Только типы: модуль чистый и тестируется без SharePoint (сервисы справочника тянут sp-http).
|
||||||
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
|
import type { ISelectOption } from '../../shared/ui';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
|
||||||
|
/** Значение варианта в списке: CompanyId из справочника, у записи без id — название. */
|
||||||
|
export function companyKey(org: IEmployeeOrganization): string {
|
||||||
|
return org.companyId || org.company;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Значение выбранной организации в списке ('' — не выбрана). */
|
||||||
|
export function selectedCompanyKey(data: IFormData): string {
|
||||||
|
return data.TripCompanyId || data.TripCompany;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Варианты «Командирующей организации» — «Компания — должность». Если в одной
|
||||||
|
* компании у сотрудника несколько записей, остаётся первая: выбирают компанию,
|
||||||
|
* а не ставку. Сохранённая организация, которой нет среди вариантов (просмотр
|
||||||
|
* заявки, справочник недоступен, сотрудник уже ушёл из компании), всё равно
|
||||||
|
* показывается — иначе поле выглядело бы пустым.
|
||||||
|
*/
|
||||||
|
export function companyOptions(organizations: IEmployeeOrganization[], data: IFormData): ISelectOption[] {
|
||||||
|
const options: ISelectOption[] = [];
|
||||||
|
organizations.forEach((org) => {
|
||||||
|
const value = companyKey(org);
|
||||||
|
if (!value || options.some((o) => o.value === value)) return;
|
||||||
|
options.push({ value, label: org.position ? `${org.company} — ${org.position}` : org.company });
|
||||||
|
});
|
||||||
|
const saved = selectedCompanyKey(data);
|
||||||
|
if (saved && !options.some((o) => o.value === saved)) {
|
||||||
|
options.push({ value: saved, label: data.TripCompany });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбрать организацию: компания + должность сотрудника именно в ней. undefined — сбросить выбор. */
|
||||||
|
export function applyCompany(data: IFormData, org: IEmployeeOrganization | undefined): IFormData {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
TripCompany: org?.company || '',
|
||||||
|
TripCompanyId: org?.companyId || '',
|
||||||
|
Position: org?.position || data.Position
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Организация и должность нового сотрудника заявки. Одна организация —
|
||||||
|
* выбирается сразу. Несколько — выбор за человеком, а должность пустая до
|
||||||
|
* выбора: в каждой компании она своя, и угадывать её по первой записи нельзя.
|
||||||
|
* Сотрудник не найден в справочнике — должность остаётся как была (введут руками).
|
||||||
|
*/
|
||||||
|
export function applyEmployeeCompany(data: IFormData, organizations: IEmployeeOrganization[]): IFormData {
|
||||||
|
if (organizations.length === 1) return applyCompany(data, organizations[0]);
|
||||||
|
const cleared = applyCompany(data, undefined);
|
||||||
|
return organizations.length > 1 ? { ...cleared, Position: '' } : cleared;
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||||
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { todaySubmitDate } from '../../shared/submitDate';
|
import { todaySubmitDate } from '../../shared/submitDate';
|
||||||
import { IFormData } from './model';
|
import { IFormData, ITripEmployee } from './model';
|
||||||
|
import { applyCompany, applyEmployeeCompany, companyKey } from './tripCompany';
|
||||||
|
|
||||||
type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
|
type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
|
||||||
|
|
||||||
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
|
/** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
|
||||||
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
|
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
|
||||||
getEmployeeOrganizations(context, email)
|
getEmployeeOrganizations(context, email)
|
||||||
.then((records) => {
|
.then((records) => {
|
||||||
if (!isAlive()) return;
|
if (!isAlive()) return;
|
||||||
setData((prev) => ({
|
setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
|
||||||
...prev,
|
|
||||||
Position: records[0]?.position || prev.Position,
|
|
||||||
Organization: formatEmployeeOrganizations(records)
|
|
||||||
}));
|
|
||||||
})
|
})
|
||||||
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
|
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -24,10 +22,18 @@ function fillProfile(context: WebPartContext, email: string, setData: SetData, i
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
|
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
|
||||||
* его должность, организации и сегодняшняя дата подачи. Возвращает обработчик
|
* его должность, организации и сегодняшняя дата подачи. Выбор другого сотрудника
|
||||||
* выбора другого сотрудника — он подтягивает должность и организации выбранного.
|
* подтягивает профиль выбранного. Список организаций грузится и для открытого
|
||||||
|
* черновика — но там сохранённые должность и организацию не перезаписываем.
|
||||||
*/
|
*/
|
||||||
export function useTripEmployee(context: WebPartContext, editId: number | undefined, setData: SetData): (login: string) => void {
|
export function useTripEmployee(
|
||||||
|
context: WebPartContext,
|
||||||
|
editId: number | undefined,
|
||||||
|
employee: string,
|
||||||
|
setData: SetData
|
||||||
|
): ITripEmployee {
|
||||||
|
const [organizations, setOrganizations] = useState<IEmployeeOrganization[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editId) return;
|
if (editId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -40,8 +46,36 @@ export function useTripEmployee(context: WebPartContext, editId: number | undefi
|
|||||||
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
|
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
|
||||||
}, [editId, context, setData]);
|
}, [editId, context, setData]);
|
||||||
|
|
||||||
return (login: string): void => {
|
// Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
|
||||||
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
|
useEffect(() => {
|
||||||
if (login) fillProfile(context, login, setData, () => true);
|
let alive = true;
|
||||||
|
const load = employee ? getEmployeeOrganizations(context, employee) : Promise.resolve<IEmployeeOrganization[]>([]);
|
||||||
|
load
|
||||||
|
.then((records) => {
|
||||||
|
if (alive) setOrganizations(records);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setOrganizations([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
};
|
};
|
||||||
|
}, [context, employee]);
|
||||||
|
|
||||||
|
const onEmployeeChange = (login: string): void => {
|
||||||
|
if (!login) {
|
||||||
|
// Сотрудник очищен — его организации и выбранная компания к заявке больше не относятся.
|
||||||
|
setData((prev) => applyCompany({ ...prev, Employee: '', Organization: '' }, undefined));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData((prev) => ({ ...prev, Employee: login }));
|
||||||
|
fillProfile(context, login, setData, () => true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCompanyChange = (key: string): void => {
|
||||||
|
const org = organizations.find((o) => companyKey(o) === key);
|
||||||
|
setData((prev) => applyCompany(prev, org));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { organizations, onEmployeeChange, onCompanyChange };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
|
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
|
||||||
import { createArticle } from './estimate';
|
import { createArticle } from './estimate';
|
||||||
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
import { draftIssues, submitIssues } from './validation';
|
import { draftIssues, submitIssues } from './validation';
|
||||||
|
|
||||||
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
|
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
|
||||||
|
|
||||||
const ROUTE: ITripRoute = {
|
const ROUTE: ITripRoute = {
|
||||||
origin: 'Алматы',
|
origin: 'Алматы',
|
||||||
stops: [{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
|
originKz: '',
|
||||||
|
originKato: '',
|
||||||
|
stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
|
||||||
returnToOrigin: true,
|
returnToOrigin: true,
|
||||||
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
|
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' },
|
||||||
|
legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILLED: IFormData = {
|
const FILLED: IFormData = {
|
||||||
@@ -22,33 +27,74 @@ const FILLED: IFormData = {
|
|||||||
TripObligationAccepted: true
|
TripObligationAccepted: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Фиксированное «сегодня» — раньше дат маршрута в фикстурах, чтобы тесты не зависели от текущей даты.
|
||||||
|
const TODAY = '2026-10-01';
|
||||||
|
const submit = (data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] => submitIssues(data, organizations, TODAY);
|
||||||
|
|
||||||
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
|
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
|
||||||
|
|
||||||
describe('validation: форма', () => {
|
describe('validation: форма', () => {
|
||||||
it('заполненная заявка — без проблем', () => {
|
it('заполненная заявка — без проблем', () => {
|
||||||
expect(submitIssues(FILLED)).toEqual([]);
|
expect(submit(FILLED)).toEqual([]);
|
||||||
expect(draftIssues(FILLED)).toEqual([]);
|
expect(draftIssues(FILLED)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('командирующая организация обязательна, только если есть из чего выбрать', () => {
|
||||||
|
const orgs = [{ id: '1', fio: '', email: '', iin: '', company: 'ТОО А', companyId: 'a', section: '', departmentId: '', position: '', mobile: '' }];
|
||||||
|
expect(fields(submit(FILLED, orgs))).toEqual(['TripCompany']);
|
||||||
|
expect(submit({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]);
|
||||||
|
expect(submit(FILLED, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('черновику нужен только сотрудник', () => {
|
it('черновику нужен только сотрудник', () => {
|
||||||
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
|
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('пустые задачи и обязательство', () => {
|
it('пустые задачи и обязательство', () => {
|
||||||
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
|
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
|
||||||
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
|
expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validation: маршрут', () => {
|
describe('validation: маршрут', () => {
|
||||||
it('объект только из справочника', () => {
|
it('объект только из справочника', () => {
|
||||||
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
|
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
|
||||||
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
|
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('зарубежная точка — нужна страна', () => {
|
||||||
|
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: ' ' }] };
|
||||||
|
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-country']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('зарубежная точка — объект можно ввести вручную, но не пустым', () => {
|
||||||
|
const abroad = { ...ROUTE.stops[0], abroad: true, country: 'Китай', project: 'Завод Китайский', projectId: '' };
|
||||||
|
expect(submit({ ...FILLED, Route: { ...ROUTE, stops: [abroad] } })).toEqual([]);
|
||||||
|
const empty = { ...abroad, project: ' ' };
|
||||||
|
expect(fields(submit({ ...FILLED, Route: { ...ROUTE, stops: [empty] } }))).toEqual(['stop-s1-project']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('у каждого участка нужен вид транспорта', () => {
|
it('у каждого участка нужен вид транспорта', () => {
|
||||||
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
|
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
|
||||||
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
|
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('у каждого участка нужно время выезда', () => {
|
||||||
|
const route = { ...ROUTE, legDepartureTime: { 'origin>s1': 'Утро' } };
|
||||||
|
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin-time']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => {
|
||||||
|
expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']);
|
||||||
|
expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]);
|
||||||
|
expect(draftIssues(FILLED)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('командировка не дольше 40 дней включительно', () => {
|
||||||
|
const until = (dateTo: string): ITripRoute => ({ ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo }] });
|
||||||
|
// 05.10 – 13.11 = 40 дней, 05.10 – 14.11 = 41.
|
||||||
|
expect(submit({ ...FILLED, Route: until('2026-11-13') })).toEqual([]);
|
||||||
|
expect(fields(submit({ ...FILLED, Route: until('2026-11-14') }))).toEqual(['stop-s1-to']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('отъезд раньше прибытия блокирует даже черновик', () => {
|
it('отъезд раньше прибытия блокирует даже черновик', () => {
|
||||||
@@ -57,9 +103,9 @@ describe('validation: маршрут', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
|
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
|
||||||
const second = { id: 's2', city: 'Актау', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
|
const second = { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
|
||||||
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
|
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' }, legDepartureTime: { 'origin>s1': 'Утро', 's1>s2': 'День', 's2>origin': 'Вечер' } };
|
||||||
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
|
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,29 +113,46 @@ describe('validation: смета', () => {
|
|||||||
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
||||||
|
|
||||||
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
||||||
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
|
const tickets = { ...travel, items: [{ ...travel.items[0], linkId: 'origin>s1', mode: 'calc' as const, quantity: '1' }] };
|
||||||
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
|
const data = { ...FILLED, Estimate: [off(perDiem), tickets, off(accommodation)] };
|
||||||
expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
|
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-price`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('лимит суточных: 6 МРП по Казахстану, 8 за рубежом', () => {
|
||||||
|
const withMrp = (mrpCount: string): IEstimateArticle => ({ ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', mrpCount }] });
|
||||||
|
const estimate = (mrpCount: string): IEstimateArticle[] => [withMrp(mrpCount), off(travel), off(accommodation)];
|
||||||
|
expect(fields(submit({ ...FILLED, Estimate: estimate('7') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
|
||||||
|
const abroad = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: 'Китай' }] };
|
||||||
|
expect(submit({ ...FILLED, Route: abroad, Estimate: estimate('8') })).toEqual([]);
|
||||||
|
expect(fields(submit({ ...FILLED, Route: abroad, Estimate: estimate('9') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('проживание: цена необязательна, но по Казахстану не больше 15 000 за ночь', () => {
|
||||||
|
const withPrice = (price: string): IEstimateArticle => ({ ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', price }] });
|
||||||
|
const estimate = (price: string): IEstimateArticle[] => [off(perDiem), off(travel), withPrice(price)];
|
||||||
|
expect(submit({ ...FILLED, Estimate: estimate('') })).toEqual([]);
|
||||||
|
expect(submit({ ...FILLED, Estimate: estimate('15000') })).toEqual([]);
|
||||||
|
expect(fields(submit({ ...FILLED, Estimate: estimate('15001') }))).toEqual([`item-${accommodation.items[0].id}-price`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
|
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
|
||||||
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
|
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
|
||||||
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
|
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
|
||||||
const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)];
|
const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)];
|
||||||
expect(fields(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
|
expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
|
||||||
expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
|
expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('при нескольких вариантах позицию нужно привязать', () => {
|
it('при нескольких вариантах позицию нужно привязать', () => {
|
||||||
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
|
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
|
||||||
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
|
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
|
||||||
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
|
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
|
||||||
expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]);
|
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-link`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('другой расход требует название и сумму', () => {
|
it('другой расход требует название и сумму', () => {
|
||||||
const other = createArticle('other', ROUTE);
|
const other = createArticle('other', ROUTE);
|
||||||
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
|
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
|
||||||
expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
|
expect(fields(submit(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
import type { IFieldIssue } from '../../shared/ui';
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { filledTasks } from './tripData';
|
import { todaySubmitDate } from '../../shared/submitDate';
|
||||||
import { legLabel, routeLegs } from './route';
|
import {
|
||||||
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
|
ESTIMATE_KINDS,
|
||||||
|
EstimateKind,
|
||||||
|
IEstimateArticle,
|
||||||
|
IEstimateItem,
|
||||||
|
IFormData,
|
||||||
|
IRouteStop,
|
||||||
|
ITripRoute,
|
||||||
|
MAX_TRIP_DAYS
|
||||||
|
} from './model';
|
||||||
|
import { filledTasks, plural, tripDays } from './tripData';
|
||||||
|
import { legLabel, routeLegs, routePeriod, stopCityLabel } from './route';
|
||||||
|
import {
|
||||||
|
articleTitle,
|
||||||
|
formatAmount,
|
||||||
|
itemAmount,
|
||||||
|
itemLinkLabel,
|
||||||
|
linkOptions,
|
||||||
|
mrpLimit,
|
||||||
|
nightPriceLimit,
|
||||||
|
resolveLinkId
|
||||||
|
} from './estimate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
|
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
|
||||||
@@ -10,11 +30,13 @@ import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } f
|
|||||||
*/
|
*/
|
||||||
export const fieldKey = {
|
export const fieldKey = {
|
||||||
origin: 'route-origin',
|
origin: 'route-origin',
|
||||||
|
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
|
||||||
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
|
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
|
||||||
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
|
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
|
||||||
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
|
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
|
||||||
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
|
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
|
||||||
legTransport: (legId: string): string => `leg-${legId}`,
|
legTransport: (legId: string): string => `leg-${legId}`,
|
||||||
|
legDepartureTime: (legId: string): string => `leg-${legId}-time`,
|
||||||
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
|
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
|
||||||
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
|
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
|
||||||
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
|
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
|
||||||
@@ -25,7 +47,8 @@ export const fieldKey = {
|
|||||||
|
|
||||||
/** «Точка 2 (Актау)» — как точку видно в форме. */
|
/** «Точка 2 (Актау)» — как точку видно в форме. */
|
||||||
function stopName(stop: IRouteStop, index: number): string {
|
function stopName(stop: IRouteStop, index: number): string {
|
||||||
return stop.city.trim() ? `Точка ${index + 1} (${stop.city.trim()})` : `Точка ${index + 1}`;
|
const city = stopCityLabel(stop);
|
||||||
|
return city ? `Точка ${index + 1} (${city})` : `Точка ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */
|
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */
|
||||||
@@ -40,13 +63,24 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
|
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
|
||||||
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] {
|
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
|
||||||
const label = stopName(stop, index);
|
const label = stopName(stop, index);
|
||||||
const issues: IFieldIssue[] = [];
|
const issues: IFieldIssue[] = [];
|
||||||
|
if (stop.abroad && !stop.country.trim()) issues.push({ field: fieldKey.stopCountry(stop), label, message: 'укажите страну' });
|
||||||
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
|
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
|
||||||
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
|
// По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
|
||||||
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
|
// За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
|
||||||
|
if (stop.abroad && !stop.project.trim()) {
|
||||||
|
issues.push({ field: fieldKey.stopProject(stop), label, message: 'укажите объект или место назначения' });
|
||||||
|
}
|
||||||
|
if (!stop.abroad && !stop.projectId) {
|
||||||
|
issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
|
||||||
|
}
|
||||||
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
|
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
|
||||||
|
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
|
||||||
|
if (stop.dateFrom && stop.dateFrom < today) {
|
||||||
|
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' });
|
||||||
|
}
|
||||||
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
|
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
|
||||||
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
|
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
|
||||||
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
|
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
|
||||||
@@ -54,15 +88,39 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
|
|||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
|
/**
|
||||||
function routeIssues(route: ITripRoute): IFieldIssue[] {
|
* Вся поездка — не дольше MAX_TRIP_DAYS дней (от прибытия в первую точку до отъезда
|
||||||
|
* из последней, включительно). Подсвечиваем отъезд последней точки — его обычно и правят.
|
||||||
|
*/
|
||||||
|
function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
|
||||||
|
const period = routePeriod(route);
|
||||||
|
const days = tripDays(period.start, period.end);
|
||||||
|
const last = route.stops[route.stops.length - 1];
|
||||||
|
if (days === undefined || days <= MAX_TRIP_DAYS || !last) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: fieldKey.stopTo(last),
|
||||||
|
label: 'Маршрут',
|
||||||
|
message: `командировка — ${plural(days, ['день', 'дня', 'дней'])}, можно не больше ${MAX_TRIP_DAYS}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маршрут для отправки: город отправления, все точки, вид транспорта и время выезда на каждом участке. */
|
||||||
|
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
|
||||||
const issues: IFieldIssue[] = [];
|
const issues: IFieldIssue[] = [];
|
||||||
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
|
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
|
||||||
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1])));
|
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
|
||||||
issues.push(...stopDateOrderIssues(route));
|
issues.push(...stopDateOrderIssues(route));
|
||||||
|
issues.push(...tripLengthIssues(route));
|
||||||
routeLegs(route).forEach((leg) => {
|
routeLegs(route).forEach((leg) => {
|
||||||
|
const label = `Переезд ${legLabel(leg)}`;
|
||||||
if (!route.legTransport[leg.id]) {
|
if (!route.legTransport[leg.id]) {
|
||||||
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });
|
issues.push({ field: fieldKey.legTransport(leg.id), label, message: 'выберите вид транспорта' });
|
||||||
|
}
|
||||||
|
// Время выезда нужно тревел-координатору под билеты — без него не отправить.
|
||||||
|
if (!route.legDepartureTime[leg.id]) {
|
||||||
|
issues.push({ field: fieldKey.legDepartureTime(leg.id), label, message: 'выберите время выезда' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return issues;
|
return issues;
|
||||||
@@ -75,6 +133,27 @@ function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
|
|||||||
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
|
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция «по маршруту» — точку и дни/ночи форма ставит сама (пустые даты подсветит
|
||||||
|
* маршрут). Суточные: нужно кол-во МРП, не больше лимита (0 дней в день переезда —
|
||||||
|
* не ошибка). Проживание: цена необязательна — в точке могут жить на объекте, — но
|
||||||
|
* не выше лимита за ночь.
|
||||||
|
*/
|
||||||
|
function routeItemIssues(kind: EstimateKind, item: IEstimateItem, label: string, route: ITripRoute): IFieldIssue[] {
|
||||||
|
const stop = route.stops.find((s) => s.id === item.linkId);
|
||||||
|
if (kind === 'accommodation') {
|
||||||
|
const limit = nightPriceLimit(stop);
|
||||||
|
if (limit === undefined || Number(item.price) <= limit) return [];
|
||||||
|
return [{ field: fieldKey.itemPrice(item), label, message: `цена за ночь не больше ${formatAmount(limit)} ₸` }];
|
||||||
|
}
|
||||||
|
if (!Number(item.mrpCount)) return [{ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' }];
|
||||||
|
const limit = mrpLimit(stop);
|
||||||
|
if (Number(item.mrpCount) > limit) {
|
||||||
|
return [{ field: fieldKey.itemMrpCount(item), label, message: `не больше ${limit} МРП в день` }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой
|
* Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой
|
||||||
* или добавленной статьи каждая позиция должна иметь сумму и (если вариантов
|
* или добавленной статьи каждая позиция должна иметь сумму и (если вариантов
|
||||||
@@ -92,12 +171,8 @@ function estimateIssues(articles: IEstimateArticle[], route: ITripRoute): IField
|
|||||||
article.items.forEach((item) => {
|
article.items.forEach((item) => {
|
||||||
const where = itemLinkLabel(article.kind, item, route);
|
const where = itemLinkLabel(article.kind, item, route);
|
||||||
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
||||||
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
|
|
||||||
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
|
|
||||||
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
||||||
if (!Number(item.mrpCount)) {
|
issues.push(...routeItemIssues(article.kind, item, label, route));
|
||||||
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
||||||
@@ -116,19 +191,30 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
|
|||||||
return [...issues, ...stopDateOrderIssues(data.Route)];
|
return [...issues, ...stopDateOrderIssues(data.Route)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
|
/**
|
||||||
export function submitIssues(data: IFormData): IFieldIssue[] {
|
* Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
|
||||||
|
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
|
||||||
|
* today — YYYY-MM-DD; параметром, чтобы тесты не зависели от текущей даты.
|
||||||
|
*/
|
||||||
|
export function submitIssues(
|
||||||
|
data: IFormData,
|
||||||
|
organizations: IEmployeeOrganization[] = [],
|
||||||
|
today: string = todaySubmitDate()
|
||||||
|
): IFieldIssue[] {
|
||||||
const issues: IFieldIssue[] = [];
|
const issues: IFieldIssue[] = [];
|
||||||
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
|
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
|
||||||
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
|
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
|
||||||
issues.push(...routeIssues(data.Route));
|
// Нет организаций (сотрудника нет в справочнике / справочник недоступен) — выбирать не из чего, не блокируем.
|
||||||
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
|
if (organizations.length > 0 && !data.TripCompany) {
|
||||||
|
issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' });
|
||||||
|
}
|
||||||
|
issues.push(...routeIssues(data.Route, today));
|
||||||
if (filledTasks(data.Tasks).length === 0) {
|
if (filledTasks(data.Tasks).length === 0) {
|
||||||
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
|
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
|
||||||
}
|
}
|
||||||
issues.push(...estimateIssues(data.Estimate, data.Route));
|
issues.push(...estimateIssues(data.Estimate, data.Route));
|
||||||
if (!data.TripObligationAccepted) {
|
if (!data.TripObligationAccepted) {
|
||||||
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство', message: 'подтвердите согласие' });
|
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство и согласие', message: 'подтвердите согласие' });
|
||||||
}
|
}
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+1
@@ -1,5 +1,6 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
|
||||||
.back {
|
.back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -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`), без печати
|
||||||
|
- «Создать на основе»: нет
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"dataListTitle": "Заявка на оплату — согласование",
|
"dataListTitle": "Изменение условий трудового договора — согласование",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
|
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
|
||||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||||
+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,339 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
||||||
import styles from './PaymentRequestForm.module.scss';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
CollapseSection,
|
|
||||||
FormCard,
|
|
||||||
InitiatorLine,
|
|
||||||
FormModeProvider,
|
|
||||||
Icon,
|
|
||||||
RequestNumber,
|
|
||||||
SubmitActions,
|
|
||||||
getDraftDeleteHandler,
|
|
||||||
useRequestSubmit
|
|
||||||
} from '../../shared/ui';
|
|
||||||
import {
|
|
||||||
composeRemoveRowRule,
|
|
||||||
ApprovalMatrix,
|
|
||||||
ApprovalHistory,
|
|
||||||
StatusBadge,
|
|
||||||
useMatrixEditor,
|
|
||||||
getPlannedMatrix,
|
|
||||||
loadRoutePhase,
|
|
||||||
isOpenedRouteReady,
|
|
||||||
notifyActiveStep,
|
|
||||||
useRequestTypeId,
|
|
||||||
REQUEST_STATUS,
|
|
||||||
isEditableStatus,
|
|
||||||
IApproverToWrite,
|
|
||||||
unassignedStepMessage,
|
|
||||||
resolveWriteListId,
|
|
||||||
useFormExecutors,
|
|
||||||
ExecutorsMatrix,
|
|
||||||
executorsToWrite,
|
|
||||||
initialSubmitStatus
|
|
||||||
} from '../../shared/approval';
|
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
|
||||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
|
||||||
import { todayIsoDate } from '../../shared/dateUtils';
|
|
||||||
import { IRequestFormProps } from '../../models/types';
|
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|
||||||
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
|
|
||||||
import { loadPaymentRequest } from './load';
|
|
||||||
import { loadPreviousPayments } from './services/previousPayments.service';
|
|
||||||
import { validatePayment } from './validation';
|
|
||||||
import { useBasisSelection } from './useBasisSelection';
|
|
||||||
import { useInvoiceFlow } from './useInvoiceFlow';
|
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
|
||||||
import { BasisSection } from './sections/BasisSection';
|
|
||||||
import { PaymentSection } from './sections/PaymentSection';
|
|
||||||
import { AmountsSection } from './sections/AmountsSection';
|
|
||||||
import { InvoiceSection } from './sections/InvoiceSection';
|
|
||||||
import { PositionsSection } from './sections/PositionsSection';
|
|
||||||
|
|
||||||
const PaymentRequestForm: 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();
|
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
|
||||||
|
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
|
||||||
const patch = useCallback((changes: Partial<IFormData>): void => setData((prev) => ({ ...prev, ...changes })), []);
|
|
||||||
|
|
||||||
const basis = useBasisSelection(context, patch, requestId);
|
|
||||||
const invoice = useInvoiceFlow(context, data, patch);
|
|
||||||
|
|
||||||
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
|
|
||||||
useEffect(() => {
|
|
||||||
if (editId) return undefined;
|
|
||||||
const requesterEmail = effectiveUserEmail(context);
|
|
||||||
setData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
RequesterEmail: requesterEmail,
|
|
||||||
RequesterName: context.pageContext.user.displayName || '',
|
|
||||||
RequestDate: prev.RequestDate || todayIsoDate()
|
|
||||||
}));
|
|
||||||
let alive = true;
|
|
||||||
getEmployeeOrganizations(context, requesterEmail)
|
|
||||||
.then((records) => {
|
|
||||||
if (alive) {
|
|
||||||
setData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
Organization: formatEmployeeOrganizations(records),
|
|
||||||
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || '',
|
|
||||||
Department: prev.Department || records[0]?.section || '',
|
|
||||||
DepartmentId: prev.DepartmentId || records[0]?.departmentId || ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [editId, context]);
|
|
||||||
|
|
||||||
// Выбор другого заявителя подтягивает его ФИО, должность, подразделение и организации.
|
|
||||||
const handleRequesterChange = (login: string): void => {
|
|
||||||
patch({ RequesterEmail: login });
|
|
||||||
if (!login) {
|
|
||||||
patch({ Organization: '' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getEmployeeOrganizations(context, login)
|
|
||||||
.then((records) =>
|
|
||||||
setData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
RequesterName: records[0]?.fio || prev.RequesterName,
|
|
||||||
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
|
|
||||||
Department: records[0]?.section || prev.Department,
|
|
||||||
DepartmentId: records[0]?.departmentId || prev.DepartmentId,
|
|
||||||
Organization: formatEmployeeOrganizations(records)
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое и статус.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editId) return undefined;
|
|
||||||
let alive = true;
|
|
||||||
loadPaymentRequest(editId, editListId)
|
|
||||||
.then((loaded) => {
|
|
||||||
if (!alive) return;
|
|
||||||
setData(loaded.data);
|
|
||||||
setRequestStatus(loaded.status);
|
|
||||||
setRequestCode(loaded.code || undefined);
|
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
|
||||||
// В элементе лежит снимок прежних оплат на момент сохранения. Пока заявку
|
|
||||||
// ещё правят (черновик, возврат), перечитываем их: по основанию могли
|
|
||||||
// подать новые, а лимит суммы должен считаться по актуальным.
|
|
||||||
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
|
|
||||||
loadPreviousPayments(context, loaded.data.BasisKey, editId)
|
|
||||||
.then((previous) => {
|
|
||||||
if (alive) patch({ PreviousPayments: previous });
|
|
||||||
})
|
|
||||||
.catch(() => undefined); // остаётся снимок — лучше, чем пустая таблица
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
const approval = useMemo(() => paymentRequestApprovalService(editListId), [editListId]);
|
|
||||||
const userEmail = data.RequesterEmail;
|
|
||||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
|
||||||
const requestTypeId = requestTypeIdState.id;
|
|
||||||
// Маршрут зависит от проекта — пока он не выбран, resolve вернёт 404.
|
|
||||||
const projectId = data.ProjectId || null;
|
|
||||||
const matrixReady =
|
|
||||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!userEmail && !!projectId && !!requestTypeId));
|
|
||||||
const loadMatrix = useCallback(
|
|
||||||
() =>
|
|
||||||
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
|
||||||
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId)
|
|
||||||
),
|
|
||||||
[context, userEmail, approval, editId, requestStatus, projectId, requestTypeId]
|
|
||||||
);
|
|
||||||
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
|
|
||||||
approval.getJobTitle(login)
|
|
||||||
);
|
|
||||||
const executors = useFormExecutors({
|
|
||||||
readOnly,
|
|
||||||
context,
|
|
||||||
approval,
|
|
||||||
requestTypeId,
|
|
||||||
userEmail: matrixReady ? userEmail : '',
|
|
||||||
editId,
|
|
||||||
requestStatus,
|
|
||||||
projectId
|
|
||||||
});
|
|
||||||
|
|
||||||
const formError = validatePayment(data);
|
|
||||||
const isValid = !formError;
|
|
||||||
const validate = (): string | undefined => {
|
|
||||||
if (formError) return formError;
|
|
||||||
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 }));
|
|
||||||
|
|
||||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
|
||||||
const rtId = requestTypeId;
|
|
||||||
if (!rtId) {
|
|
||||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
|
||||||
}
|
|
||||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
|
||||||
const { id, code } = await savePaymentRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
|
|
||||||
setRequestId(id);
|
|
||||||
setRequestStatus(status);
|
|
||||||
if (code) setRequestCode(code);
|
|
||||||
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 error = validate();
|
|
||||||
if (error) return error;
|
|
||||||
if (matrix.error) {
|
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
unassignedStepMessage(
|
|
||||||
matrix.rows,
|
|
||||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
|
||||||
) ||
|
|
||||||
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 sectionProps = { data, set, patch, context, onRequesterChange: handleRequesterChange };
|
|
||||||
|
|
||||||
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}
|
|
||||||
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
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}>
|
|
||||||
<HeaderSection {...sectionProps} />
|
|
||||||
<CollapseSection title="Основание" icon="document">
|
|
||||||
<BasisSection {...sectionProps} basis={basis} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
|
||||||
<InvoiceSection data={data} invoice={invoice} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
|
||||||
<PaymentSection {...sectionProps} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Суммы" icon="calculator">
|
|
||||||
<AmountsSection {...sectionProps} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Позиции" icon="clipboard">
|
|
||||||
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
|
||||||
</CollapseSection>
|
|
||||||
</FormModeProvider>
|
|
||||||
|
|
||||||
<ApprovalMatrix
|
|
||||||
editor={matrix}
|
|
||||||
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
|
||||||
search={(q) => approval.searchUsers(q)}
|
|
||||||
waiting={!matrixReady}
|
|
||||||
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
|
|
||||||
/>
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</FormCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaymentRequestForm;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
@import '../../../../styles/design-tokens';
|
|
||||||
|
|
||||||
.state {
|
|
||||||
padding: $sp-8;
|
|
||||||
color: $gray-500;
|
|
||||||
font-size: $fs-sm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: $sp-3;
|
|
||||||
margin-bottom: $sp-4;
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
|
||||||
import styles from './PaymentRequestView.module.scss';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
CollapseSection,
|
|
||||||
FormCard,
|
|
||||||
InitiatorLine,
|
|
||||||
FormModeProvider,
|
|
||||||
PrintSplitButton,
|
|
||||||
OrgLetterhead,
|
|
||||||
Icon
|
|
||||||
} from '../../shared/ui';
|
|
||||||
import { navigate } from '../../shared/routing';
|
|
||||||
import {
|
|
||||||
ApproverService,
|
|
||||||
DecisionPanel,
|
|
||||||
StatusBadge,
|
|
||||||
ApprovalMatrix,
|
|
||||||
ApprovalHistory,
|
|
||||||
useMatrixEditor,
|
|
||||||
useViewExecutors,
|
|
||||||
ExecutorsMatrix,
|
|
||||||
approvalPhaseRows,
|
|
||||||
notifyActiveStep,
|
|
||||||
useRequestTypeId,
|
|
||||||
isEditableStatus
|
|
||||||
} from '../../shared/approval';
|
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
|
||||||
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
|
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|
||||||
import { loadPaymentRequest } from './load';
|
|
||||||
import { paymentRequestApprovalService } from './submit';
|
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
|
||||||
import { PaymentSection } from './sections/PaymentSection';
|
|
||||||
import { AmountsSection } from './sections/AmountsSection';
|
|
||||||
import { InvoiceSection } from './sections/InvoiceSection';
|
|
||||||
import { PositionsSection } from './sections/PositionsSection';
|
|
||||||
import { IInvoiceFlow } from './useInvoiceFlow';
|
|
||||||
|
|
||||||
interface IPaymentRequestViewProps {
|
|
||||||
context: WebPartContext;
|
|
||||||
itemId: number;
|
|
||||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
|
||||||
formKey: string;
|
|
||||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
|
||||||
listId?: string;
|
|
||||||
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
|
||||||
onCodeLoaded?: (code: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** В просмотре счёт не загружается: поток нужен только секции как заглушка пропсов. */
|
|
||||||
const IDLE_INVOICE: IInvoiceFlow = { stage: 'idle', error: '', attach: () => Promise.resolve() };
|
|
||||||
|
|
||||||
// Заявка на оплату только для просмотра (режим согласующего).
|
|
||||||
const PaymentRequestView: React.FC<IPaymentRequestViewProps> = ({ 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 | null>(null);
|
|
||||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
|
||||||
const set: SetField = () => undefined;
|
|
||||||
const patch = (): void => undefined;
|
|
||||||
|
|
||||||
const approval = useMemo(() => paymentRequestApprovalService(listId), [listId]);
|
|
||||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
|
||||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
|
||||||
|
|
||||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
|
||||||
const loadApprovers = React.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);
|
|
||||||
loadPaymentRequest(itemId, listId)
|
|
||||||
.then((loaded) => {
|
|
||||||
if (!alive) return;
|
|
||||||
setData(loaded.data);
|
|
||||||
setStatus(loaded.status);
|
|
||||||
setCode(loaded.code);
|
|
||||||
notifyCode(loaded.code);
|
|
||||||
setError(null);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (alive) {
|
|
||||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [itemId, listId, notifyCode]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={styles.state}>
|
|
||||||
<Alert variant="err">{error}</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionProps = { data, set, patch, context, printOrgIndex };
|
|
||||||
|
|
||||||
const notify = (): void => {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
|
||||||
{isEditableStatus(status) && (
|
|
||||||
<Button variant="secondary" onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Редактировать
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rp-print-root">
|
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
|
||||||
<FormCard
|
|
||||||
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>
|
|
||||||
<HeaderSection {...sectionProps} />
|
|
||||||
<CollapseSection title="Основание" icon="document">
|
|
||||||
<div>
|
|
||||||
{data.BasisTitle} · {data.ProjectName} · {data.CompanyName}
|
|
||||||
</div>
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Вложения — счёт на оплату" icon="document">
|
|
||||||
<InvoiceSection data={data} invoice={IDLE_INVOICE} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Реквизиты платежа" icon="calculator">
|
|
||||||
<PaymentSection {...sectionProps} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Суммы" icon="calculator">
|
|
||||||
<AmountsSection {...sectionProps} />
|
|
||||||
</CollapseSection>
|
|
||||||
<CollapseSection title="Позиции" icon="clipboard">
|
|
||||||
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
|
|
||||||
</CollapseSection>
|
|
||||||
</FormModeProvider>
|
|
||||||
</FormCard>
|
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
|
||||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rp-no-print">
|
|
||||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rp-no-print">
|
|
||||||
<DecisionPanel
|
|
||||||
service={approverService}
|
|
||||||
requestId={itemId}
|
|
||||||
search={(q) => approval.searchUsers(q)}
|
|
||||||
onDecided={(newStatus) => {
|
|
||||||
setStatus(newStatus);
|
|
||||||
setDecisionSeq((n) => n + 1);
|
|
||||||
notify();
|
|
||||||
}}
|
|
||||||
onDelegated={() => {
|
|
||||||
setDecisionSeq((n) => n + 1);
|
|
||||||
notify();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaymentRequestView;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Заявка на оплату (`acc-payment-request`)
|
|
||||||
|
|
||||||
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
|
|
||||||
**Категория:** Бухгалтерия
|
|
||||||
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
|
|
||||||
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
|
|
||||||
|
|
||||||
## Маршрут
|
|
||||||
- Фазы: согласование (исполнителей нет)
|
|
||||||
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
|
|
||||||
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
|
|
||||||
|
|
||||||
## Особое поведение
|
|
||||||
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
|
|
||||||
- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`).
|
|
||||||
- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки.
|
|
||||||
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
|
|
||||||
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
|
|
||||||
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
|
|
||||||
- **Ранее поданные заявки на оплату** — по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`) через approval-route `GET /api/basis-requests` по всем партициям, без черновиков, отклонённых и возвращённых. Сервер читает от имени приложения, поэтому видны и чужие заявки (права на элементы разорваны поштучно); номер становится ссылкой, только если у пользователя есть права открыть заявку. Снимок пишется в `PreviousPaymentsJson`; черновик и возвращённая при открытии перечитывают список заново.
|
|
||||||
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
|
|
||||||
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
|
|
||||||
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
|
|
||||||
- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым):
|
|
||||||
- **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано).
|
|
||||||
- **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту).
|
|
||||||
- **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`.
|
|
||||||
- Фильтры-Id передаются, только если это GUID: иначе API отвечает 400.
|
|
||||||
|
|
||||||
## Что не проверено на живом API
|
|
||||||
- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании).
|
|
||||||
- Доступ пользователей портала на чтение списков сайта СТ.
|
|
||||||
|
|
||||||
## Секции формы
|
|
||||||
HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection
|
|
||||||
|
|
||||||
## Точки входа
|
|
||||||
- rptype: `acc-payment-request`
|
|
||||||
- Режим согласующего (просмотр): есть (`PaymentRequestView`)
|
|
||||||
- «Создать на основе»: —
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
|
|
||||||
|
|
||||||
describe('parseAmount', () => {
|
|
||||||
it('понимает пробелы и запятую', () => {
|
|
||||||
expect(parseAmount('1 234,50')).toBe(1234.5);
|
|
||||||
});
|
|
||||||
it('мусор и пустая строка — 0', () => {
|
|
||||||
expect(parseAmount('abc')).toBe(0);
|
|
||||||
expect(parseAmount('')).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('overLimitAmount', () => {
|
|
||||||
const prev = [
|
|
||||||
{ code: 'A', amount: 600 },
|
|
||||||
{ code: 'B', amount: 100 }
|
|
||||||
];
|
|
||||||
it('не превышает, пока сумма всех оплат не больше общей', () => {
|
|
||||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300, PreviousPayments: prev })).toBe(0);
|
|
||||||
});
|
|
||||||
it('возвращает величину превышения', () => {
|
|
||||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 400, PreviousPayments: prev })).toBe(100);
|
|
||||||
});
|
|
||||||
it('копейки округления не считаются превышением', () => {
|
|
||||||
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300.004, PreviousPayments: prev })).toBe(0);
|
|
||||||
});
|
|
||||||
it('без общей суммы сравнивать не с чем', () => {
|
|
||||||
expect(overLimitAmount({ TotalAmount: 0, PayAmount: 5, PreviousPayments: prev })).toBe(0);
|
|
||||||
});
|
|
||||||
it('считает сумму прежних оплат', () => {
|
|
||||||
expect(previousPaymentsTotal(prev)).toBe(700);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('recipientMatchesInvoice', () => {
|
|
||||||
it('БИН решает, когда он есть у обеих сторон', () => {
|
|
||||||
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'Б', bin: '1' })).toBe(true);
|
|
||||||
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'А', bin: '2' })).toBe(false);
|
|
||||||
});
|
|
||||||
it('без БИН сравнивает названия без формы собственности и кавычек', () => {
|
|
||||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа Строй»', bin: '' }, { name: 'Альфа Строй, ТОО', bin: '' })).toBe(true);
|
|
||||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '' }, { name: 'ТОО «Бета»', bin: '' })).toBe(false);
|
|
||||||
});
|
|
||||||
it('нет данных по счёту — расхождения нет', () => {
|
|
||||||
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatAmountInput', () => {
|
|
||||||
it('разбивает на разряды', () => {
|
|
||||||
expect(formatAmountInput('1234567')).toBe('1 234 567');
|
|
||||||
expect(formatAmountInput('1 528 642')).toBe('1 528 642');
|
|
||||||
});
|
|
||||||
it('держит недописанную дробную часть и режет её до двух знаков', () => {
|
|
||||||
expect(formatAmountInput('1234,')).toBe('1 234,');
|
|
||||||
expect(formatAmountInput('1234.567')).toBe('1 234,56');
|
|
||||||
expect(formatAmountInput(',5')).toBe('0,5');
|
|
||||||
});
|
|
||||||
it('выкидывает мусор и лишние разделители', () => {
|
|
||||||
expect(formatAmountInput('12a3,4,5')).toBe('123,45');
|
|
||||||
expect(formatAmountInput('')).toBe('');
|
|
||||||
});
|
|
||||||
it('согласован с parseAmount', () => {
|
|
||||||
expect(parseAmount(formatAmountInput('154100,5'))).toBe(154100.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { IFormData, IPreviousPayment } from './model';
|
|
||||||
|
|
||||||
/** Допуск при сравнении денег: копейки округления не считаем превышением. */
|
|
||||||
const MONEY_EPSILON = 0.005;
|
|
||||||
|
|
||||||
/** Число из текста поля ввода: пробелы и запятая допустимы; мусор → 0. */
|
|
||||||
export function parseAmount(raw: string): number {
|
|
||||||
const value = Number(raw.replace(/\s/g, '').replace(',', '.'));
|
|
||||||
return Number.isFinite(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Текст поля суммы с разрядами («1234567,5» → «1 234 567,5»), как его видно при
|
|
||||||
* вводе: остаются цифры и один десятичный разделитель (запятая), в дробной
|
|
||||||
* части не больше двух знаков. Недописанное «12,» сохраняется — иначе запятую
|
|
||||||
* нельзя было бы ввести.
|
|
||||||
*/
|
|
||||||
export function formatAmountInput(raw: string): string {
|
|
||||||
const cleaned = raw.replace(/[^\d.,]/g, '').replace('.', ',');
|
|
||||||
const comma = cleaned.indexOf(',');
|
|
||||||
const intPart = (comma < 0 ? cleaned : cleaned.slice(0, comma)).replace(/,/g, '').replace(/^0+(?=\d)/, '');
|
|
||||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
|
||||||
if (comma < 0) return grouped;
|
|
||||||
const fraction = cleaned.slice(comma + 1).replace(/,/g, '').slice(0, 2);
|
|
||||||
return `${grouped || '0'},${fraction}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сумма ранее поданных оплат по основанию. */
|
|
||||||
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
|
|
||||||
return payments.reduce((sum, p) => sum + p.amount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ.
|
|
||||||
* 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0.
|
|
||||||
*/
|
|
||||||
export function overLimitAmount(data: Pick<IFormData, 'TotalAmount' | 'PayAmount' | 'PreviousPayments'>): number {
|
|
||||||
if (data.TotalAmount <= 0) return 0;
|
|
||||||
const over = previousPaymentsTotal(data.PreviousPayments) + data.PayAmount - data.TotalAmount;
|
|
||||||
return over > MONEY_EPSILON ? over : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сумма с разрядами для показа («1 234 567,5»). */
|
|
||||||
export function formatAmount(value: number): string {
|
|
||||||
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEGAL_FORMS = /(^|\s)(тоо|ао|ип|llp|ооо|товарищество с ограниченной ответственностью)(?=\s|$)/gi;
|
|
||||||
|
|
||||||
/** Название контрагента для сравнения: без формы собственности, кавычек и регистра. */
|
|
||||||
export function normalizeCounterparty(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-zа-яё0-9]+/gi, ' ')
|
|
||||||
.replace(LEGAL_FORMS, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Совпадает ли получатель из СТ с поставщиком из счёта. БИН надёжнее названия:
|
|
||||||
* если он есть у обеих сторон — решает он. Нет данных по счёту — расхождения нет
|
|
||||||
* (подсвечивать нечего).
|
|
||||||
*/
|
|
||||||
export function recipientMatchesInvoice(
|
|
||||||
recipient: { name: string; bin: string },
|
|
||||||
invoice: { name: string; bin: string }
|
|
||||||
): boolean {
|
|
||||||
if (!invoice.name && !invoice.bin) return true;
|
|
||||||
if (recipient.bin && invoice.bin) return recipient.bin === invoice.bin;
|
|
||||||
const a = normalizeCounterparty(recipient.name);
|
|
||||||
const b = normalizeCounterparty(invoice.name);
|
|
||||||
if (!a || !b) return true;
|
|
||||||
return a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ICompany, IProject } from '../../shared/dictionary';
|
|
||||||
import { normalizeCounterparty } from './amounts';
|
|
||||||
import { IFormData, IPreviousPayment } from './model';
|
|
||||||
import { rowsFromBasis } from './positions';
|
|
||||||
import { IBasisInfo } from './services/basis.types';
|
|
||||||
|
|
||||||
/** Проект из справочника по названию: в СТ и потребности проект хранится только текстом. */
|
|
||||||
function findProject(projects: IProject[], name: string): IProject | undefined {
|
|
||||||
const wanted = name.trim().toLowerCase();
|
|
||||||
return wanted ? projects.filter((p) => p.name.trim().toLowerCase() === wanted)[0] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Компания из справочника по подсказке (организация потребности). */
|
|
||||||
function findCompany(companies: ICompany[], hint: string): ICompany | undefined {
|
|
||||||
const wanted = normalizeCounterparty(hint);
|
|
||||||
if (!wanted) return undefined;
|
|
||||||
return companies.filter((c) => normalizeCounterparty(c.Name) === wanted)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Поля формы, которые подставляет выбранное основание. Всё подставленное остаётся
|
|
||||||
* редактируемым: справочники не всегда точны, а заявитель лучше знает, что платит.
|
|
||||||
* Счёт и сумма оплаты основанием не затрагиваются.
|
|
||||||
*/
|
|
||||||
export function basisPatch(
|
|
||||||
info: IBasisInfo,
|
|
||||||
previous: IPreviousPayment[],
|
|
||||||
projects: IProject[],
|
|
||||||
companies: ICompany[]
|
|
||||||
): Partial<IFormData> {
|
|
||||||
const project = findProject(projects, info.projectName);
|
|
||||||
const company = findCompany(companies, info.companyHint);
|
|
||||||
return {
|
|
||||||
BasisKind: info.kind,
|
|
||||||
BasisKey: info.key,
|
|
||||||
BasisTitle: info.title,
|
|
||||||
ProjectName: info.projectName,
|
|
||||||
ProjectId: project ? project.oid : '',
|
|
||||||
ProjectManager: project ? project.projectManager || '' : '',
|
|
||||||
CompanyId: company ? company.Id : '',
|
|
||||||
CompanyName: company ? company.Name : '',
|
|
||||||
RecipientName: info.recipientName,
|
|
||||||
RecipientBin: info.recipientBin,
|
|
||||||
Section: info.section,
|
|
||||||
WorkKind: info.workKind,
|
|
||||||
TotalAmount: info.total,
|
|
||||||
Currency: info.currency,
|
|
||||||
DeliveryTerms: info.deliveryTerms,
|
|
||||||
PaymentTerms: info.paymentTerms,
|
|
||||||
DeliveryTime: info.deliveryTime,
|
|
||||||
PreviousPayments: previous,
|
|
||||||
Positions: rowsFromBasis(info.positions)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { IFormData, IPositionRow } from './model';
|
|
||||||
import { buildRows, IInvoiceItem, IPositionPair } from './positions';
|
|
||||||
import { IInvoiceData } from './services/invoiceAi.service';
|
|
||||||
import { IBasisPosition } from './services/basis.types';
|
|
||||||
|
|
||||||
/** Назначение платежа по номеру счёта. */
|
|
||||||
export function purposeForInvoice(invoiceNumber: string): string {
|
|
||||||
return invoiceNumber ? `Оплата по счёту № ${invoiceNumber}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Позиции основания из уже собранной таблицы (строки «нет в СТ» — это позиции
|
|
||||||
* счёта, к основанию они не относятся). Нужны, чтобы повторно сверить таблицу с
|
|
||||||
* другим счётом, не перезагружая СТ.
|
|
||||||
*/
|
|
||||||
export function basisPositionsFromRows(rows: IPositionRow[]): IBasisPosition[] {
|
|
||||||
return rows
|
|
||||||
.filter((row) => row.status !== 'extra')
|
|
||||||
.map((row) => ({
|
|
||||||
code: row.code,
|
|
||||||
name: row.name,
|
|
||||||
unit: row.unit,
|
|
||||||
qty: row.qty,
|
|
||||||
supplierName: row.supplierName,
|
|
||||||
supplierQty: row.supplierQty,
|
|
||||||
supplierPrice: row.supplierPrice
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Что счёт подставляет в форму. Пустое значение из счёта поле не затирает.
|
|
||||||
* Общая сумма берётся из счёта только когда основание её не дало (потребность):
|
|
||||||
* у СТ общая сумма — предел для всех оплат, и счёт его менять не должен.
|
|
||||||
* Получатель у потребности — поставщик из счёта; у СТ — победитель, и расхождение
|
|
||||||
* со счётом подсвечивается, а не затирается.
|
|
||||||
*/
|
|
||||||
export function invoicePatch(
|
|
||||||
data: IFormData,
|
|
||||||
invoice: IInvoiceData,
|
|
||||||
items: IInvoiceItem[],
|
|
||||||
pairs: IPositionPair[]
|
|
||||||
): Partial<IFormData> {
|
|
||||||
const patch: Partial<IFormData> = {
|
|
||||||
InvoiceNumber: invoice.invoiceNumber,
|
|
||||||
InvoiceSupplierName: invoice.supplierName,
|
|
||||||
InvoiceSupplierBin: invoice.supplierBin,
|
|
||||||
Positions: buildRows(basisPositionsFromRows(data.Positions), items, pairs)
|
|
||||||
};
|
|
||||||
if (invoice.recipientAccount) patch.RecipientAccount = invoice.recipientAccount;
|
|
||||||
if (invoice.currency) patch.Currency = invoice.currency;
|
|
||||||
if (invoice.invoiceNumber) patch.PaymentPurpose = purposeForInvoice(invoice.invoiceNumber);
|
|
||||||
if (invoice.vatAmount > 0) patch.VatAmount = invoice.vatAmount;
|
|
||||||
if (invoice.totalAmount > 0) {
|
|
||||||
patch.PayAmount = invoice.totalAmount;
|
|
||||||
if (data.TotalAmount <= 0) patch.TotalAmount = invoice.totalAmount;
|
|
||||||
}
|
|
||||||
if (!data.RecipientName && invoice.supplierName) {
|
|
||||||
patch.RecipientName = invoice.supplierName;
|
|
||||||
patch.RecipientBin = invoice.supplierBin;
|
|
||||||
}
|
|
||||||
return patch;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { getSP } from '../../shared/pnp/pnpjs';
|
|
||||||
import { dataListTitle } from '../../shared/listTitles';
|
|
||||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
|
||||||
import { EMPTY_DATA, FORM_ID, IFormData, IPositionRow, IPreviousPayment, BasisKind } from './model';
|
|
||||||
|
|
||||||
export interface ILoadedRequest {
|
|
||||||
data: IFormData;
|
|
||||||
status: string;
|
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Текстовые колонки, которые читаются как есть (имя поля формы = имя колонки). */
|
|
||||||
const TEXT_KEYS = [
|
|
||||||
'RequesterEmail',
|
|
||||||
'RequesterName',
|
|
||||||
'RequesterJobTitle',
|
|
||||||
'Organization',
|
|
||||||
'Department',
|
|
||||||
'DepartmentId',
|
|
||||||
'BasisKey',
|
|
||||||
'BasisTitle',
|
|
||||||
'ProjectName',
|
|
||||||
'ProjectId',
|
|
||||||
'ProjectManager',
|
|
||||||
'CompanyId',
|
|
||||||
'CompanyName',
|
|
||||||
'BankAccountId',
|
|
||||||
'BankAccountName',
|
|
||||||
'RecipientName',
|
|
||||||
'RecipientBin',
|
|
||||||
'RecipientAccount',
|
|
||||||
'Section',
|
|
||||||
'WorkKind',
|
|
||||||
'ContractId',
|
|
||||||
'ContractName',
|
|
||||||
'CashFlowGroupId',
|
|
||||||
'CashFlowGroupName',
|
|
||||||
'CashFlowItemId',
|
|
||||||
'CashFlowItemName',
|
|
||||||
'PaymentPurpose',
|
|
||||||
'DeliveryTerms',
|
|
||||||
'PaymentTerms',
|
|
||||||
'Currency',
|
|
||||||
'DeliveryTime',
|
|
||||||
'Comment',
|
|
||||||
'InvoiceFileName',
|
|
||||||
'InvoiceUrl',
|
|
||||||
'InvoiceNumber',
|
|
||||||
'InvoiceSupplierName',
|
|
||||||
'InvoiceSupplierBin'
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const NUMBER_KEYS = ['TotalAmount', 'VatAmount', 'PayAmount'] as const;
|
|
||||||
|
|
||||||
const SELECT_FIELDS = [
|
|
||||||
'Id',
|
|
||||||
'RequestDate',
|
|
||||||
'BasisKind',
|
|
||||||
'PreviousPaymentsJson',
|
|
||||||
'PositionsJson',
|
|
||||||
'RequestStatus',
|
|
||||||
'RequestUniqueCode',
|
|
||||||
...TEXT_KEYS,
|
|
||||||
...NUMBER_KEYS
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
|
|
||||||
* значении или битом JSON — заявка должна открываться даже с испорченной
|
|
||||||
* таблицей, иначе её нельзя было бы починить через форму.
|
|
||||||
*/
|
|
||||||
function parseRows<T>(raw: unknown): T[] {
|
|
||||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Прочитать «Заявку на оплату» по id: модель формы + статус + код.
|
|
||||||
* listId — партиция, где лежит именно эта заявка; не задан — текущая.
|
|
||||||
*/
|
|
||||||
export async function loadPaymentRequest(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(...SELECT_FIELDS)()) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const data: IFormData = {
|
|
||||||
...EMPTY_DATA,
|
|
||||||
RequestDate: spDateOnlyToIso(item.RequestDate),
|
|
||||||
BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct',
|
|
||||||
PreviousPayments: parseRows<IPreviousPayment>(item.PreviousPaymentsJson),
|
|
||||||
Positions: parseRows<IPositionRow>(item.PositionsJson)
|
|
||||||
};
|
|
||||||
TEXT_KEYS.forEach((key) => {
|
|
||||||
data[key] = (item[key] as string) || '';
|
|
||||||
});
|
|
||||||
NUMBER_KEYS.forEach((key) => {
|
|
||||||
data[key] = Number(item[key] || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
status: (item.RequestStatus as string) || '',
|
|
||||||
code: (item.RequestUniqueCode as string) || ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
|
||||||
import { RequestTypeId } from '../../models/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
|
||||||
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
|
||||||
*/
|
|
||||||
export const FORM_ID: RequestTypeId = 'acc-payment-request';
|
|
||||||
|
|
||||||
/** На чём основана оплата: согласованная СТ либо потребность (когда СТ не было). */
|
|
||||||
export type BasisKind = 'ct' | 'need';
|
|
||||||
|
|
||||||
export const BASIS_OPTIONS: Array<{ value: BasisKind; label: string }> = [
|
|
||||||
{ value: 'ct', label: 'Сравнительная таблица (СТ)' },
|
|
||||||
{ value: 'need', label: 'Потребность' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Ранее поданная заявка на оплату по той же СТ/потребности. */
|
|
||||||
export interface IPreviousPayment {
|
|
||||||
/** Уникальный код заявки на оплату. */
|
|
||||||
code: string;
|
|
||||||
amount: number;
|
|
||||||
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
|
|
||||||
id?: number;
|
|
||||||
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
|
|
||||||
listId?: string;
|
|
||||||
/** Статус заявки на момент чтения. */
|
|
||||||
status?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Результат сверки строки с позициями счёта:
|
|
||||||
* ok — совпало; diff — позиция та же, но отличается количество/ед. изм.;
|
|
||||||
* missing — есть в СТ/потребности, в счёте нет; extra — есть в счёте, в СТ нет.
|
|
||||||
*/
|
|
||||||
export type RowStatus = 'ok' | 'diff' | 'missing' | 'extra';
|
|
||||||
|
|
||||||
/** Строка таблицы позиций: СТ/потребность ↔ поставщик ↔ счёт. */
|
|
||||||
export interface IPositionRow {
|
|
||||||
/** Уникальный код позиции потребности. */
|
|
||||||
code: string;
|
|
||||||
/** Наименование и ед. изм. из потребности (в СТ они есть). */
|
|
||||||
name: string;
|
|
||||||
unit: string;
|
|
||||||
qty: number;
|
|
||||||
/** Позиция поставщика из его КП (только при основании «СТ»). */
|
|
||||||
supplierName: string;
|
|
||||||
supplierQty: number;
|
|
||||||
supplierPrice: number;
|
|
||||||
/** Позиция из счёта. */
|
|
||||||
invoiceName: string;
|
|
||||||
invoiceUnit: string;
|
|
||||||
invoiceQty: number;
|
|
||||||
invoicePrice: number;
|
|
||||||
invoiceSum: number;
|
|
||||||
status: RowStatus;
|
|
||||||
/** Пояснение к расхождению; '' — нет. */
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Данные формы «Заявка на оплату». Ключи = internalName колонок SP-списка
|
|
||||||
* «Заявка на оплату — данные». Табличные части уходят в JSON-колонки
|
|
||||||
* (PreviousPaymentsJson, PositionsJson), суммы — в числовые колонки (для отчётов).
|
|
||||||
*/
|
|
||||||
export interface IFormData {
|
|
||||||
// --- Заявитель ---
|
|
||||||
RequesterEmail: string;
|
|
||||||
RequesterName: string;
|
|
||||||
RequesterJobTitle: string;
|
|
||||||
Organization: string;
|
|
||||||
Department: string;
|
|
||||||
DepartmentId: string;
|
|
||||||
RequestDate: string;
|
|
||||||
|
|
||||||
// --- Основание ---
|
|
||||||
BasisKind: BasisKind;
|
|
||||||
/** Ключ основания для поиска прежних оплат: «ct:<Id СТ>» либо «need:<oid потребности>». */
|
|
||||||
BasisKey: string;
|
|
||||||
/** Подпись основания: «СТ №3 — Проект» либо «Потребность 081/26-04/0017». */
|
|
||||||
BasisTitle: string;
|
|
||||||
ProjectName: string;
|
|
||||||
/** oid проекта: по нему бэкенд резолвит маршрут согласования. */
|
|
||||||
ProjectId: string;
|
|
||||||
ProjectManager: string;
|
|
||||||
CompanyId: string;
|
|
||||||
CompanyName: string;
|
|
||||||
|
|
||||||
// --- Платёж ---
|
|
||||||
BankAccountId: string;
|
|
||||||
BankAccountName: string;
|
|
||||||
RecipientName: string;
|
|
||||||
RecipientBin: string;
|
|
||||||
/** Счёт получателя (IBAN) — из файла счёта. */
|
|
||||||
RecipientAccount: string;
|
|
||||||
Section: string;
|
|
||||||
WorkKind: string;
|
|
||||||
ContractId: string;
|
|
||||||
ContractName: string;
|
|
||||||
CashFlowGroupId: string;
|
|
||||||
CashFlowGroupName: string;
|
|
||||||
CashFlowItemId: string;
|
|
||||||
CashFlowItemName: string;
|
|
||||||
PaymentPurpose: string;
|
|
||||||
DeliveryTerms: string;
|
|
||||||
PaymentTerms: string;
|
|
||||||
|
|
||||||
// --- Суммы ---
|
|
||||||
/** Суммы хранятся числом: 0 — не заполнено. */
|
|
||||||
TotalAmount: number;
|
|
||||||
VatAmount: number;
|
|
||||||
Currency: string;
|
|
||||||
PayAmount: number;
|
|
||||||
DeliveryTime: string;
|
|
||||||
Comment: string;
|
|
||||||
|
|
||||||
// --- Счёт ---
|
|
||||||
InvoiceFileName: string;
|
|
||||||
/** Адрес файла в библиотеке счетов (ServerRelativeUrl). */
|
|
||||||
InvoiceUrl: string;
|
|
||||||
InvoiceNumber: string;
|
|
||||||
/** Поставщик, как он назван в самом счёте, — сверяется с получателем из СТ. */
|
|
||||||
InvoiceSupplierName: string;
|
|
||||||
InvoiceSupplierBin: string;
|
|
||||||
|
|
||||||
// --- Табличные части ---
|
|
||||||
PreviousPayments: IPreviousPayment[];
|
|
||||||
Positions: IPositionRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
|
||||||
|
|
||||||
/** Пропсы каждой секции формы. */
|
|
||||||
export interface ISectionProps {
|
|
||||||
data: IFormData;
|
|
||||||
set: SetField;
|
|
||||||
context: WebPartContext;
|
|
||||||
/** Подставить сразу несколько полей (выбор основания меняет их пачкой). */
|
|
||||||
patch: (changes: Partial<IFormData>) => void;
|
|
||||||
printOrgIndex?: number;
|
|
||||||
onRequesterChange?: (login: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EMPTY_DATA: IFormData = {
|
|
||||||
RequesterEmail: '',
|
|
||||||
RequesterName: '',
|
|
||||||
RequesterJobTitle: '',
|
|
||||||
Organization: '',
|
|
||||||
Department: '',
|
|
||||||
DepartmentId: '',
|
|
||||||
RequestDate: '',
|
|
||||||
BasisKind: 'ct',
|
|
||||||
BasisKey: '',
|
|
||||||
BasisTitle: '',
|
|
||||||
ProjectName: '',
|
|
||||||
ProjectId: '',
|
|
||||||
ProjectManager: '',
|
|
||||||
CompanyId: '',
|
|
||||||
CompanyName: '',
|
|
||||||
BankAccountId: '',
|
|
||||||
BankAccountName: '',
|
|
||||||
RecipientName: '',
|
|
||||||
RecipientBin: '',
|
|
||||||
RecipientAccount: '',
|
|
||||||
Section: '',
|
|
||||||
WorkKind: '',
|
|
||||||
ContractId: '',
|
|
||||||
ContractName: '',
|
|
||||||
CashFlowGroupId: '',
|
|
||||||
CashFlowGroupName: '',
|
|
||||||
CashFlowItemId: '',
|
|
||||||
CashFlowItemName: '',
|
|
||||||
PaymentPurpose: '',
|
|
||||||
DeliveryTerms: '',
|
|
||||||
PaymentTerms: '',
|
|
||||||
TotalAmount: 0,
|
|
||||||
VatAmount: 0,
|
|
||||||
Currency: '',
|
|
||||||
PayAmount: 0,
|
|
||||||
DeliveryTime: '',
|
|
||||||
Comment: '',
|
|
||||||
InvoiceFileName: '',
|
|
||||||
InvoiceUrl: '',
|
|
||||||
InvoiceNumber: '',
|
|
||||||
InvoiceSupplierName: '',
|
|
||||||
InvoiceSupplierBin: '',
|
|
||||||
PreviousPayments: [],
|
|
||||||
Positions: []
|
|
||||||
};
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { buildRows, hasPositionIssues, IInvoiceItem } from './positions';
|
|
||||||
import { IBasisPosition } from './services/basis.types';
|
|
||||||
|
|
||||||
const base = (name: string, qty: number, unit = 'шт'): IBasisPosition => ({
|
|
||||||
code: `K-${name}`,
|
|
||||||
name,
|
|
||||||
unit,
|
|
||||||
qty,
|
|
||||||
supplierName: '',
|
|
||||||
supplierQty: 0,
|
|
||||||
supplierPrice: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const item = (name: string, quantity: number, unit = 'шт'): IInvoiceItem => ({
|
|
||||||
name,
|
|
||||||
unit,
|
|
||||||
quantity,
|
|
||||||
price: 10,
|
|
||||||
sum: 10 * quantity
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildRows', () => {
|
|
||||||
it('совпавшая пара — ok', () => {
|
|
||||||
const rows = buildRows([base('Цемент', 5)], [item('Цемент М400', 5)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
|
||||||
expect(rows).toHaveLength(1);
|
|
||||||
expect(rows[0].status).toBe('ok');
|
|
||||||
expect(rows[0].invoiceName).toBe('Цемент М400');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('разное количество — diff с пояснением', () => {
|
|
||||||
const rows = buildRows([base('Цемент', 5)], [item('Цемент', 7)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
|
||||||
expect(rows[0].status).toBe('diff');
|
|
||||||
expect(rows[0].note).toContain('количество');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('единицы измерения сравниваются без регистра и точек', () => {
|
|
||||||
const rows = buildRows([base('Труба', 2, 'Шт.')], [item('Труба', 2, 'шт')], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
|
||||||
expect(rows[0].status).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('позиция, которую ИИ не упомянул, не пропадает', () => {
|
|
||||||
const rows = buildRows([base('А', 1), base('Б', 1)], [item('А', 1), item('В', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
|
|
||||||
expect(rows.map((r) => r.status)).toEqual(['ok', 'missing', 'extra']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('пары с неверными индексами и повторы отбрасываются', () => {
|
|
||||||
const rows = buildRows(
|
|
||||||
[base('А', 1)],
|
|
||||||
[item('А', 1), item('Б', 1)],
|
|
||||||
[
|
|
||||||
{ baseIndex: 0, invoiceIndex: 0, note: '' },
|
|
||||||
{ baseIndex: 0, invoiceIndex: 1, note: '' },
|
|
||||||
{ baseIndex: 9, invoiceIndex: 1, note: '' }
|
|
||||||
]
|
|
||||||
);
|
|
||||||
expect(rows.map((r) => r.status)).toEqual(['ok', 'extra']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hasPositionIssues видит любое расхождение', () => {
|
|
||||||
expect(hasPositionIssues(buildRows([base('А', 1)], [], []))).toBe(true);
|
|
||||||
expect(hasPositionIssues(buildRows([base('А', 1)], [item('А', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { IPositionRow } from './model';
|
|
||||||
import { IBasisPosition } from './services/basis.types';
|
|
||||||
|
|
||||||
/** Позиция, распознанная в счёте. */
|
|
||||||
export interface IInvoiceItem {
|
|
||||||
name: string;
|
|
||||||
unit: string;
|
|
||||||
quantity: number;
|
|
||||||
price: number;
|
|
||||||
sum: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Пара, которую вернула ИИ: какой позиции основания (baseIndex) соответствует
|
|
||||||
* какая позиция счёта (invoiceIndex). −1 с одной из сторон — пары нет.
|
|
||||||
*/
|
|
||||||
export interface IPositionPair {
|
|
||||||
baseIndex: number;
|
|
||||||
invoiceIndex: number;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QTY_EPSILON = 0.0001;
|
|
||||||
|
|
||||||
function emptyInvoicePart(): Pick<IPositionRow, 'invoiceName' | 'invoiceUnit' | 'invoiceQty' | 'invoicePrice' | 'invoiceSum'> {
|
|
||||||
return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function invoicePart(item: IInvoiceItem): ReturnType<typeof emptyInvoicePart> {
|
|
||||||
return {
|
|
||||||
invoiceName: item.name,
|
|
||||||
invoiceUnit: item.unit,
|
|
||||||
invoiceQty: item.quantity,
|
|
||||||
invoicePrice: item.price,
|
|
||||||
invoiceSum: item.sum
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ед. изм. для сравнения: «шт.», «Шт» и «шт» — одно и то же. */
|
|
||||||
function normalizeUnit(unit: string): string {
|
|
||||||
return unit.toLowerCase().replace(/[.\s]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function basisPart(position: IBasisPosition): Pick<IPositionRow, 'code' | 'name' | 'unit' | 'qty' | 'supplierName' | 'supplierQty' | 'supplierPrice'> {
|
|
||||||
return {
|
|
||||||
code: position.code,
|
|
||||||
name: position.name,
|
|
||||||
unit: position.unit,
|
|
||||||
qty: position.qty,
|
|
||||||
supplierName: position.supplierName,
|
|
||||||
supplierQty: position.supplierQty,
|
|
||||||
supplierPrice: position.supplierPrice
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Строки таблицы до загрузки счёта: только основание, сверять пока не с чем. */
|
|
||||||
export function rowsFromBasis(base: IBasisPosition[]): IPositionRow[] {
|
|
||||||
return base.map((position) => ({
|
|
||||||
...basisPart(position),
|
|
||||||
...emptyInvoicePart(),
|
|
||||||
status: 'ok',
|
|
||||||
note: ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Расхождения парной строки: ед. изм. и количество сравниваем сами, не полагаясь на ИИ. */
|
|
||||||
function diffNote(position: IBasisPosition, item: IInvoiceItem, aiNote: string): string {
|
|
||||||
const notes: string[] = [];
|
|
||||||
if (position.unit && item.unit && normalizeUnit(position.unit) !== normalizeUnit(item.unit)) {
|
|
||||||
notes.push(`ед. изм.: ${position.unit} → ${item.unit}`);
|
|
||||||
}
|
|
||||||
if (Math.abs(position.qty - item.quantity) > QTY_EPSILON) {
|
|
||||||
notes.push(`количество: ${position.qty} → ${item.quantity}`);
|
|
||||||
}
|
|
||||||
// Смысловое отличие названия (другая марка, размер) видит только ИИ — его пометка сама по себе расхождение.
|
|
||||||
if (aiNote) notes.push(aiNote);
|
|
||||||
return notes.join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Собрать таблицу сверки из ответа ИИ. Пары с неверными индексами и повторы
|
|
||||||
* отбрасываются, а всё, что ИИ не упомянул, попадает в таблицу как «нет в счёте» /
|
|
||||||
* «нет в основании»: ни одна позиция не должна пропасть молча.
|
|
||||||
*/
|
|
||||||
export function buildRows(base: IBasisPosition[], invoice: IInvoiceItem[], pairs: IPositionPair[]): IPositionRow[] {
|
|
||||||
const invoiceByBase = new Map<number, { index: number; note: string }>();
|
|
||||||
const usedInvoice = new Set<number>();
|
|
||||||
|
|
||||||
pairs.forEach((pair) => {
|
|
||||||
const validBase = pair.baseIndex >= 0 && pair.baseIndex < base.length;
|
|
||||||
const validInvoice = pair.invoiceIndex >= 0 && pair.invoiceIndex < invoice.length;
|
|
||||||
if (!validBase || !validInvoice) return;
|
|
||||||
if (invoiceByBase.has(pair.baseIndex) || usedInvoice.has(pair.invoiceIndex)) return;
|
|
||||||
invoiceByBase.set(pair.baseIndex, { index: pair.invoiceIndex, note: pair.note });
|
|
||||||
usedInvoice.add(pair.invoiceIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows: IPositionRow[] = base.map((position, baseIndex) => {
|
|
||||||
const match = invoiceByBase.get(baseIndex);
|
|
||||||
if (!match) {
|
|
||||||
return { ...basisPart(position), ...emptyInvoicePart(), status: 'missing', note: 'Нет в счёте' };
|
|
||||||
}
|
|
||||||
const item = invoice[match.index];
|
|
||||||
const note = diffNote(position, item, match.note);
|
|
||||||
return { ...basisPart(position), ...invoicePart(item), status: note ? 'diff' : 'ok', note };
|
|
||||||
});
|
|
||||||
|
|
||||||
invoice.forEach((item, index) => {
|
|
||||||
if (usedInvoice.has(index)) return;
|
|
||||||
rows.push({
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
unit: '',
|
|
||||||
qty: 0,
|
|
||||||
supplierName: '',
|
|
||||||
supplierQty: 0,
|
|
||||||
supplierPrice: 0,
|
|
||||||
...invoicePart(item),
|
|
||||||
status: 'extra',
|
|
||||||
note: 'Нет в СТ / потребности'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Есть ли в таблице хоть одно расхождение со счётом. */
|
|
||||||
export function hasPositionIssues(rows: IPositionRow[]): boolean {
|
|
||||||
return rows.some((row) => row.status !== 'ok');
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"dataListTitle": "Заявка на оплату — данные",
|
|
||||||
"columns": [
|
|
||||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
|
||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
|
||||||
|
|
||||||
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
|
|
||||||
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
|
|
||||||
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
|
|
||||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
|
||||||
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
|
|
||||||
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
|
|
||||||
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
|
|
||||||
|
|
||||||
{ "internalName": "BasisKind", "title": "Основание (ct / need)", "type": "Text" },
|
|
||||||
{ "internalName": "BasisKey", "title": "Ключ основания (ct:Id / need:oid)", "type": "Text" },
|
|
||||||
{ "internalName": "BasisTitle", "title": "Основание", "type": "Text" },
|
|
||||||
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
|
|
||||||
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
|
|
||||||
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
|
|
||||||
{ "internalName": "CompanyId", "title": "Id компании", "type": "Text" },
|
|
||||||
{ "internalName": "CompanyName", "title": "Компания", "type": "Text" },
|
|
||||||
|
|
||||||
{ "internalName": "BankAccountId", "title": "Id банковского счёта (1С)", "type": "Text" },
|
|
||||||
{ "internalName": "BankAccountName", "title": "Банковский счёт", "type": "Text" },
|
|
||||||
{ "internalName": "RecipientName", "title": "Получатель", "type": "Text" },
|
|
||||||
{ "internalName": "RecipientBin", "title": "БИН получателя", "type": "Text" },
|
|
||||||
{ "internalName": "RecipientAccount", "title": "Счёт получателя", "type": "Text" },
|
|
||||||
{ "internalName": "Section", "title": "Раздел", "type": "Text" },
|
|
||||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
|
||||||
{ "internalName": "ContractId", "title": "Id договора (1С)", "type": "Text" },
|
|
||||||
{ "internalName": "ContractName", "title": "Договор", "type": "Text" },
|
|
||||||
{ "internalName": "CashFlowGroupId", "title": "Id группы статьи ДДС", "type": "Text" },
|
|
||||||
{ "internalName": "CashFlowGroupName", "title": "Группа статьи ДДС", "type": "Text" },
|
|
||||||
{ "internalName": "CashFlowItemId", "title": "Id статьи ДДС", "type": "Text" },
|
|
||||||
{ "internalName": "CashFlowItemName", "title": "Статья ДДС", "type": "Text" },
|
|
||||||
{ "internalName": "PaymentPurpose", "title": "Назначение платежа", "type": "Note" },
|
|
||||||
{ "internalName": "DeliveryTerms", "title": "Условия доставки", "type": "Note" },
|
|
||||||
{ "internalName": "PaymentTerms", "title": "Условия оплаты", "type": "Note" },
|
|
||||||
|
|
||||||
{ "internalName": "TotalAmount", "title": "Общая сумма", "type": "Number" },
|
|
||||||
{ "internalName": "VatAmount", "title": "НДС", "type": "Number" },
|
|
||||||
{ "internalName": "Currency", "title": "Валюта", "type": "Text" },
|
|
||||||
{ "internalName": "PayAmount", "title": "Сумма оплаты", "type": "Number" },
|
|
||||||
{ "internalName": "DeliveryTime", "title": "Общий срок поставки", "type": "Text" },
|
|
||||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
|
||||||
|
|
||||||
{ "internalName": "InvoiceFileName", "title": "Файл счёта", "type": "Text" },
|
|
||||||
{ "internalName": "InvoiceUrl", "title": "Адрес файла счёта", "type": "Text" },
|
|
||||||
{ "internalName": "InvoiceNumber", "title": "Номер счёта", "type": "Text" },
|
|
||||||
{ "internalName": "InvoiceSupplierName", "title": "Поставщик по счёту", "type": "Text" },
|
|
||||||
{ "internalName": "InvoiceSupplierBin", "title": "БИН поставщика по счёту", "type": "Text" },
|
|
||||||
|
|
||||||
{ "internalName": "PreviousPaymentsJson", "title": "Ранее поданные оплаты (JSON)", "type": "Note" },
|
|
||||||
{ "internalName": "PositionsJson", "title": "Позиции: СТ, поставщик, счёт (JSON)", "type": "Note" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { TextInput } from '../../../shared/ui';
|
|
||||||
import { formatAmountInput, parseAmount } from '../amounts';
|
|
||||||
|
|
||||||
interface IAmountInputProps {
|
|
||||||
id?: string;
|
|
||||||
value: number;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
|
|
||||||
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
|
|
||||||
* каждом нажатии и дробную часть ввести было бы нельзя.
|
|
||||||
*/
|
|
||||||
export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }) => {
|
|
||||||
const [text, setText] = React.useState<string>(toText(value));
|
|
||||||
|
|
||||||
// Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его.
|
|
||||||
React.useEffect(() => {
|
|
||||||
setText((current) => (parseAmount(current) === value ? current : toText(value)));
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextInput
|
|
||||||
id={id}
|
|
||||||
value={text}
|
|
||||||
onChange={(next) => {
|
|
||||||
const formatted = formatAmountInput(next);
|
|
||||||
setText(formatted);
|
|
||||||
onChange(parseAmount(formatted));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import styles from './PaymentSections.module.scss';
|
|
||||||
import { FormField, TextInput, TextArea } from '../../../shared/ui';
|
|
||||||
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
|
|
||||||
import { IPreviousPayment, ISectionProps } from '../model';
|
|
||||||
import { AmountInput } from './AmountInput';
|
|
||||||
import { RequestDrawer } from './RequestDrawer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
|
|
||||||
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
|
|
||||||
*/
|
|
||||||
export const AmountsSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
|
||||||
// Прежняя заявка, открытая в панели справа; undefined — панель закрыта.
|
|
||||||
const [opened, setOpened] = React.useState<IPreviousPayment | undefined>(undefined);
|
|
||||||
const close = React.useCallback(() => setOpened(undefined), []);
|
|
||||||
const over = overLimitAmount(data);
|
|
||||||
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label="Общая сумма" htmlFor="TotalAmount">
|
|
||||||
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Валюта" htmlFor="Currency">
|
|
||||||
<TextInput id="Currency" value={data.Currency} onChange={(v) => set('Currency', v.toUpperCase())} placeholder="KZT" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="НДС" htmlFor="VatAmount">
|
|
||||||
<AmountInput id="VatAmount" value={data.VatAmount} onChange={(v) => set('VatAmount', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Сумма оплаты" htmlFor="PayAmount" required>
|
|
||||||
<div className={over > 0 ? styles.warnBox : undefined}>
|
|
||||||
<AmountInput id="PayAmount" value={data.PayAmount} onChange={(v) => set('PayAmount', v)} />
|
|
||||||
{over > 0 && (
|
|
||||||
<div className={styles.warnText}>
|
|
||||||
Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Общий срок поставки" htmlFor="DeliveryTime">
|
|
||||||
<TextInput id="DeliveryTime" value={data.DeliveryTime} onChange={(v) => set('DeliveryTime', v)} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Комментарий" htmlFor="Comment" full>
|
|
||||||
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
|
|
||||||
{data.PreviousPayments.length === 0 ? (
|
|
||||||
<div className={`${styles.empty} ${styles.fullRow}`}>Ранее заявок на оплату по этому основанию не было.</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.tableWrap}>
|
|
||||||
<table className={styles.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Номер заявки на оплату</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th className={styles.num}>Сумма оплаты</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.PreviousPayments.map((p) => (
|
|
||||||
<tr key={p.code}>
|
|
||||||
<td>
|
|
||||||
{p.id ? (
|
|
||||||
<button type="button" className={styles.link} onClick={() => setOpened(p)}>
|
|
||||||
{p.code}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
p.code
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{p.status || ''}</td>
|
|
||||||
<td className={styles.num}>{formatAmount(p.amount)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={2}>Итого ранее</td>
|
|
||||||
<td className={styles.num}>{formatAmount(previousTotal)}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{opened && opened.id && (
|
|
||||||
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
|
||||||
import { IDictionaryItem, ItemPicker } from '../../../shared/dictionary';
|
|
||||||
import { friendlyErrorMessage } from '../../../shared/diagnostics';
|
|
||||||
import { BasisKind } from '../model';
|
|
||||||
import { IBasisOption } from '../services/basis.types';
|
|
||||||
import { listApprovedCts } from '../services/ctBasis.service';
|
|
||||||
import { listNeeds } from '../services/needBasis.service';
|
|
||||||
|
|
||||||
interface IBasisPickerProps {
|
|
||||||
context: WebPartContext;
|
|
||||||
kind: BasisKind;
|
|
||||||
/** Подпись выбранного основания. */
|
|
||||||
value: string;
|
|
||||||
onPick: (id: string) => void;
|
|
||||||
readOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPickerItem(option: IBasisOption): IDictionaryItem {
|
|
||||||
return { id: option.id, label: option.title, note: option.note };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Поиск основания: согласованные СТ либо потребности (по выбранному виду основания).
|
|
||||||
* Выбор запускает загрузку СТ/потребности целиком (см. useBasisSelection), поэтому
|
|
||||||
* ручной ввод текста основанием не считается — onPick зовётся только с id записи.
|
|
||||||
*/
|
|
||||||
export const BasisPicker: React.FC<IBasisPickerProps> = ({ context, kind, value, onPick, readOnly }) => {
|
|
||||||
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
|
|
||||||
const [error, setError] = React.useState<string>('');
|
|
||||||
// null — показываем выбранное основание; строка — пользователь печатает запрос (в т.ч. пустой).
|
|
||||||
const [query, setQuery] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (readOnly) return undefined;
|
|
||||||
let alive = true;
|
|
||||||
setError('');
|
|
||||||
setItems([]);
|
|
||||||
(kind === 'ct' ? listApprovedCts(context) : listNeeds(context))
|
|
||||||
.then((options) => {
|
|
||||||
if (alive) setItems(options.map(toPickerItem));
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (alive) setError(`Не удалось загрузить список: ${friendlyErrorMessage(e)}`);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, [context, kind, readOnly]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ItemPicker
|
|
||||||
id="BasisTitle"
|
|
||||||
items={items}
|
|
||||||
value={query ?? value}
|
|
||||||
readOnly={readOnly}
|
|
||||||
placeholder={kind === 'ct' ? 'Найдите согласованную СТ по номеру или проекту…' : 'Найдите потребность по номеру или проекту…'}
|
|
||||||
hint={error}
|
|
||||||
onChange={(label, id) => {
|
|
||||||
setQuery(id ? null : label);
|
|
||||||
if (id) onPick(id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user