feat: справочник городов КАТО и поле выбора города
Города и посёлки РК (178) из официального КАТО stat.gov.kz — на русском и казахском, с кодом и областью. Справочник генерируется скриптом scripts/Build-KatoCities.ps1 и лежит на фронте: внешних API и ключей не нужно. CityPicker ищет по обоим языкам с учётом раскладки (Құлсары = Кулсары) и оставляет свой вариант для сёл и заграницы. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -31,5 +31,7 @@ export {
|
|||||||
getCompanyDirectorEmail,
|
getCompanyDirectorEmail,
|
||||||
getManagerEmail
|
getManagerEmail
|
||||||
} from './employeeLookup.service';
|
} from './employeeLookup.service';
|
||||||
|
export { CityPicker } from './ui/CityPicker';
|
||||||
|
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
|
||||||
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
|
||||||
export type { IOrganizationLine } 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