-
{flag}
+
+ {flag}
{secondary ? dataGridCellStack(primary, secondary) : {primary}}
)
diff --git a/apps/web/src/components/form-date-picker.tsx b/apps/web/src/components/form-date-picker.tsx
new file mode 100644
index 0000000..6f5b857
--- /dev/null
+++ b/apps/web/src/components/form-date-picker.tsx
@@ -0,0 +1,86 @@
+import { useState } from 'react'
+import { format, parseISO, isValid } from 'date-fns'
+import { ru } from 'date-fns/locale'
+import { CalendarIcon, XIcon } from 'lucide-react'
+
+import { Button } from '@cfdm/ui/components/button'
+import { Calendar } from '@cfdm/ui/components/calendar'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@cfdm/ui/components/popover'
+import { cn } from '@cfdm/ui/lib/utils'
+
+interface FormDatePickerProps {
+ id?: string
+ value?: string
+ onChange: (value: string) => void
+ placeholder?: string
+ className?: string
+}
+
+export function FormDatePicker({
+ id,
+ value = '',
+ onChange,
+ placeholder = 'Выберите дату',
+ className,
+}: FormDatePickerProps) {
+ const [open, setOpen] = useState(false)
+ const parsed = value ? parseISO(value) : undefined
+ const selected = parsed && isValid(parsed) ? parsed : undefined
+ const label = selected
+ ? format(selected, 'd MMMM yyyy', { locale: ru })
+ : placeholder
+
+ return (
+
+
+
+
+ {label}
+
+ }
+ />
+
+ {
+ if (date) {
+ onChange(format(date, 'yyyy-MM-dd'))
+ setOpen(false)
+ }
+ }}
+ defaultMonth={selected}
+ locale={ru}
+ weekStartsOn={1}
+ />
+
+
+ {selected ? (
+
+ ) : null}
+
+ )
+}
diff --git a/apps/web/src/components/vps-filters-toolbar.tsx b/apps/web/src/components/vps-filters-toolbar.tsx
index 46b9bbb..a7c0f5f 100644
--- a/apps/web/src/components/vps-filters-toolbar.tsx
+++ b/apps/web/src/components/vps-filters-toolbar.tsx
@@ -30,7 +30,8 @@ import {
saveFilterPresets,
type VpsFilterPreset,
} from '@/components/vps-filters'
-import { vpsStatusLabel, tariffTypeLabel, environmentLabel, getCountryFlagEmojiByCode } from '@/lib/format'
+import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format'
+import { CountryFlag } from '@/components/country-flag'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
interface VpsFiltersToolbarProps {
@@ -187,7 +188,7 @@ export function VpsFiltersToolbar({
const countryOpts: FilterOption
[] = countryOptions.map((c) => ({
value: c.value,
label: c.label,
- icon: c.code ? {getCountryFlagEmojiByCode(c.code)} : undefined,
+ icon: c.code ? : ,
metadata: { count: count((v) => (v.country ?? '').trim() === c.value) },
}))
diff --git a/apps/web/src/lib/date-selector-i18n.ts b/apps/web/src/lib/date-selector-i18n.ts
new file mode 100644
index 0000000..53614ca
--- /dev/null
+++ b/apps/web/src/lib/date-selector-i18n.ts
@@ -0,0 +1,65 @@
+import type { DateSelectorI18nConfig } from '@/components/reui/date-selector'
+
+/** Русская локализация ReUI DateSelector (фильтры, не формы). */
+export const RU_DATE_SELECTOR_I18N: DateSelectorI18nConfig = {
+ selectDate: 'Выберите дату',
+ apply: 'Применить',
+ cancel: 'Отмена',
+ clear: 'Очистить',
+ today: 'Сегодня',
+ filterTypes: {
+ is: 'равно',
+ before: 'до',
+ after: 'после',
+ between: 'между',
+ },
+ periodTypes: {
+ day: 'День',
+ month: 'Месяц',
+ quarter: 'Квартал',
+ halfYear: 'Полгода',
+ year: 'Год',
+ },
+ months: [
+ 'Январь',
+ 'Февраль',
+ 'Март',
+ 'Апрель',
+ 'Май',
+ 'Июнь',
+ 'Июль',
+ 'Август',
+ 'Сентябрь',
+ 'Октябрь',
+ 'Ноябрь',
+ 'Декабрь',
+ ],
+ monthsShort: [
+ 'Янв',
+ 'Фев',
+ 'Мар',
+ 'Апр',
+ 'Май',
+ 'Июн',
+ 'Июл',
+ 'Авг',
+ 'Сен',
+ 'Окт',
+ 'Ноя',
+ 'Дек',
+ ],
+ quarters: ['1 кв.', '2 кв.', '3 кв.', '4 кв.'],
+ halfYears: ['1 пол.', '2 пол.'],
+ weekdays: [
+ 'воскресенье',
+ 'понедельник',
+ 'вторник',
+ 'среда',
+ 'четверг',
+ 'пятница',
+ 'суббота',
+ ],
+ weekdaysShort: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
+ placeholder: 'Выберите дату…',
+ rangePlaceholder: 'Выберите период…',
+}
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index 581e338..5ebd8da 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -1,4 +1,9 @@
import type { Settings, RatesData, Vps, Provider } from '@/types/entities'
+import { COUNTRIES, COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
+
+const COUNTRY_BY_NAME_EN: Record = Object.fromEntries(
+ COUNTRIES.map((c) => [c.nameEn.toLowerCase(), c]),
+)
export function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
@@ -30,11 +35,32 @@ const COUNTRY_CODE_BY_NAME: Record = {
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ',
}
+/** ISO 3166-1 alpha-2 по русскому/английскому названию или коду. */
+export function resolveCountryCode(country?: string): string | undefined {
+ if (!country) return undefined
+ const normalized = country.trim()
+ const lower = normalized.toLowerCase()
+ if (/^[a-z]{2}$/i.test(normalized)) {
+ const upper = normalized.toUpperCase()
+ if (COUNTRY_BY_CODE[upper]) return upper
+ }
+ return (
+ COUNTRY_BY_NAME_RU[lower]?.code
+ ?? COUNTRY_BY_NAME_EN[lower]?.code
+ ?? COUNTRY_CODE_BY_NAME[lower]
+ )
+}
+
+/** URL SVG-флага (flagcdn). */
+export function getCountryFlagUrl(code?: string): string | undefined {
+ if (!code || code.length !== 2) return undefined
+ return `https://flagcdn.com/${code.toLowerCase()}.svg`
+}
+
export function getCountryFlagEmoji(country?: string): string {
- if (!country) return '🌐'
- const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
+ const code = resolveCountryCode(country)
if (!code) return '🌐'
- return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
+ return getCountryFlagEmojiByCode(code)
}
/** Флаг по ISO 3166-1 alpha-2 коду страны. */
diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx
index c9e00c5..9dc1ebc 100644
--- a/apps/web/src/routes/_auth/vps.tsx
+++ b/apps/web/src/routes/_auth/vps.tsx
@@ -4,8 +4,6 @@ import { useState, useMemo } from 'react'
import { Controller } from 'react-hook-form'
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
import { toast } from 'sonner'
-import { format, parseISO, isValid } from 'date-fns'
-
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
@@ -17,6 +15,7 @@ import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
+import { CountryFlag } from '@/components/country-flag'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -33,10 +32,7 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
-import {
- DateSelector,
- type DateSelectorValue,
-} from '@/components/reui/date-selector'
+import { FormDatePicker } from '@/components/form-date-picker'
import {
applyVpsFilters,
buildDefaultVpsFilters,
@@ -45,7 +41,7 @@ import {
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
import type { Vps } from '@/types/entities'
-import { vpsStatusLabel, tariffTypeLabel, getCountryFlagEmoji, getCountryFlagEmojiByCode } from '@/lib/format'
+import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo'
@@ -167,7 +163,7 @@ function VpsPage() {
value: name,
label: name,
code: ref?.code,
- leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name),
+ leading: ,
}
})
}, [snapshot?.vps])
@@ -245,14 +241,9 @@ function VpsPage() {
const country = v.country?.trim()
const city = v.city?.trim()
if (!country && !city) return —
- const countryEntry = country ? COUNTRY_BY_NAME_RU[country] : undefined
- const flag = countryEntry
- ? getCountryFlagEmojiByCode(countryEntry.code)
- : country
- ? getCountryFlagEmoji(country)
- : null
const primary = country || city || '—'
const secondary = country && city ? city : undefined
+ const flag = country ? : null
return flag
? dataGridCellWithFlag(flag, primary, secondary)
: dataGridCellStack(primary, secondary)
@@ -599,26 +590,13 @@ function VpsPage() {
{
- const strVal = (field.value as string | undefined) ?? ''
- const parsed = strVal ? parseISO(strVal) : undefined
- const dateVal: DateSelectorValue | undefined =
- parsed && isValid(parsed)
- ? { period: 'day', operator: 'is', startDate: parsed }
- : undefined
- return (
- {
- const d = v.startDate
- field.onChange(d ? format(d, 'yyyy-MM-dd') : '')
- }}
- allowRange={false}
- defaultPeriodType="day"
- showInput
- />
- )
- }}
+ render={({ field }) => (
+
+ )}
/>