Compare commits
16 Commits
093bbecd9f
...
a750db9d7f
| Author | SHA1 | Date | |
|---|---|---|---|
| a750db9d7f | |||
| ea04602606 | |||
| 57feeddcfe | |||
| fa2e9593f1 | |||
| 8d162db4ba | |||
| 51f3c6fb9a | |||
| 63448e60aa | |||
| 50a1ae2ce3 | |||
| d07056937a | |||
| 17f448a1c2 | |||
| 6122370bbf | |||
| cd7b27daa2 | |||
| fa84dd6ad5 | |||
| 08dd151c56 | |||
| c892fb26a9 | |||
| 513f6c1d30 |
+1
-1
@@ -49,7 +49,7 @@
|
||||
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
|
||||
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
|
||||
| [`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` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
|
||||
import { submitDateOnOpen } from '../../shared/submitDate';
|
||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { draftIssues, submitIssues } from './validation';
|
||||
import { syncPerDiem } from './estimate';
|
||||
import { syncRouteItems } from './estimate';
|
||||
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
||||
import { loadBusinessTripRequest } from './load';
|
||||
import { useTripEmployee } from './useTripEmployee';
|
||||
@@ -86,10 +86,10 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
|
||||
// Суточные и проживание строятся по маршруту (см. syncRouteItems): секции, проверки и сохранение
|
||||
// работают со сметой, уже приведённой к текущим точкам и датам.
|
||||
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
|
||||
const handleEmployeeChange = useTripEmployee(context, editId, setData);
|
||||
const formData: IFormData = { ...data, Estimate: syncRouteItems(data.Estimate, data.Route) };
|
||||
const employee = useTripEmployee(context, editId, data.Employee, setData);
|
||||
|
||||
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
|
||||
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
||||
const draftMissing = draftIssues(formData);
|
||||
const submitMissing = submitIssues(formData);
|
||||
const submitMissing = submitIssues(formData, employee.organizations);
|
||||
|
||||
const validate = (): string | undefined => {
|
||||
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
||||
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
|
||||
const sectionProps = { data: formData, set, context, employee };
|
||||
|
||||
return (
|
||||
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
||||
|
||||
@@ -18,13 +18,46 @@
|
||||
## Особое поведение
|
||||
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
|
||||
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
|
||||
- **Командирующая организация** (с 10-23, логика — `tripCompany.ts`). Сотрудник может работать
|
||||
в нескольких компаниях — выбирают, от какой он едет (`Select` «Компания — должность»).
|
||||
Одна организация — выбирается сама, поле заблокировано. Несколько — обязательна при отправке,
|
||||
должность пустая до выбора и подставляется из выбранной компании. Сотрудника нет в
|
||||
справочнике — поля нет, отправку не блокирует. Смена сотрудника сбрасывает выбор. Хранение:
|
||||
`TripCompany` (название) + `TripCompanyId` (CompanyId — под будущий маршрут по компании);
|
||||
`Organization` — по-прежнему все организации сотрудника. Заявки до 10-23 в просмотре
|
||||
показывают прежний список организаций.
|
||||
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
|
||||
по образцу 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`, ключ `откуда>куда`); у
|
||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
|
||||
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
|
||||
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
|
||||
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
|
||||
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
|
||||
(`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под
|
||||
билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
|
||||
- **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы →
|
||||
Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение».
|
||||
Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении.
|
||||
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
|
||||
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
|
||||
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
|
||||
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
|
||||
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
|
||||
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
|
||||
@@ -33,24 +66,40 @@
|
||||
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
|
||||
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
|
||||
прибытие раньше отъезда из предыдущей точки — ошибка.
|
||||
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
|
||||
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
|
||||
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
|
||||
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
|
||||
последней включительно; при отправке подсвечивается отъезд последней точки.
|
||||
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
|
||||
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
|
||||
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
||||
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
||||
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
||||
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
||||
суточных:
|
||||
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
|
||||
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
|
||||
суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute`
|
||||
в `ESTIMATE_KINDS`), по позиции на точку:
|
||||
- **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки,
|
||||
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
||||
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
||||
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`); точку удалили — позицию нужно привязать заново.
|
||||
@@ -64,7 +113,10 @@
|
||||
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
|
||||
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
||||
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
||||
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
|
||||
- **Комментарий** (с 10-23, `TripComment`, Note) — необязательное поле после итога сметы; в
|
||||
просмотре пустой не показывается.
|
||||
- **Обязательство и согласие** (так названа секция с 10-23, по требованию кадров) — обязательство
|
||||
по авансовому отчёту (текст бланка) и флажок согласия, без него не отправить.
|
||||
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
|
||||
можно править (см. shared/submitDate.ts).
|
||||
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
|
||||
@@ -76,8 +128,8 @@
|
||||
заголовком (см. грабли коллизий internalName).
|
||||
|
||||
## Секции формы
|
||||
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
|
||||
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
|
||||
EmployeeSection (сотрудник · должность · дата подачи · командирующая организация) → RouteSection (маршрут
|
||||
лентой; поле «Цель» убрано 10-23, колонка `TripPurpose` в SP осталась от тестовых заявок) → TasksSection (задачи) → EstimateSection (смета + итог) →
|
||||
ObligationSection (текст обязательства + согласие).
|
||||
|
||||
## Точки входа
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
|
||||
|
||||
.chips {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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 styles from './TransportChips.module.scss';
|
||||
import styles from './ChoiceChips.module.scss';
|
||||
import { useFormReadOnly } from '../../../shared/ui';
|
||||
import { TRANSPORT_TYPES } from '../model';
|
||||
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';
|
||||
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';
|
||||
const BUS =
|
||||
'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';
|
||||
// local_taxi: та же машина, что CAR, но с шашкой на крыше — чтобы такси отличалось от служебного/личного авто.
|
||||
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 =
|
||||
'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> = {
|
||||
Авиа: PLANE,
|
||||
Железнодорожный: TRAIN,
|
||||
Автобус: BUS,
|
||||
Такси: TAXI,
|
||||
'Служебный автотранспорт': 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 type { ISelectOption } from '../../../../shared/ui';
|
||||
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
|
||||
import { linkOptions, resolveLinkId } from '../../estimate';
|
||||
import { linkOptions, resolveLinkId, routeItemAutoQuantity } from '../../estimate';
|
||||
import { fieldKey } from '../../validation';
|
||||
import { AmountFields } from './AmountFields';
|
||||
import { PerDiemFields } from './PerDiemFields';
|
||||
import { AccommodationFields } from './AccommodationFields';
|
||||
|
||||
interface Props {
|
||||
kind: EstimateKind;
|
||||
@@ -21,13 +22,15 @@ interface Props {
|
||||
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
||||
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
||||
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
||||
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
||||
* (и проживание) привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
||||
*/
|
||||
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
||||
const info = ESTIMATE_KINDS[kind];
|
||||
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
||||
const showLink = !info.itemsFromRoute && options.length > 1;
|
||||
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
||||
const stop = route.stops.find((s) => s.id === item.linkId);
|
||||
const autoQuantity = routeItemAutoQuantity(kind, route, item.linkId);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
@@ -53,11 +56,9 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{info.itemsFromRoute ? (
|
||||
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
|
||||
) : (
|
||||
<AmountFields kind={kind} item={item} onChange={onChange} />
|
||||
)}
|
||||
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
|
||||
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
|
||||
<TextInput
|
||||
value={item.comment}
|
||||
onChange={(v) => onChange({ comment: v })}
|
||||
|
||||
+27
-13
@@ -1,42 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import styles from './PerDiemFields.module.scss';
|
||||
import styles from './StopItemFields.module.scss';
|
||||
import { FormField, MoneyInput } from '../../../../shared/ui';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
item: IEstimateItem;
|
||||
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
|
||||
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */
|
||||
stop: IRouteStop | undefined;
|
||||
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
|
||||
autoQuantity: string;
|
||||
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 project = stop?.project.trim();
|
||||
const limit = mrpLimit(stop);
|
||||
const isOverLimit = Number(item.mrpCount) > limit;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className={styles.calcRow}>
|
||||
<FormField label="Дней">
|
||||
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||
{hasDays ? item.quantity : '—'}
|
||||
</div>
|
||||
</FormField>
|
||||
<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="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
|
||||
<FormField label={`Кол-во МРП в день (до ${limit})`} htmlFor={fieldKey.itemMrpCount(item)}>
|
||||
<div className={styles.number}>
|
||||
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
||||
</div>
|
||||
@@ -56,6 +65,11 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className={styles.limitError}>
|
||||
Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}.
|
||||
</div>
|
||||
)}
|
||||
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
+76
@@ -1,5 +1,6 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields).
|
||||
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
||||
$calc-inline-breakpoint: 640px;
|
||||
|
||||
@@ -115,3 +116,78 @@ $calc-inline-breakpoint: 640px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
padding-top: $sp-2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './RouteEditor.module.scss';
|
||||
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
|
||||
import { IRouteStop, ITripRoute } from '../../model';
|
||||
import { IRouteLeg, createStop, routeLegs } from '../../route';
|
||||
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
|
||||
import { CityPicker } from '../../../../shared/dictionary';
|
||||
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
|
||||
import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route';
|
||||
import { fieldKey } from '../../validation';
|
||||
import { RouteStopCard } from './RouteStopCard';
|
||||
import { RouteLegRow } from './RouteLegRow';
|
||||
import { LegFields } from './LegFields';
|
||||
|
||||
interface Props {
|
||||
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 }) => {
|
||||
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)) });
|
||||
const addStop = (): void =>
|
||||
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
|
||||
const removeStop = (id: string): void => {
|
||||
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся.
|
||||
const legTransport: Record<string, string> = {};
|
||||
Object.keys(route.legTransport)
|
||||
.filter((key) => key.split('>').indexOf(id) === -1)
|
||||
.forEach((key) => {
|
||||
legTransport[key] = route.legTransport[key];
|
||||
// Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
|
||||
const removeStop = (id: string): void =>
|
||||
onChange({
|
||||
...route,
|
||||
stops: route.stops.filter((s) => s.id !== id),
|
||||
legTransport: withoutStopLegs(route.legTransport, id),
|
||||
legDepartureTime: withoutStopLegs(route.legDepartureTime, id)
|
||||
});
|
||||
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
|
||||
};
|
||||
const setTransport = (id: string, value: string): void =>
|
||||
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 && (
|
||||
<TimelineRow marker="line">
|
||||
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
|
||||
</TimelineRow>
|
||||
<LegFields
|
||||
leg={leg}
|
||||
transport={route.legTransport[leg.id] || ''}
|
||||
onTransportChange={(v) => setTransport(leg.id, v)}
|
||||
departureTime={route.legDepartureTime[leg.id] || ''}
|
||||
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -71,29 +77,27 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
||||
<TimelineRow marker="endpoint">
|
||||
<div className={styles.origin}>
|
||||
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
|
||||
<TextInput
|
||||
<CityPicker
|
||||
id={fieldKey.origin}
|
||||
value={route.origin}
|
||||
onChange={(v) => onChange({ ...route, origin: v })}
|
||||
placeholder="Напр.: Алматы"
|
||||
value={{ name: route.origin, nameKz: route.originKz, kato: route.originKato }}
|
||||
onChange={(city) => onChange({ ...route, origin: city.name, originKz: city.nameKz, originKato: city.kato })}
|
||||
placeholder={CITY_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</TimelineRow>
|
||||
|
||||
{route.stops.map((stop, i) => (
|
||||
<React.Fragment key={stop.id}>
|
||||
{legRow(legTo(stop.id))}
|
||||
<TimelineRow marker="stop" label={String(i + 1)}>
|
||||
<TimelineRow key={stop.id} marker="stop" label={String(i + 1)}>
|
||||
<RouteStopCard
|
||||
stop={stop}
|
||||
index={i}
|
||||
context={context}
|
||||
onChange={(patch) => updateStop(stop.id, patch)}
|
||||
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
|
||||
inbound={legFields(legTo(stop.id))}
|
||||
/>
|
||||
</TimelineRow>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{!readOnly && (
|
||||
@@ -105,21 +109,21 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
|
||||
</TimelineRow>
|
||||
)}
|
||||
|
||||
{legRow(returnLeg)}
|
||||
|
||||
<TimelineRow marker="endpoint">
|
||||
<div className={styles.end}>
|
||||
{readOnly ? (
|
||||
<span className={styles.endText}>
|
||||
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
|
||||
{route.returnToOrigin ? `Возвращение: ${originLabel(route) || 'город отправления'}` : 'Без возвращения в город отправления'}
|
||||
</span>
|
||||
) : (
|
||||
<Toggle
|
||||
checked={route.returnToOrigin}
|
||||
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
|
||||
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
|
||||
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
|
||||
/>
|
||||
)}
|
||||
{/* Обратный переезд — здесь: своей карточки точки у него нет. */}
|
||||
{legFields(returnLeg)}
|
||||
</div>
|
||||
</TimelineRow>
|
||||
</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 styles from './RouteScheme.module.scss';
|
||||
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 { plural, tripDays } from '../../tripData';
|
||||
import { ISchemeNode } from './schemeNode';
|
||||
@@ -18,14 +18,14 @@ interface Props {
|
||||
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
|
||||
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
|
||||
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 nodes: ISchemeNode[] = [
|
||||
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
|
||||
];
|
||||
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) {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
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 { 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 { todaySubmitDate } from '../../../../shared/submitDate';
|
||||
import { fieldKey } from '../../validation';
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +18,8 @@ interface Props {
|
||||
onChange: (patch: Partial<IRouteStop>) => void;
|
||||
/** Не передан — точку удалить нельзя (она единственная). */
|
||||
onRemove?: () => void;
|
||||
/** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */
|
||||
inbound?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
|
||||
@@ -22,8 +27,20 @@ export function stopCardDomId(stopId: string): string {
|
||||
return `route-stop-${stopId}`;
|
||||
}
|
||||
|
||||
/** Точка маршрута: город, объект из справочника и даты пребывания. */
|
||||
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
|
||||
type StopLocation = 'domestic' | 'abroad';
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -39,11 +56,23 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<SegmentedSwitch
|
||||
value={stop.abroad ? 'abroad' : 'domestic'}
|
||||
options={LOCATIONS}
|
||||
onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
|
||||
ariaLabel={`Где точка ${index + 1}`}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.grid}>
|
||||
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
|
||||
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
|
||||
</FormField>
|
||||
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
|
||||
<StopPlaceFields stop={stop} onChange={onChange} />
|
||||
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
|
||||
<FormField
|
||||
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
|
||||
htmlFor={fieldKey.stopProject(stop)}
|
||||
required
|
||||
full={stop.abroad}
|
||||
>
|
||||
<ProjectPicker
|
||||
id={fieldKey.stopProject(stop)}
|
||||
context={context}
|
||||
@@ -58,6 +87,8 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
||||
type="date"
|
||||
value={stop.dateFrom}
|
||||
onChange={(v) => onChange({ dateFrom: v })}
|
||||
// Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts.
|
||||
min={todaySubmitDate()}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
|
||||
@@ -66,10 +97,12 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
|
||||
type="date"
|
||||
value={stop.dateTo}
|
||||
onChange={(v) => onChange({ dateTo: v })}
|
||||
min={stop.dateFrom || undefined}
|
||||
min={stop.dateFrom || todaySubmitDate()}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */}
|
||||
{inbound}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RouteViewSwitch.module.scss';
|
||||
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
|
||||
|
||||
export type RouteViewMode = 'form' | 'scheme';
|
||||
|
||||
@@ -8,25 +8,12 @@ interface Props {
|
||||
onChange: (mode: RouteViewMode) => void;
|
||||
}
|
||||
|
||||
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
|
||||
{ mode: 'form', label: 'Форма' },
|
||||
{ mode: 'scheme', label: 'Схема' }
|
||||
const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
|
||||
{ value: 'form', label: 'Форма' },
|
||||
{ value: 'scheme', label: 'Схема' }
|
||||
];
|
||||
|
||||
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
|
||||
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
|
||||
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
|
||||
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
|
||||
{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>
|
||||
<SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
|
||||
const dates = stopDates(stop);
|
||||
return (
|
||||
<span className={styles.text}>
|
||||
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
|
||||
<span className={styles.city}>{node.city || `Город ${NOT_SET}`}</span>
|
||||
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
|
||||
<span className={styles.project} title={stop.project.trim() || undefined}>
|
||||
{stop.project.trim() || 'объект не выбран'}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
|
||||
legs: IRouteLeg[];
|
||||
legTransport: Record<string, string>;
|
||||
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
|
||||
legDepartureTime: Record<string, string>;
|
||||
onSelectStop?: (stopId: string) => void;
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
|
||||
};
|
||||
|
||||
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
|
||||
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
|
||||
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
|
||||
const count = nodes.length;
|
||||
return (
|
||||
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
|
||||
@@ -116,7 +118,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectS
|
||||
</svg>
|
||||
{legs.map((leg, i) => (
|
||||
<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>
|
||||
))}
|
||||
</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}>
|
||||
{nodes.map((node, i) => {
|
||||
const leg = legs[i];
|
||||
@@ -151,7 +153,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectSto
|
||||
<div className={styles.vRow}>
|
||||
<span className={styles.vRail} />
|
||||
<span className={styles.vLeg}>
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
|
||||
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
|
||||
</span>
|
||||
</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 {
|
||||
leg: IRouteLeg;
|
||||
transport: string;
|
||||
/** Время выезда; '' — не указано. */
|
||||
departureTime: string;
|
||||
}
|
||||
|
||||
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
|
||||
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
|
||||
/**
|
||||
* Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
|
||||
* выезда — в подсказке (на линию не помещаются).
|
||||
*/
|
||||
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
|
||||
<span
|
||||
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} />
|
||||
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ISchemeNode {
|
||||
number?: number;
|
||||
/** Сама точка — только у точки маршрута. */
|
||||
stop?: IRouteStop;
|
||||
/** Город отправления — у endpoint. */
|
||||
/** Город на двух языках (см. route.ts/stopCityLabel): отправления — у endpoint, точки — у stop. */
|
||||
city: string;
|
||||
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
|
||||
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 {
|
||||
addItem,
|
||||
articleAmount,
|
||||
@@ -7,22 +16,28 @@ import {
|
||||
estimateTotal,
|
||||
formatEstimateText,
|
||||
itemCalculation,
|
||||
mrpLimit,
|
||||
nightPriceLimit,
|
||||
parseEstimate,
|
||||
projectShares,
|
||||
resolveLinkId,
|
||||
routeItemAutoQuantity,
|
||||
serializeEstimate,
|
||||
stopTotals,
|
||||
syncPerDiem
|
||||
syncRouteItems
|
||||
} from './estimate';
|
||||
|
||||
const ROUTE: ITripRoute = {
|
||||
origin: 'Алматы',
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [
|
||||
{ id: 's1', city: 'Атырау', 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: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: {}
|
||||
legTransport: {},
|
||||
legDepartureTime: {}
|
||||
};
|
||||
|
||||
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
||||
@@ -34,6 +49,7 @@ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
||||
price: '',
|
||||
comment: '',
|
||||
mrpCount: '',
|
||||
quantityManual: '',
|
||||
...patch
|
||||
});
|
||||
|
||||
@@ -121,7 +137,7 @@ describe('суточные по маршруту', () => {
|
||||
};
|
||||
|
||||
it('по позиции на точку; день переезда считается один раз', () => {
|
||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
||||
['s1', '4'],
|
||||
['s2', '2']
|
||||
@@ -129,32 +145,58 @@ describe('суточные по маршруту', () => {
|
||||
});
|
||||
|
||||
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(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
||||
});
|
||||
|
||||
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||
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']);
|
||||
});
|
||||
|
||||
it('без дат дней нет — и суммы нет', () => {
|
||||
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(articleAmount(synced)).toBe(0);
|
||||
});
|
||||
|
||||
it('другие статьи не трогает', () => {
|
||||
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
|
||||
it('статьи с ручными позициями не трогает', () => {
|
||||
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('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
||||
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(
|
||||
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
||||
);
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
EstimateKind,
|
||||
IEstimateArticle,
|
||||
IEstimateItem,
|
||||
IRouteStop,
|
||||
ITripRoute,
|
||||
MAX_MRP_PER_DAY,
|
||||
MAX_NIGHT_PRICE,
|
||||
MRP_VALUE
|
||||
} from './model';
|
||||
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
|
||||
import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route';
|
||||
import { plural } from './tripData';
|
||||
|
||||
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
||||
@@ -54,10 +57,15 @@ function itemMrpValue(item: IEstimateItem): number {
|
||||
}
|
||||
|
||||
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
|
||||
/** Пометка у дней/ночей, введённых вручную, — согласующему важно видеть, что их правили. */
|
||||
function manualMark(item: IEstimateItem): string {
|
||||
return item.quantityManual ? ' (вручную)' : '';
|
||||
}
|
||||
|
||||
function perDiemCalculation(item: IEstimateItem): string {
|
||||
if (!item.quantity || !toNumber(item.mrpCount)) return '';
|
||||
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 {
|
||||
if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item);
|
||||
if (kind === 'perDiem') return perDiemCalculation(item);
|
||||
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: '',
|
||||
price: '',
|
||||
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 {
|
||||
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) => {
|
||||
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);
|
||||
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
||||
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
||||
const days = stopPerDiemDays(route, index);
|
||||
return {
|
||||
...base,
|
||||
mode: 'calc',
|
||||
quantity: days === undefined ? '' : String(days),
|
||||
price: perDiemRate(base.mrpCount)
|
||||
};
|
||||
return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, stop.id) };
|
||||
});
|
||||
return { ...article, items };
|
||||
});
|
||||
@@ -314,7 +349,8 @@ function toItem(value: unknown): IEstimateItem | undefined {
|
||||
quantity: text(raw.quantity),
|
||||
price: text(raw.price),
|
||||
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',
|
||||
'Position',
|
||||
'Organization',
|
||||
'TripCompany',
|
||||
'TripCompanyId',
|
||||
'TripRouteJson',
|
||||
'TripPurpose',
|
||||
'TripTasksJson',
|
||||
'TripEstimateJson',
|
||||
'TripComment',
|
||||
'TripObligationAccepted',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
@@ -47,10 +49,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
||||
Employee: employee?.EMail || '',
|
||||
Position: (item.Position as string) || '',
|
||||
Organization: (item.Organization as string) || '',
|
||||
TripCompany: (item.TripCompany as string) || '',
|
||||
TripCompanyId: (item.TripCompanyId as string) || '',
|
||||
Route: parseRoute(item.TripRouteJson),
|
||||
TripPurpose: (item.TripPurpose as string) || '',
|
||||
Tasks: parseTasks(item.TripTasksJson),
|
||||
Estimate: parseEstimate(item.TripEstimateJson),
|
||||
TripComment: (item.TripComment as string) || '',
|
||||
TripObligationAccepted: item.TripObligationAccepted === true
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import type { RequestTypeId } from '../../models/types';
|
||||
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||
|
||||
/**
|
||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||
@@ -19,10 +20,26 @@ export const FORM_TITLE = 'Заявка на командировку';
|
||||
export interface IRouteStop {
|
||||
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
|
||||
id: string;
|
||||
/**
|
||||
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
|
||||
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
|
||||
* маршруты (Алматы → Москва → Атырау).
|
||||
*/
|
||||
abroad: boolean;
|
||||
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
|
||||
country: string;
|
||||
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
|
||||
city: string;
|
||||
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
|
||||
cityKz: string;
|
||||
/** Код КАТО города; '' — введён вручную. */
|
||||
cityKato: string;
|
||||
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
|
||||
project: string;
|
||||
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
|
||||
/**
|
||||
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
|
||||
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
|
||||
*/
|
||||
projectId: string;
|
||||
/** YYYY-MM-DD — прибытие в точку. */
|
||||
dateFrom: string;
|
||||
@@ -37,18 +54,37 @@ export interface IRouteStop {
|
||||
* участка по его ключу.
|
||||
*/
|
||||
export interface ITripRoute {
|
||||
/** Город отправления (обычно город работы). */
|
||||
/** Город отправления (обычно город работы), на русском. */
|
||||
origin: string;
|
||||
/** Город отправления на казахском; '' — введён вручную. */
|
||||
originKz: string;
|
||||
/** Код КАТО города отправления; '' — введён вручную. */
|
||||
originKato: string;
|
||||
stops: IRouteStop[];
|
||||
/** Обратный путь из последней точки в город отправления. */
|
||||
returnToOrigin: boolean;
|
||||
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
|
||||
legTransport: Record<string, string>;
|
||||
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
|
||||
legDepartureTime: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
|
||||
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
|
||||
|
||||
/** Ключ «точки» города отправления в ключах участков. */
|
||||
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> = [
|
||||
'Авиа',
|
||||
'Железнодорожный',
|
||||
'Автобус',
|
||||
'Такси',
|
||||
'Служебный автотранспорт',
|
||||
'Личный автотранспорт'
|
||||
];
|
||||
@@ -65,7 +101,7 @@ export const TRANSPORT_TYPES: ReadonlyArray<string> = [
|
||||
export const TRANSPORT_SHORT: Record<string, string> = {
|
||||
Авиа: 'Авиа',
|
||||
Железнодорожный: 'Ж/д',
|
||||
Автобус: 'Автобус',
|
||||
Такси: 'Такси',
|
||||
'Служебный автотранспорт': 'Служебное авто',
|
||||
'Личный автотранспорт': 'Личное авто'
|
||||
};
|
||||
@@ -101,9 +137,15 @@ export interface IEstimateItem {
|
||||
/**
|
||||
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
||||
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
||||
* estimate.ts/syncPerDiem. У остальных статей — ''.
|
||||
* estimate.ts/syncRouteItems. У остальных статей — ''.
|
||||
*/
|
||||
mrpCount: string;
|
||||
/**
|
||||
* Только у позиций «по маршруту» (суточные, проживание): дни/ночи, введённые
|
||||
* вручную поверх расчёта по датам — бывает форс-мажор (задержался в точке).
|
||||
* Любое число, без ограничений. '' — считать по датам точки.
|
||||
*/
|
||||
quantityManual: string;
|
||||
}
|
||||
|
||||
/** Статья сметы — заголовок с позициями внутри. */
|
||||
@@ -130,7 +172,7 @@ export interface IEstimateKindInfo {
|
||||
link: EstimateLinkTarget;
|
||||
/**
|
||||
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
||||
* одной на точку) и считаются сами — так устроены суточные.
|
||||
* одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи).
|
||||
*/
|
||||
itemsFromRoute: boolean;
|
||||
quantityLabel: string;
|
||||
@@ -168,7 +210,8 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
||||
core: true,
|
||||
defaultMode: 'calc',
|
||||
link: 'stop',
|
||||
itemsFromRoute: false,
|
||||
// Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров).
|
||||
itemsFromRoute: true,
|
||||
quantityLabel: 'Ночей',
|
||||
priceLabel: 'Цена за ночь',
|
||||
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
||||
@@ -228,6 +271,21 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
|
||||
export const MRP_VALUE = 4325;
|
||||
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'];
|
||||
|
||||
@@ -253,15 +311,23 @@ export interface IFormData {
|
||||
Position: string;
|
||||
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
|
||||
Organization: string;
|
||||
/**
|
||||
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
|
||||
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
|
||||
*/
|
||||
TripCompany: string;
|
||||
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
|
||||
TripCompanyId: string;
|
||||
|
||||
Route: ITripRoute;
|
||||
TripPurpose: string;
|
||||
|
||||
/** Задание на командировку — список задач по порядку. */
|
||||
Tasks: string[];
|
||||
|
||||
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
|
||||
Estimate: IEstimateArticle[];
|
||||
/** Комментарий к заявке — после итога сметы, необязательный. */
|
||||
TripComment: string;
|
||||
|
||||
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
|
||||
TripObligationAccepted: boolean;
|
||||
@@ -270,28 +336,40 @@ export interface IFormData {
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
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 / справочники). */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
/**
|
||||
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
|
||||
* отдельный обработчик. Не передан (режим чтения) — правка через set.
|
||||
* Выбор сотрудника и организации подтягивает должность и организации — поэтому
|
||||
* у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
|
||||
*/
|
||||
onEmployeeChange?: (login: string) => void;
|
||||
employee?: ITripEmployee;
|
||||
}
|
||||
|
||||
export const DEFAULT_PURPOSE = 'Производственная необходимость';
|
||||
|
||||
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
|
||||
export const FIRST_STOP_ID = 'stop-1';
|
||||
|
||||
export const EMPTY_ROUTE: ITripRoute = {
|
||||
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,
|
||||
legTransport: {}
|
||||
legTransport: {},
|
||||
legDepartureTime: {}
|
||||
};
|
||||
|
||||
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
||||
@@ -303,7 +381,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
||||
quantity: '',
|
||||
price: '',
|
||||
comment: '',
|
||||
mrpCount: ''
|
||||
mrpCount: '',
|
||||
quantityManual: ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,11 +406,13 @@ export const EMPTY_DATA: IFormData = {
|
||||
Employee: '',
|
||||
Position: '',
|
||||
Organization: '',
|
||||
TripCompany: '',
|
||||
TripCompanyId: '',
|
||||
Route: EMPTY_ROUTE,
|
||||
TripPurpose: DEFAULT_PURPOSE,
|
||||
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
|
||||
Tasks: [''],
|
||||
Estimate: EMPTY_ESTIMATE,
|
||||
TripComment: '',
|
||||
TripObligationAccepted: false
|
||||
};
|
||||
|
||||
@@ -345,4 +426,7 @@ export const OBLIGATION_TEXT =
|
||||
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
|
||||
|
||||
export const EMPLOYEE_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": "Position", "title": "Должность", "type": "Text" },
|
||||
{ "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": "TripRoute", "title": "Маршрут", "type": "Note" },
|
||||
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
|
||||
@@ -18,7 +21,6 @@
|
||||
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
|
||||
{ "internalName": "TripPurpose", "title": "Цель командировки", "type": "Note" },
|
||||
|
||||
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
|
||||
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
|
||||
@@ -26,6 +28,7 @@
|
||||
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
|
||||
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
|
||||
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
|
||||
{ "internalName": "TripComment", "title": "Комментарий", "type": "Note" },
|
||||
|
||||
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EMPTY_ROUTE, ITripRoute } from './model';
|
||||
import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
|
||||
import {
|
||||
createStop,
|
||||
legLabel,
|
||||
@@ -9,17 +9,23 @@ import {
|
||||
routeProjects,
|
||||
routeText,
|
||||
serializeRoute,
|
||||
stopNights
|
||||
stopLocationPatch,
|
||||
stopNights,
|
||||
tripKind,
|
||||
withoutStopLegs
|
||||
} from './route';
|
||||
|
||||
const ROUTE: ITripRoute = {
|
||||
origin: 'Алматы',
|
||||
originKz: '',
|
||||
originKato: '',
|
||||
stops: [
|
||||
{ id: 's1', city: 'Атырау', 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: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
|
||||
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
|
||||
],
|
||||
returnToOrigin: true,
|
||||
legTransport: { 'origin>s1': 'Авиа' }
|
||||
legTransport: { 'origin>s1': 'Авиа' },
|
||||
legDepartureTime: {}
|
||||
};
|
||||
|
||||
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('новая точка начинается с даты отъезда из предыдущей', () => {
|
||||
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
|
||||
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('хранение маршрута', () => {
|
||||
@@ -59,6 +97,15 @@ describe('хранение маршрута', () => {
|
||||
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 или маршрут без точек — маршрут новой заявки', () => {
|
||||
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
|
||||
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
|
||||
/** Участок пути (переезд) между двумя соседними точками маршрута. */
|
||||
@@ -24,8 +26,36 @@ export function newId(prefix: string): string {
|
||||
const ORIGIN_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 {
|
||||
return stop.city.trim() || STOP_FALLBACK;
|
||||
return stopCityLabel(stop) || STOP_FALLBACK;
|
||||
}
|
||||
|
||||
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
|
||||
@@ -40,7 +70,7 @@ export function stopLabel(stop: IRouteStop): string {
|
||||
* участки пересобрались сами.
|
||||
*/
|
||||
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) }))];
|
||||
if (route.returnToOrigin) points.push(origin);
|
||||
|
||||
@@ -91,7 +121,7 @@ const unique = (values: string[]): string[] =>
|
||||
|
||||
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
|
||||
export function routeCities(route: ITripRoute): string {
|
||||
return unique(route.stops.map((s) => s.city)).join(', ');
|
||||
return unique(route.stops.map(stopCityLabel)).join(', ');
|
||||
}
|
||||
|
||||
/** Объекты точек (без повторов) — для колонки TripProjects. */
|
||||
@@ -106,23 +136,28 @@ function stopDates(stop: IRouteStop): string {
|
||||
|
||||
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
|
||||
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) => {
|
||||
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
|
||||
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(' → ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
|
||||
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
|
||||
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
|
||||
* страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
|
||||
* человек сам уже ввёл; поля остаются редактируемыми.
|
||||
*/
|
||||
export function createStop(previous: IRouteStop | undefined): IRouteStop {
|
||||
return {
|
||||
id: newId('stop'),
|
||||
abroad: previous?.abroad ?? false,
|
||||
country: previous?.abroad ? previous.country : '',
|
||||
city: '',
|
||||
cityKz: '',
|
||||
cityKato: '',
|
||||
project: '',
|
||||
projectId: '',
|
||||
dateFrom: previous?.dateTo || '',
|
||||
@@ -143,7 +178,13 @@ function toStop(value: unknown): IRouteStop | undefined {
|
||||
if (!id) return undefined;
|
||||
return {
|
||||
id,
|
||||
// Заявки до признака «другая страна» — все точки по Казахстану.
|
||||
abroad: raw.abroad === true,
|
||||
country: text(raw.country),
|
||||
city: text(raw.city),
|
||||
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
|
||||
cityKz: text(raw.cityKz),
|
||||
cityKato: text(raw.cityKato),
|
||||
project: text(raw.project),
|
||||
projectId: text(raw.projectId),
|
||||
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 {};
|
||||
const result: Record<string, string> = {};
|
||||
Object.keys(value as object).forEach((key) => {
|
||||
const type = (value as Record<string, unknown>)[key];
|
||||
if (typeof type === 'string') result[key] = type;
|
||||
const entry = (value as Record<string, unknown>)[key];
|
||||
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;
|
||||
}
|
||||
@@ -177,8 +230,12 @@ export function parseRoute(raw: unknown): ITripRoute {
|
||||
if (stops.length === 0) return EMPTY_ROUTE;
|
||||
return {
|
||||
origin: text(rawRoute.origin),
|
||||
originKz: text(rawRoute.originKz),
|
||||
originKato: text(rawRoute.originKato),
|
||||
stops,
|
||||
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 { 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 { 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>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.Employee}
|
||||
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -23,6 +30,20 @@ export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, o
|
||||
<FormField label="Дата подачи" htmlFor="SubmitDate">
|
||||
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
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 { EstimateEditor } from '../components/estimate/EstimateEditor';
|
||||
import { EstimateTable } from '../components/estimate/EstimateTable';
|
||||
@@ -9,7 +9,8 @@ import { EstimateSummary } from '../components/estimate/EstimateSummary';
|
||||
/**
|
||||
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
|
||||
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
|
||||
* Итог с долями и разбивкой по объектам — в обоих режимах.
|
||||
* Итог с долями и разбивкой по объектам — в обоих режимах. После итога —
|
||||
* необязательный комментарий к заявке.
|
||||
*/
|
||||
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
@@ -26,6 +27,12 @@ export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
|
||||
<EstimateEditor data={data} set={set} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ISectionProps, OBLIGATION_LABEL, OBLIGATION_TEXT } from '../model';
|
||||
|
||||
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
|
||||
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<CollapseSection title="Обязательство" icon="document">
|
||||
<CollapseSection title="Обязательство и согласие" icon="document">
|
||||
<p className={styles.text}>{OBLIGATION_TEXT}</p>
|
||||
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
|
||||
<Checkbox
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { RouteEditor } from '../components/route/RouteEditor';
|
||||
import { RouteScheme } from '../components/route/RouteScheme';
|
||||
@@ -9,8 +9,8 @@ import { RouteViewMode, RouteViewSwitch } from '../components/route/RouteViewSwi
|
||||
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} />
|
||||
)}
|
||||
</div>
|
||||
<FormField label="Цель" htmlFor="TripPurpose" required full>
|
||||
<TextArea id="TripPurpose" value={data.TripPurpose} onChange={(v) => set('TripPurpose', v)} rows={2} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
|
||||
import { formatIsoDate } from '../../shared/dateUtils';
|
||||
import { FORM_ID, FORM_TITLE, IFormData } from './model';
|
||||
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';
|
||||
|
||||
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
|
||||
@@ -34,7 +34,10 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
EmployeeId: employeeId,
|
||||
Position: data.Position || null,
|
||||
Organization: data.Organization || null,
|
||||
TripCompany: data.TripCompany || null,
|
||||
TripCompanyId: data.TripCompanyId || null,
|
||||
|
||||
TripKind: tripKind(route),
|
||||
TripRouteJson: serializeRoute(route),
|
||||
TripRoute: routeText(route) || null,
|
||||
TripCity: routeCities(route) || null,
|
||||
@@ -44,7 +47,6 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
TripStartDate: period.start || null,
|
||||
TripEndDate: period.end || null,
|
||||
TripDaysCount: tripDays(period.start, period.end) ?? null,
|
||||
TripPurpose: data.TripPurpose || null,
|
||||
|
||||
// Читаемая строка — для представлений списка; источник для формы — JSON.
|
||||
TripTasks: formatTasks(data.Tasks) || null,
|
||||
@@ -54,6 +56,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
|
||||
TripEstimateJson: serializeEstimate(data.Estimate),
|
||||
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
|
||||
TripEstimateTotal: estimateTotal(data.Estimate),
|
||||
TripComment: data.TripComment.trim() || null,
|
||||
|
||||
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 { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
|
||||
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||
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>>;
|
||||
|
||||
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
|
||||
/** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
|
||||
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
|
||||
getEmployeeOrganizations(context, email)
|
||||
.then((records) => {
|
||||
if (!isAlive()) return;
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
Position: records[0]?.position || prev.Position,
|
||||
Organization: formatEmployeeOrganizations(records)
|
||||
}));
|
||||
setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
|
||||
})
|
||||
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
|
||||
.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(() => {
|
||||
if (editId) return;
|
||||
let alive = true;
|
||||
@@ -40,8 +46,36 @@ export function useTripEmployee(context: WebPartContext, editId: number | undefi
|
||||
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
|
||||
}, [editId, context, setData]);
|
||||
|
||||
return (login: string): void => {
|
||||
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
|
||||
if (login) fillProfile(context, login, setData, () => true);
|
||||
// Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
|
||||
useEffect(() => {
|
||||
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 { createArticle } from './estimate';
|
||||
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||
import type { IFieldIssue } from '../../shared/ui';
|
||||
import { draftIssues, submitIssues } from './validation';
|
||||
|
||||
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
|
||||
|
||||
const ROUTE: ITripRoute = {
|
||||
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,
|
||||
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
|
||||
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' },
|
||||
legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' }
|
||||
};
|
||||
|
||||
const FILLED: IFormData = {
|
||||
@@ -22,33 +27,74 @@ const FILLED: IFormData = {
|
||||
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);
|
||||
|
||||
describe('validation: форма', () => {
|
||||
it('заполненная заявка — без проблем', () => {
|
||||
expect(submitIssues(FILLED)).toEqual([]);
|
||||
expect(submit(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('черновику нужен только сотрудник', () => {
|
||||
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
|
||||
});
|
||||
|
||||
it('пустые задачи и обязательство', () => {
|
||||
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
|
||||
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
|
||||
expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation: маршрут', () => {
|
||||
it('объект только из справочника', () => {
|
||||
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('у каждого участка нужен вид транспорта', () => {
|
||||
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('отъезд раньше прибытия блокирует даже черновик', () => {
|
||||
@@ -57,9 +103,9 @@ describe('validation: маршрут', () => {
|
||||
});
|
||||
|
||||
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
|
||||
const second = { id: 's2', city: 'Актау', 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': 'Авиа' } };
|
||||
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
|
||||
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': 'Авиа' }, legDepartureTime: { 'origin>s1': 'Утро', 's1>s2': 'День', 's2>origin': 'Вечер' } };
|
||||
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,29 +113,46 @@ describe('validation: смета', () => {
|
||||
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
||||
|
||||
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
||||
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
|
||||
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
|
||||
expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
|
||||
const tickets = { ...travel, items: [{ ...travel.items[0], linkId: 'origin>s1', mode: 'calc' as const, quantity: '1' }] };
|
||||
const data = { ...FILLED, Estimate: [off(perDiem), tickets, off(accommodation)] };
|
||||
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('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
|
||||
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
|
||||
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
|
||||
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(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
|
||||
expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
|
||||
expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
|
||||
});
|
||||
|
||||
it('при нескольких вариантах позицию нужно привязать', () => {
|
||||
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
|
||||
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
|
||||
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('другой расход требует название и сумму', () => {
|
||||
const other = createArticle('other', ROUTE);
|
||||
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 { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
|
||||
import { filledTasks } from './tripData';
|
||||
import { legLabel, routeLegs } from './route';
|
||||
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
|
||||
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||
import { todaySubmitDate } from '../../shared/submitDate';
|
||||
import {
|
||||
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:
|
||||
@@ -10,11 +30,13 @@ import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } f
|
||||
*/
|
||||
export const fieldKey = {
|
||||
origin: 'route-origin',
|
||||
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
|
||||
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
|
||||
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
|
||||
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
|
||||
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
|
||||
legTransport: (legId: string): string => `leg-${legId}`,
|
||||
legDepartureTime: (legId: string): string => `leg-${legId}-time`,
|
||||
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
|
||||
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
|
||||
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
|
||||
@@ -25,7 +47,8 @@ export const fieldKey = {
|
||||
|
||||
/** «Точка 2 (Актау)» — как точку видно в форме. */
|
||||
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). */
|
||||
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 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: 'укажите город' });
|
||||
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
|
||||
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
|
||||
// По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
|
||||
// За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
|
||||
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 && stop.dateFrom < today) {
|
||||
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' });
|
||||
}
|
||||
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
|
||||
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
|
||||
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
|
||||
@@ -54,15 +88,39 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
|
||||
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[] = [];
|
||||
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(...tripLengthIssues(route));
|
||||
routeLegs(route).forEach((leg) => {
|
||||
const label = `Переезд ${legLabel(leg)}`;
|
||||
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;
|
||||
@@ -75,6 +133,27 @@ function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
|
||||
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) => {
|
||||
const where = itemLinkLabel(article.kind, item, route);
|
||||
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
||||
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
|
||||
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
|
||||
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
||||
if (!Number(item.mrpCount)) {
|
||||
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
|
||||
}
|
||||
issues.push(...routeItemIssues(article.kind, item, label, route));
|
||||
return;
|
||||
}
|
||||
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
||||
@@ -116,19 +191,30 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
|
||||
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[] = [];
|
||||
if (!data.Employee) issues.push({ field: 'Employee', 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) {
|
||||
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
|
||||
}
|
||||
issues.push(...estimateIssues(data.Estimate, data.Route));
|
||||
if (!data.TripObligationAccepted) {
|
||||
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство', message: 'подтвердите согласие' });
|
||||
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство и согласие', message: 'подтвердите согласие' });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -31,5 +31,7 @@ export {
|
||||
getCompanyDirectorEmail,
|
||||
getManagerEmail
|
||||
} from './employeeLookup.service';
|
||||
export { CityPicker } from './ui/CityPicker';
|
||||
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
|
||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||
export type { IOrganizationLine } from './organizationLines';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Город или посёлок РК из КАТО (см. scripts/Build-KatoCities.ps1). */
|
||||
export interface IKatoCity {
|
||||
/** Код КАТО, 9 цифр. */
|
||||
kato: string;
|
||||
/** Название без префикса «г.»/«п.»: «Атырау», «Кульсары». */
|
||||
name: string;
|
||||
/** Название на казахском без «қ.»/«к.»: «Атырау», «Құлсары». Часто отличается от русского (Уральск — Орал). */
|
||||
nameKz: string;
|
||||
kind: 'city' | 'settlement';
|
||||
/**
|
||||
* Область — различает одноимённые пункты (Актау: город в Мангистауской области
|
||||
* и посёлок в Карагандинской). '' — город республиканского значения.
|
||||
*/
|
||||
region: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Город на двух языках: «Кульсары (Құлсары)». Названия совпадают (Атырау) или
|
||||
* казахского нет (введено вручную) — одно название. Пустая строка — город не указан.
|
||||
*/
|
||||
export function cityLabel(name: string, nameKz: string): string {
|
||||
const ru = name.trim();
|
||||
const kz = nameKz.trim();
|
||||
if (!ru) return kz;
|
||||
return kz && kz !== ru ? `${ru} (${kz})` : ru;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт.
|
||||
// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «КАТО_18.09.2026.xlsx».
|
||||
import { IKatoCity } from './IKatoCity';
|
||||
|
||||
export const KATO_CITIES: ReadonlyArray<IKatoCity> = [
|
||||
{ kato: '353220100', name: 'Абай', nameKz: 'Абай', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '356431100', name: 'Агадырь', nameKz: 'Ақадыр', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '434430100', name: 'Айтеке би', nameKz: 'Әйтеке би', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '356435100', name: 'Акжал', nameKz: 'Ақжал', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '113220100', name: 'Акколь', nameKz: 'Ақкөл', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '273620100', name: 'Аксай', nameKz: 'Ақсай', kind: 'city', region: 'Западно-Казахстанская область' },
|
||||
{ kato: '111833100', name: 'Аксу', nameKz: 'Ақсу', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '551610000', name: 'Аксу', nameKz: 'Ақсу', kind: 'city', region: 'Павлодарская область' },
|
||||
{ kato: '352231100', name: 'Актас', nameKz: 'Ақтас', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '623831100', name: 'Актас', nameKz: 'Ақтас', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '352431100', name: 'Актау', nameKz: 'Ақтау', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '471010000', name: 'Актау', nameKz: 'Ақтау', kind: 'city', region: 'Мангистауская область' },
|
||||
{ kato: '151010000', name: 'Актобе', nameKz: 'Ақтөбе', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '356437100', name: 'Акчатау', nameKz: 'Ақшатау', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '191810000', name: 'Алатау', nameKz: 'Алатау', kind: 'city', region: 'Алматинская область' },
|
||||
{ kato: '153220100', name: 'Алга', nameKz: 'Алға', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '115635100', name: 'Алексеевка', nameKz: 'Алексеевка', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '750000000', name: 'Алматы', nameKz: 'Алматы', kind: 'city', region: '' },
|
||||
{ kato: '634820100', name: 'Алтай', nameKz: 'Алтай', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '433220100', name: 'Аральск', nameKz: 'Арал', kind: 'city', region: 'Кызылординская область' },
|
||||
{ kato: '391610000', name: 'Аркалык', nameKz: 'Арқалық', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '113430100', name: 'Аршалы', nameKz: 'Аршалы', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '611610000', name: 'Арысь', nameKz: 'Арыс', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '710000000', name: 'Астана', nameKz: 'Астана', kind: 'city', region: '' },
|
||||
{ kato: '613843100', name: 'Асыката', nameKz: 'Асықата', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '614459100', name: 'Атакент', nameKz: 'Атакент', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '551045100', name: 'Атамекен', nameKz: 'Атамекен', kind: 'settlement', region: 'Павлодарская область' },
|
||||
{ kato: '113820100', name: 'Атбасар', nameKz: 'Атбасар', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '231010000', name: 'Атырау', nameKz: 'Атырау', kind: 'city', region: 'Атырауская область' },
|
||||
{ kato: '103620100', name: 'Аягоз', nameKz: 'Аягөз', kind: 'city', region: 'область Абай' },
|
||||
{ kato: '431910000', name: 'Байконыр', nameKz: 'Байқоңыр', kind: 'city', region: 'Кызылординская область' },
|
||||
{ kato: '351610000', name: 'Балхаш', nameKz: 'Балқаш', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '431037100', name: 'Белколь', nameKz: 'Белкөл', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '111837100', name: 'Бестобе', nameKz: 'Бестөбе', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '196835100', name: 'Боралдай', nameKz: 'Боралдай', kind: 'settlement', region: 'Алматинская область' },
|
||||
{ kato: '354030100', name: 'Ботакара', nameKz: 'Ботақара', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '593620100', name: 'Булаево', nameKz: 'Булаев', kind: 'city', region: 'Северо-Казахстанская область' },
|
||||
{ kato: '117035100', name: 'Бурабай', nameKz: 'Бурабай', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '354081100', name: 'Габидена Мустафина', nameKz: 'Ғабиден Мұстафин', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '314851400', name: 'Гвардейский', nameKz: 'Гвардейский', kind: 'settlement', region: 'Жамбылская область' },
|
||||
{ kato: '392433100', name: 'Горняцкий', nameKz: 'Горняк', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '315441300', name: 'Гранитогорск', nameKz: 'Гранитогорск', kind: 'settlement', region: 'Жамбылская область' },
|
||||
{ kato: '351635100', name: 'Гульшат', nameKz: 'Гүлшат', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '356447100', name: 'Дарьинский', nameKz: 'Дария', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '115420100', name: 'Державинск', nameKz: 'Державин', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '271033100', name: 'Деркул', nameKz: 'Дерқұл', kind: 'settlement', region: 'Западно-Казахстанская область' },
|
||||
{ kato: '352835100', name: 'Долинка', nameKz: 'Долинка', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '235235100', name: 'Доссор', nameKz: 'Доссор', kind: 'settlement', region: 'Атырауская область' },
|
||||
{ kato: '114620100', name: 'Ерейментау', nameKz: 'Ерейментау', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '194020100', name: 'Есик', nameKz: 'Есік', kind: 'city', region: 'Алматинская область' },
|
||||
{ kato: '114820100', name: 'Есиль', nameKz: 'Есіл', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '621835100', name: 'Жайрем', nameKz: 'Жәйрем', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '433237100', name: 'Жаксыкылыш', nameKz: 'Жақсықылыш', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '433630100', name: 'Жалагаш', nameKz: 'Жалағаш', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '233639100', name: 'Жана Каратон', nameKz: 'Жаңа Қаратон', kind: 'settlement', region: 'Атырауская область' },
|
||||
{ kato: '434030100', name: 'Жанакорган', nameKz: 'Жаңақорған', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '471810000', name: 'Жанаозен', nameKz: 'Жаңаөзен', kind: 'city', region: 'Мангистауская область' },
|
||||
{ kato: '316020100', name: 'Жанатас', nameKz: 'Жаңатас', kind: 'city', region: 'Жамбылская область' },
|
||||
{ kato: '623630100', name: 'Жаңаарқа', nameKz: 'Жаңаарқа', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '334620100', name: 'Жаркент', nameKz: 'Жаркент', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '623843100', name: 'Жезды', nameKz: 'Жезді', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '621010000', name: 'Жезказган', nameKz: 'Жезқазған', kind: 'city', region: 'область Ұлытау' },
|
||||
{ kato: '154825100', name: 'Жем', nameKz: 'Жем', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '613820100', name: 'Жетысай', nameKz: 'Жетісай', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '113432100', name: 'Жибек жолы', nameKz: 'Жібек жолы', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '394420100', name: 'Житикара', nameKz: 'Жітіқара', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '116839100', name: 'Жолымбет', nameKz: 'Жолымбет', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '434630100', name: 'Жосалы', nameKz: 'Жосалы', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '111841100', name: 'Заводской', nameKz: 'Зауыт', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '634620100', name: 'Зайсан', nameKz: 'Зайсан', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '271035100', name: 'Зачаганск', nameKz: 'Зачаганск', kind: 'settlement', region: 'Западно-Казахстанская область' },
|
||||
{ kato: '234030100', name: 'Индерборский', nameKz: 'Индербор', kind: 'settlement', region: 'Атырауская область' },
|
||||
{ kato: '434423100', name: 'Казалинск', nameKz: 'Қазалы', kind: 'city', region: 'Кызылординская область' },
|
||||
{ kato: '154820100', name: 'Кандыагаш', nameKz: 'Қандыағаш', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '395030100', name: 'Карабалык', nameKz: 'Қарабалық', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '353257100', name: 'Карабас', nameKz: 'Қарабас', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '354863100', name: 'Карагайлы', nameKz: 'Қарағайлы', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '351010000', name: 'Караганда', nameKz: 'Қарағанды', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '621810000', name: 'Каражал', nameKz: 'Қаражал', kind: 'city', region: 'область Ұлытау' },
|
||||
{ kato: '316220100', name: 'Каратау', nameKz: 'Қаратау', kind: 'city', region: 'Жамбылская область' },
|
||||
{ kato: '354820100', name: 'Каркаралинск', nameKz: 'Қарқаралы', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '623851100', name: 'Карсакпай', nameKz: 'Қарсақпай', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '195220100', name: 'Каскелен', nameKz: 'Қаскелең', kind: 'city', region: 'Алматинская область' },
|
||||
{ kato: '392435100', name: 'Качар', nameKz: 'Қашар', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '612010000', name: 'Кентау', nameKz: 'Кентау', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '615467100', name: 'Коктерек', nameKz: 'Көктерек', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '111010000', name: 'Кокшетау', nameKz: 'Көкшетау', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '351600200', name: 'Конырат', nameKz: 'Қоңырат', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '391010000', name: 'Костанай', nameKz: 'Қостанай', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '111610000', name: 'Косшы', nameKz: 'Қосшы', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '114859100', name: 'Красногорский', nameKz: 'Красногор', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '271039100', name: 'Круглоозерное', nameKz: 'Круглоозерное', kind: 'settlement', region: 'Западно-Казахстанская область' },
|
||||
{ kato: '233620100', name: 'Кульсары', nameKz: 'Құлсары', kind: 'city', region: 'Атырауская область' },
|
||||
{ kato: '101810000', name: 'Курчатов', nameKz: 'Курчатов', kind: 'city', region: 'область Абай' },
|
||||
{ kato: '393633100', name: 'Кушмурун', nameKz: 'Құсмұрын', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '354061100', name: 'Кушокы', nameKz: 'Қушоқы', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '615645100', name: 'Кыземшек', nameKz: 'Қыземшек', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '623657100', name: 'Кызылжар', nameKz: 'Қызылжар', kind: 'settlement', region: 'область Ұлытау' },
|
||||
{ kato: '431010000', name: 'Кызылорда', nameKz: 'Қызылорда', kind: 'city', region: 'Кызылординская область' },
|
||||
{ kato: '191010000', name: 'Қонаев', nameKz: 'Қонаев', kind: 'city', region: 'Алматинская область' },
|
||||
{ kato: '615820100', name: 'Ленгер', nameKz: 'Леңгір', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '392010000', name: 'Лисаковск', nameKz: 'Лисаков', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '553655100', name: 'Майкаин', nameKz: 'Майқайың', kind: 'settlement', region: 'Павлодарская область' },
|
||||
{ kato: '235230100', name: 'Макат', nameKz: 'Мақат', kind: 'settlement', region: 'Атырауская область' },
|
||||
{ kato: '114020100', name: 'Макинск', nameKz: 'Макинск', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '595220100', name: 'Мамлютка', nameKz: 'Мамлют', kind: 'city', region: 'Северо-Казахстанская область' },
|
||||
{ kato: '356471100', name: 'Мойынты', nameKz: 'Мойынты', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '355657100', name: 'Молодежный', nameKz: 'Молодежный', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '614430100', name: 'Мырзакент', nameKz: 'Мырзакент', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '116835400', name: 'Научный', nameKz: 'Научный', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '352839100', name: 'Новодолинский', nameKz: 'Новодолинский', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '355230100', name: 'Нура', nameKz: 'Нұра', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '392035100', name: 'Октябрьский', nameKz: 'Октябрь', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '355630100', name: 'Осакаровка', nameKz: 'Осакаровка', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '551010000', name: 'Павлодар', nameKz: 'Павлодар', kind: 'city', region: 'Павлодарская область' },
|
||||
{ kato: '591010000', name: 'Петропавловск', nameKz: 'Петропавл', kind: 'city', region: 'Северо-Казахстанская область' },
|
||||
{ kato: '352110000', name: 'Приозерск', nameKz: 'Приозерск', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '632410000', name: 'Риддер', nameKz: 'Риддер', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '392410000', name: 'Рудный', nameKz: 'Рудный', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '356451100', name: 'С.Сейфуллин', nameKz: 'С.Сейфуллин', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '433257100', name: 'Саксаульск', nameKz: 'Сексеуіл', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '352210000', name: 'Сарань', nameKz: 'Саран', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '334820100', name: 'Саркан', nameKz: 'Сарқан', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '615420100', name: 'Сарыагаш', nameKz: 'Сарыағаш', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '396230100', name: 'Сарыколь', nameKz: 'Сарыкөл', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '353667100', name: 'Сарышаган', nameKz: 'Сарышаған', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '622010000', name: 'Сатпаев', nameKz: 'Сәтбаев', kind: 'city', region: 'область Ұлытау' },
|
||||
{ kato: '351645100', name: 'Саяк', nameKz: 'Саяқ', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '101010000', name: 'Семей', nameKz: 'Семей', kind: 'city', region: 'область Абай' },
|
||||
{ kato: '595620100', name: 'Сергеевка', nameKz: 'Сергеев', kind: 'city', region: 'Северо-Казахстанская область' },
|
||||
{ kato: '634821100', name: 'Серебрянск', nameKz: 'Серебрянск', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '552253100', name: 'Солнечный', nameKz: 'Солнечный', kind: 'settlement', region: 'Павлодарская область' },
|
||||
{ kato: '111037100', name: 'Станционный', nameKz: 'Станционный', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '111810000', name: 'Степногорск', nameKz: 'Степногор', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '114520100', name: 'Степняк', nameKz: 'Степняк', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '596020100', name: 'Тайынша', nameKz: 'Тайынша', kind: 'city', region: 'Северо-Казахстанская область' },
|
||||
{ kato: '196220100', name: 'Талгар', nameKz: 'Талғар', kind: 'city', region: 'Алматинская область' },
|
||||
{ kato: '331010000', name: 'Талдыкорган', nameKz: 'Талдықорған', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '311010000', name: 'Тараз', nameKz: 'Тараз', kind: 'city', region: 'Жамбылская область' },
|
||||
{ kato: '431047100', name: 'Тасбугет', nameKz: 'Тасбөгет', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '331810000', name: 'Текели', nameKz: 'Текелі', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '155621100', name: 'Темир', nameKz: 'Темір', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '352410000', name: 'Темиртау', nameKz: 'Теміртау', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '396473100', name: 'Тобол', nameKz: 'Тобыл', kind: 'settlement', region: 'Костанайская область' },
|
||||
{ kato: '395420100', name: 'Тобыл', nameKz: 'Тобыл', kind: 'city', region: 'Костанайская область' },
|
||||
{ kato: '353285100', name: 'Топар', nameKz: 'Топар', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '611010000', name: 'Туркестан', nameKz: 'Түркістан', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '616063100', name: 'Тюлькубас', nameKz: 'Түлкібас', kind: 'settlement', region: 'Туркестанская область' },
|
||||
{ kato: '632400200', name: 'Ульба', nameKz: 'Үлбі', kind: 'settlement', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '271010000', name: 'Уральск', nameKz: 'Орал', kind: 'city', region: 'Западно-Казахстанская область' },
|
||||
{ kato: '631010000', name: 'Усть-Каменогорск', nameKz: 'Өскемен', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '333420100', name: 'Ушарал', nameKz: 'Үшарал', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '334420100', name: 'Уштобе', nameKz: 'Үштөбе', kind: 'city', region: 'область Жетісу' },
|
||||
{ kato: '475220100', name: 'Форт-Шевченко', nameKz: 'Форт-Шевченко', kind: 'city', region: 'Мангистауская область' },
|
||||
{ kato: '156020100', name: 'Хромтау', nameKz: 'Хромтау', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '104163100', name: 'Чаган', nameKz: 'Шаған', kind: 'settlement', region: 'область Абай' },
|
||||
{ kato: '156420100', name: 'Шалкар', nameKz: 'Шалқар', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '434065100', name: 'Шалкия', nameKz: 'Шалқия', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '111845100', name: 'Шантобе', nameKz: 'Шаңтөбе', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '104221100', name: 'Шар', nameKz: 'Шар', kind: 'city', region: 'область Абай' },
|
||||
{ kato: '616420100', name: 'Шардара', nameKz: 'Шардара', kind: 'city', region: 'Туркестанская область' },
|
||||
{ kato: '352845100', name: 'Шахан', nameKz: 'Шақан', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '352810000', name: 'Шахтинск', nameKz: 'Шахтинск', kind: 'city', region: 'Карагандинская область' },
|
||||
{ kato: '353679100', name: 'Шашубай', nameKz: 'Шәшубай', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '636820100', name: 'Шемонаиха', nameKz: 'Шемонайха', kind: 'city', region: 'Восточно-Казахстанская область' },
|
||||
{ kato: '552257100', name: 'Шидерты', nameKz: 'Шідерті', kind: 'settlement', region: 'Павлодарская область' },
|
||||
{ kato: '435230100', name: 'Шиели', nameKz: 'Шиелі', kind: 'settlement', region: 'Кызылординская область' },
|
||||
{ kato: '116830100', name: 'Шортанды', nameKz: 'Шортанды', kind: 'settlement', region: 'Акмолинская область' },
|
||||
{ kato: '316621100', name: 'Шу', nameKz: 'Шу', kind: 'city', region: 'Жамбылская область' },
|
||||
{ kato: '355289100', name: 'Шубарколь', nameKz: 'Шұбаркөл', kind: 'settlement', region: 'Карагандинская область' },
|
||||
{ kato: '155630100', name: 'Шубаркудук', nameKz: 'Шұбарқұдық', kind: 'settlement', region: 'Актюбинская область' },
|
||||
{ kato: '155642100', name: 'Шубарши', nameKz: 'Шұбарши', kind: 'settlement', region: 'Актюбинская область' },
|
||||
{ kato: '104165100', name: 'Шульбинск', nameKz: 'Шүлбі', kind: 'settlement', region: 'область Абай' },
|
||||
{ kato: '790000000', name: 'Шымкент', nameKz: 'Шымкент', kind: 'city', region: '' },
|
||||
{ kato: '117020100', name: 'Щучинск', nameKz: 'Щучинск', kind: 'city', region: 'Акмолинская область' },
|
||||
{ kato: '552210000', name: 'Экибастуз', nameKz: 'Екібастұз', kind: 'city', region: 'Павлодарская область' },
|
||||
{ kato: '154823100', name: 'Эмба', nameKz: 'Ембі', kind: 'city', region: 'Актюбинская область' },
|
||||
{ kato: '353289100', name: 'Южный', nameKz: 'Южный', kind: 'settlement', region: 'Карагандинская область' },
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
import { IKatoCity } from './IKatoCity';
|
||||
import { findExactCity, normalizeCityText, searchCities } from './searchCities';
|
||||
|
||||
const city = (name: string, nameKz: string, kind: IKatoCity['kind'] = 'city', region = ''): IKatoCity => ({
|
||||
kato: name,
|
||||
name,
|
||||
nameKz,
|
||||
kind,
|
||||
region
|
||||
});
|
||||
|
||||
const CITIES: IKatoCity[] = [
|
||||
city('Актау', 'Ақтау', 'settlement', 'Карагандинская область'),
|
||||
city('Актау', 'Ақтау', 'city', 'Мангистауская область'),
|
||||
city('Актобе', 'Ақтөбе'),
|
||||
city('Кульсары', 'Құлсары'),
|
||||
city('Уральск', 'Орал'),
|
||||
city('Жана Каратон', 'Жаңа Қаратон', 'settlement')
|
||||
];
|
||||
|
||||
const names = (list: IKatoCity[]): string[] => list.map((c) => `${c.name}/${c.kind}`);
|
||||
|
||||
describe('searchCities', () => {
|
||||
it('казахские буквы и мягкий знак не мешают', () => {
|
||||
expect(normalizeCityText('Құлсары')).toBe(normalizeCityText('Кульсары'));
|
||||
expect(names(searchCities(CITIES, 'Кулс', 10))).toEqual(['Кульсары/city']);
|
||||
expect(names(searchCities(CITIES, 'Құл', 10))).toEqual(['Кульсары/city']);
|
||||
});
|
||||
|
||||
it('по казахскому названию, даже если русское другое', () => {
|
||||
expect(names(searchCities(CITIES, 'Орал', 10))).toEqual(['Уральск/city']);
|
||||
});
|
||||
|
||||
it('город раньше одноимённого посёлка, начало названия раньше середины', () => {
|
||||
expect(names(searchCities(CITIES, 'акт', 10))).toEqual(['Актау/city', 'Актобе/city', 'Актау/settlement']);
|
||||
});
|
||||
|
||||
it('по началу второго слова', () => {
|
||||
expect(names(searchCities(CITIES, 'карат', 10))).toEqual(['Жана Каратон/settlement']);
|
||||
});
|
||||
|
||||
it('название введено целиком — находится, только если однозначно', () => {
|
||||
expect(findExactCity(CITIES, ' кульсары ')?.name).toBe('Кульсары');
|
||||
expect(findExactCity(CITIES, 'Орал')?.name).toBe('Уральск');
|
||||
expect(findExactCity(CITIES, 'Актау')).toBeUndefined();
|
||||
expect(findExactCity(CITIES, 'Кульс')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('пустой запрос — пусто, лимит соблюдается', () => {
|
||||
expect(searchCities(CITIES, ' ', 10)).toEqual([]);
|
||||
expect(searchCities(CITIES, 'а', 2)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IKatoCity } from './IKatoCity';
|
||||
|
||||
// Казахские буквы → ближайшие русские: «Кулсары» на русской раскладке находит «Құлсары».
|
||||
const KZ_TO_RU: Record<string, string> = {
|
||||
ә: 'а',
|
||||
ғ: 'г',
|
||||
қ: 'к',
|
||||
ң: 'н',
|
||||
ө: 'о',
|
||||
ұ: 'у',
|
||||
ү: 'у',
|
||||
һ: 'х',
|
||||
і: 'и',
|
||||
ё: 'е'
|
||||
};
|
||||
|
||||
/**
|
||||
* Строка для сравнения: нижний регистр, казахские буквы приведены к русским, без
|
||||
* мягкого/твёрдого знака — «Кульсары», «Кулсары» и «Құлсары» дают одно и то же.
|
||||
*/
|
||||
export function normalizeCityText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[әғқңөұүһіё]/g, (ch) => KZ_TO_RU[ch])
|
||||
.replace(/[ьъ]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Насколько хорошо название подходит под запрос; undefined — не подходит. Меньше — лучше. */
|
||||
function matchRank(name: string, query: string): number | undefined {
|
||||
const normalized = normalizeCityText(name);
|
||||
if (normalized.startsWith(query)) return 0;
|
||||
// Начало второго слова: «Каратон» находит «Жана Каратон».
|
||||
if (normalized.split(/[\s-]+/).some((word) => word.startsWith(query))) return 1;
|
||||
if (normalized.includes(query)) return 2;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Города и посёлки под запрос — по русскому и казахскому названию. Порядок: совпало
|
||||
* начало названия → начало слова → середина; при равенстве города раньше посёлков.
|
||||
*/
|
||||
export function searchCities(cities: ReadonlyArray<IKatoCity>, query: string, limit: number): IKatoCity[] {
|
||||
const q = normalizeCityText(query);
|
||||
if (!q) return [];
|
||||
const found: Array<{ city: IKatoCity; rank: number }> = [];
|
||||
cities.forEach((city) => {
|
||||
const ranks = [matchRank(city.name, q), matchRank(city.nameKz, q)].filter((r): r is number => r !== undefined);
|
||||
if (ranks.length > 0) found.push({ city, rank: Math.min(...ranks) });
|
||||
});
|
||||
return found
|
||||
.sort((a, b) => a.rank - b.rank || kindOrder(a.city) - kindOrder(b.city) || a.city.name.localeCompare(b.city.name, 'ru'))
|
||||
.slice(0, limit)
|
||||
.map((f) => f.city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пункт, название которого введено целиком (на любом языке). Неоднозначно
|
||||
* (два Актау) или не найдено — undefined: выбирать за человека нельзя.
|
||||
*/
|
||||
export function findExactCity(cities: ReadonlyArray<IKatoCity>, text: string): IKatoCity | undefined {
|
||||
const q = normalizeCityText(text);
|
||||
if (!q) return undefined;
|
||||
const matches = cities.filter((c) => normalizeCityText(c.name) === q || normalizeCityText(c.nameKz) === q);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function kindOrder(city: IKatoCity): number {
|
||||
return city.kind === 'city' ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
// справа место под крестик-очистку
|
||||
padding: 11px 36px 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
@include readonly-field(':read-only');
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-400;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
&:hover {
|
||||
background: $gray-100;
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Ничего не нашлось — подсказка, что введённый текст сохранится как есть.
|
||||
.hint {
|
||||
padding: 9px 12px;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import styles from './CityPicker.module.scss';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { IKatoCity } from '../kato/IKatoCity';
|
||||
import { KATO_CITIES } from '../kato/katoCities';
|
||||
import { cityLabel } from '../kato/cityLabel';
|
||||
import { findExactCity, searchCities } from '../kato/searchCities';
|
||||
|
||||
/** Значение поля: город из справочника КАТО (оба названия + код) или введённый вручную текст. */
|
||||
export interface ICityValue {
|
||||
/** На русском — или текст, введённый вручную. */
|
||||
name: string;
|
||||
/** На казахском; '' — введено вручную. */
|
||||
nameKz: string;
|
||||
/** Код КАТО; '' — введено вручную. */
|
||||
kato: string;
|
||||
}
|
||||
|
||||
export interface ICityPickerProps {
|
||||
id?: string;
|
||||
value: ICityValue;
|
||||
onChange: (value: ICityValue) => void;
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 20;
|
||||
const EMPTY_CITY: ICityValue = { name: '', nameKz: '', kato: '' };
|
||||
|
||||
/** «Атырауская область · город»; у Астаны/Алматы/Шымкента области нет. */
|
||||
function cityMeta(city: IKatoCity): string {
|
||||
const kind = city.kind === 'city' ? 'город' : 'посёлок';
|
||||
return city.region ? `${city.region} · ${kind}` : 'город республиканского значения';
|
||||
}
|
||||
|
||||
/**
|
||||
* Город / населённый пункт РК со справочником КАТО (shared/dictionary/kato).
|
||||
* Ищет по русскому и казахскому названию; выбор сохраняет оба названия и код.
|
||||
* Нет в справочнике (село, заграница) — остаётся текст, введённый вручную.
|
||||
*/
|
||||
export const CityPicker: React.FC<ICityPickerProps> = ({ id, value, onChange, placeholder, readOnly }) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
|
||||
const isPicked = value.kato !== '';
|
||||
// Выбранный город показываем на двух языках; пока человек печатает — ровно его текст.
|
||||
const shownText = isPicked ? cityLabel(value.name, value.nameKz) : value.name;
|
||||
const suggestions = isPicked ? [] : searchCities(KATO_CITIES, value.name, MAX_SUGGESTIONS);
|
||||
|
||||
const handlePick = (city: IKatoCity): void => {
|
||||
onChange({ name: city.name, nameKz: city.nameKz, kato: city.kato });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<input
|
||||
id={id}
|
||||
className={styles.input}
|
||||
value={shownText}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
// Любая правка — уже ручной ввод: казахское название и код сбрасываются.
|
||||
// setOpen(true) и при вводе: после выбора список закрыт, а фокус остаётся на поле.
|
||||
onChange={(e) => {
|
||||
onChange({ name: e.target.value, nameKz: '', kato: '' });
|
||||
setOpen(true);
|
||||
}}
|
||||
// Выбранный город выделяем целиком — новый ввод заменяет его, а не дописывается к «(Құлсары)».
|
||||
onFocus={(e) => {
|
||||
if (isPicked && !effectiveReadOnly) e.target.select();
|
||||
setOpen(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOpen(false);
|
||||
// Название набрано целиком, но не выбрано из списка — подставляем город из справочника.
|
||||
const exact = isPicked ? undefined : findExactCity(KATO_CITIES, value.name);
|
||||
if (exact) handlePick(exact);
|
||||
}}
|
||||
/>
|
||||
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в списке ниже). */}
|
||||
{!effectiveReadOnly && shownText.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label="Очистить"
|
||||
title="Очистить"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange(EMPTY_CITY);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{!effectiveReadOnly && open && value.name.trim() !== '' && !isPicked && (
|
||||
<div className={styles.list}>
|
||||
{suggestions.map((city) => (
|
||||
<button
|
||||
key={city.kato}
|
||||
type="button"
|
||||
className={styles.item}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handlePick(city)}
|
||||
>
|
||||
{cityLabel(city.name, city.nameKz)}
|
||||
<span className={styles.meta}>{cityMeta(city)}</span>
|
||||
</button>
|
||||
))}
|
||||
{suggestions.length === 0 && (
|
||||
<div className={styles.hint}>Нет в справочнике — останется как введено</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user