From f681f72e8c13dabb4740eb58b7eb89c50139de49 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 27 Jun 2026 01:59:50 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=84=D0=BB=D0=B0=D0=B3=D0=B8=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD,=20FormDatePicker=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=81=D0=BA=D0=B8=D0=B5=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20=D1=80=D0=B0=D0=BC=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG-флаги через flagcdn и resolveCountryCode; поле «Оплачено до» — компактный Popover+Calendar на русском; DataGridCard без вложенной обводки. Co-authored-by: Cursor --- apps/web/src/components/country-flag.tsx | 22 +++ apps/web/src/components/data-grid-card.tsx | 171 +++++++++++------- apps/web/src/components/data-grid-cells.tsx | 4 +- apps/web/src/components/form-date-picker.tsx | 86 +++++++++ .../src/components/vps-filters-toolbar.tsx | 5 +- apps/web/src/lib/date-selector-i18n.ts | 65 +++++++ apps/web/src/lib/format.ts | 32 +++- apps/web/src/routes/_auth/vps.tsx | 46 ++--- 8 files changed, 329 insertions(+), 102 deletions(-) create mode 100644 apps/web/src/components/country-flag.tsx create mode 100644 apps/web/src/components/form-date-picker.tsx create mode 100644 apps/web/src/lib/date-selector-i18n.ts diff --git a/apps/web/src/components/country-flag.tsx b/apps/web/src/components/country-flag.tsx new file mode 100644 index 0000000..c5d4236 --- /dev/null +++ b/apps/web/src/components/country-flag.tsx @@ -0,0 +1,22 @@ +import { cn } from '@cfdm/ui/lib/utils' +import { getCountryFlagUrl, resolveCountryCode } from '@/lib/format' + +interface CountryFlagProps { + code?: string + country?: string + className?: string +} + +export function CountryFlag({ code, country, className }: CountryFlagProps) { + const resolvedCode = code ?? resolveCountryCode(country) + const url = getCountryFlagUrl(resolvedCode) + if (!url) return null + + return ( + + ) +} diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index e7a69f3..6bb943f 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -10,6 +10,7 @@ import { } from '@tanstack/react-table' import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { cn } from '@cfdm/ui/lib/utils' import { DataGrid, DataGridContainer, @@ -68,12 +69,73 @@ export interface DataGridCardProps { function DataGridPaginationBar() { return ( -
+
) } +function DataGridCardBody({ + table, + data, + emptyTitle, + onRowClick, + dense, + virtualization, + height, + footerContent, + showPagination, +}: { + table: ReturnType> + data: TData[] + emptyTitle: string + onRowClick?: (row: TData) => void + dense: boolean + virtualization: boolean + height: number + footerContent?: ReactNode + showPagination: boolean +}) { + return ( + + + {virtualization ? ( + <> + + + + {showPagination ? : null} + + ) : ( + <> + + {showPagination ? : null} + + )} + + + ) +} + export function DataGridCard({ title, description, @@ -119,28 +181,19 @@ export function DataGridCard({ enableColumnPinning: pinLastColumn, }) - if (data.length === 0) { - return ( - - {(title || actions) && ( - -
- {title ? {title} : null} - {description ?

{description}

: null} -
- {actions ?
{actions}
: null} -
- )} - - - -
- ) - } + const hasHeader = Boolean(title || description || actions) - return ( - - {(title || actions) && ( + if (data.length === 0) { + if (!hasHeader) { + return ( +
+ +
+ ) + } + + return ( +
{title ? {title} : null} @@ -148,45 +201,41 @@ export function DataGridCard({
{actions ?
{actions}
: null}
- )} - - - - {virtualization ? ( - <> - - - - {showPagination ? : null} - - ) : ( - <> - - {showPagination ? : null} - - )} - - - + + + +
+ ) + } + + const gridBody = ( + + ) + + if (!hasHeader) { + return
{gridBody}
+ } + + return ( + + +
+ {title ? {title} : null} + {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ {gridBody}
) } diff --git a/apps/web/src/components/data-grid-cells.tsx b/apps/web/src/components/data-grid-cells.tsx index c4f7272..0d24daf 100644 --- a/apps/web/src/components/data-grid-cells.tsx +++ b/apps/web/src/components/data-grid-cells.tsx @@ -34,8 +34,8 @@ export function dataGridCellWithFlag( secondary?: ReactNode, ) { return ( -
- {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 }) => ( + + )} />