diff --git a/scripts/Build-KatoCities.ps1 b/scripts/Build-KatoCities.ps1 new file mode 100644 index 0000000..7495510 --- /dev/null +++ b/scripts/Build-KatoCities.ps1 @@ -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 = [ +$($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 +} diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index 08e96fe..9c322b0 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -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'; diff --git a/src/webparts/requestPortal/shared/dictionary/kato/IKatoCity.ts b/src/webparts/requestPortal/shared/dictionary/kato/IKatoCity.ts new file mode 100644 index 0000000..7dede8c --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/kato/IKatoCity.ts @@ -0,0 +1,15 @@ +/** Город или посёлок РК из КАТО (см. scripts/Build-KatoCities.ps1). */ +export interface IKatoCity { + /** Код КАТО, 9 цифр. */ + kato: string; + /** Название без префикса «г.»/«п.»: «Атырау», «Кульсары». */ + name: string; + /** Название на казахском без «қ.»/«к.»: «Атырау», «Құлсары». Часто отличается от русского (Уральск — Орал). */ + nameKz: string; + kind: 'city' | 'settlement'; + /** + * Область — различает одноимённые пункты (Актау: город в Мангистауской области + * и посёлок в Карагандинской). '' — город республиканского значения. + */ + region: string; +} diff --git a/src/webparts/requestPortal/shared/dictionary/kato/cityLabel.ts b/src/webparts/requestPortal/shared/dictionary/kato/cityLabel.ts new file mode 100644 index 0000000..3a64710 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/kato/cityLabel.ts @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts b/src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts new file mode 100644 index 0000000..df88132 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts @@ -0,0 +1,184 @@ +// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт. +// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «КАТО_18.09.2026.xlsx». +import { IKatoCity } from './IKatoCity'; + +export const KATO_CITIES: ReadonlyArray = [ + { 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: 'Карагандинская область' }, +]; diff --git a/src/webparts/requestPortal/shared/dictionary/kato/searchCities.test.ts b/src/webparts/requestPortal/shared/dictionary/kato/searchCities.test.ts new file mode 100644 index 0000000..f32f698 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/kato/searchCities.test.ts @@ -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); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/kato/searchCities.ts b/src/webparts/requestPortal/shared/dictionary/kato/searchCities.ts new file mode 100644 index 0000000..31d8670 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/kato/searchCities.ts @@ -0,0 +1,70 @@ +import { IKatoCity } from './IKatoCity'; + +// Казахские буквы → ближайшие русские: «Кулсары» на русской раскладке находит «Құлсары». +const KZ_TO_RU: Record = { + ә: 'а', + ғ: 'г', + қ: 'к', + ң: 'н', + ө: 'о', + ұ: 'у', + ү: 'у', + һ: 'х', + і: 'и', + ё: 'е' +}; + +/** + * Строка для сравнения: нижний регистр, казахские буквы приведены к русским, без + * мягкого/твёрдого знака — «Кульсары», «Кулсары» и «Құлсары» дают одно и то же. + */ +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, 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, 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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.module.scss b/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.module.scss new file mode 100644 index 0000000..2a1dc2f --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.tsx b/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.tsx new file mode 100644 index 0000000..98c1777 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/ui/CityPicker.tsx @@ -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 = ({ id, value, onChange, placeholder, readOnly }) => { + const contextReadOnly = useFormReadOnly(); + const effectiveReadOnly = readOnly ?? contextReadOnly; + const [open, setOpen] = React.useState(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 ( +
+ { + 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 && ( + + )} + {!effectiveReadOnly && open && value.name.trim() !== '' && !isPicked && ( +
+ {suggestions.map((city) => ( + + ))} + {suggestions.length === 0 && ( +
Нет в справочнике — останется как введено
+ )} +
+ )} +
+ ); +};