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:
Тимур Городецкий
2026-10-23 14:21:26 +05:00
parent 513f6c1d30
commit c892fb26a9
9 changed files with 650 additions and 0 deletions
@@ -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>
);
};