diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 4215e29..8e45dc9 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -43,7 +43,7 @@ import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar' import type { Vps } from '@/types/entities' import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format' import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager' -import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo' +import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo' export const Route = createFileRoute('/_auth/vps')({ loader: ({ context: { queryClient } }) => @@ -168,23 +168,10 @@ function VpsPage() { }) }, [snapshot?.vps]) - const cityOptions = useMemo(() => { - const names = new Set() - // Из существующих VPS - for (const v of snapshot?.vps ?? []) { - const c = (v.city || '').trim() - if (c) names.add(c) - } - // Из стандартизированного справочника (фильтр по первой выбранной стране, если есть) - const countryCode = filters.country[0] - ? COUNTRY_BY_NAME_RU[filters.country[0].toLowerCase()]?.code - : undefined - for (const city of listCities(countryCode)) names.add(city.name) - return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => ({ - value: name, - label: name, - })) - }, [snapshot?.vps, filters.country]) + const cityOptions = useMemo( + () => buildCityOptions(snapshot?.vps, filters.country[0]), + [snapshot?.vps, filters.country], + ) const tableSections = useMemo(() => { if (!filters.groupByProject) { @@ -390,6 +377,12 @@ function VpsPage() { {(form) => { const { register, formState: { errors }, watch, setValue } = form const providerId = watch('providerId') + const formCountry = watch('country') ?? '' + const formCity = watch('city') ?? '' + const formCityOptions = buildCityOptions( + snapshot?.vps, + formCountry.trim() || undefined, + ) return ( <> @@ -425,8 +418,13 @@ function VpsPage() { setValue('country', v)} + value={formCountry} + onChange={(v) => { + setValue('country', v) + if (v.trim() && formCity.trim() && !cityMatchesCountry(formCity, v, snapshot?.vps)) { + setValue('city', '') + } + }} options={countryOptions} searchPlaceholder="Поиск страны…" emptyText="Нет вариантов" @@ -436,9 +434,13 @@ function VpsPage() { setValue('city', v)} - options={cityOptions} + value={formCity} + onChange={(v) => { + setValue('city', v) + const country = resolveCountryForCityFromRows(v, snapshot?.vps) + if (country) setValue('country', country) + }} + options={formCityOptions} searchPlaceholder="Поиск города…" emptyText="Нет вариантов" showLeadingInInput={false} diff --git a/packages/shared/src/geo/cities.ts b/packages/shared/src/geo/cities.ts index 9441110..c8d570f 100644 --- a/packages/shared/src/geo/cities.ts +++ b/packages/shared/src/geo/cities.ts @@ -4,7 +4,7 @@ * Не зависит от данных в БД vps-tracker — общедоступный статичный справочник. */ -import { COUNTRY_BY_CODE, COUNTRIES, type CountryRef } from './countries.js' +import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, COUNTRIES, type CountryRef } from './countries.js' export interface CityRef { /** ISO 3166-1 alpha-2 код страны */ @@ -178,6 +178,99 @@ export const CITIES: readonly CityRef[] = [ { countryCode: 'IL', name: 'Тель-Авив', nameEn: 'Tel Aviv' }, ] +export const CITY_BY_NAME_RU: Record = Object.fromEntries( + CITIES.map((c) => [c.name.toLowerCase(), c]), +) + +/** Найти город в справочнике по русскому названию. */ +export function resolveCityRef(cityName: string): CityRef | undefined { + return CITY_BY_NAME_RU[cityName.trim().toLowerCase()] +} + +/** Страна из справочника для города (если есть привязка). */ +export function resolveCountryForCity(cityName: string): CountryRef | undefined { + const city = resolveCityRef(cityName) + return city ? COUNTRY_BY_CODE[city.countryCode] : undefined +} + +export interface CityLocationRow { + city?: string | null + country?: string | null +} + +/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */ +export function cityMatchesCountry( + cityName: string, + countryName: string, + rows?: readonly CityLocationRow[], +): boolean { + const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code + if (!countryCode) return true + + const catalog = resolveCityRef(cityName) + if (catalog) return catalog.countryCode === countryCode + + return (rows ?? []).some( + (row) => + row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() && + COUNTRY_BY_NAME_RU[(row.country || '').trim().toLowerCase()]?.code === countryCode, + ) +} + +/** Опции городов: из VPS и справочника, опционально по стране. */ +export function buildCityOptions( + rows: readonly CityLocationRow[] | undefined, + countryName?: string, +): { value: string; label: string }[] { + const names = new Set() + const countryCode = countryName?.trim() + ? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code + : undefined + + const belongsToCountry = (city: string, rowCountry?: string): boolean => { + if (!countryCode) return true + const catalog = resolveCityRef(city) + if (catalog) return catalog.countryCode === countryCode + if (rowCountry?.trim()) { + return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode + } + return false + } + + for (const row of rows ?? []) { + const city = (row.city || '').trim() + if (!city) continue + if (belongsToCountry(city, row.country ?? undefined)) { + names.add(city) + } + } + + for (const { name } of listCities(countryCode)) { + names.add(name) + } + + return [...names] + .sort((a, b) => a.localeCompare(b, 'ru')) + .map((name) => ({ value: name, label: name })) +} + +/** Страна для города: справочник, иначе первая запись VPS с таким городом. */ +export function resolveCountryForCityFromRows( + cityName: string, + rows: readonly CityLocationRow[] | undefined, +): string | undefined { + const trimmed = cityName.trim() + if (!trimmed) return undefined + + const fromCatalog = resolveCountryForCity(trimmed) + if (fromCatalog) return fromCatalog.name + + const row = (rows ?? []).find( + (r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim(), + ) + return row?.country?.trim() +} + export interface CityOption extends CityRef { country: CountryRef }