fix(web): флаги стран, FormDatePicker и плоские таблицы без двойной рамки

SVG-флаги через flagcdn и resolveCountryCode; поле «Оплачено до» — компактный Popover+Calendar на русском; DataGridCard без вложенной обводки.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-27 01:59:50 +07:00
co-authored by Cursor
parent 4c7dfc45d6
commit f681f72e8c
8 changed files with 329 additions and 102 deletions
+22
View File
@@ -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 (
<img
src={url}
alt=""
className={cn('size-4 shrink-0 rounded-full object-cover', className)}
/>
)
}
+96 -47
View File
@@ -10,6 +10,7 @@ import {
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
import { import {
DataGrid, DataGrid,
DataGridContainer, DataGridContainer,
@@ -68,12 +69,73 @@ export interface DataGridCardProps<TData extends object> {
function DataGridPaginationBar() { function DataGridPaginationBar() {
return ( return (
<div className="border-t px-4 py-2.5"> <div className="px-4 py-2.5">
<DataGridPagination {...PAGINATION_LABELS} /> <DataGridPagination {...PAGINATION_LABELS} />
</div> </div>
) )
} }
function DataGridCardBody<TData extends object>({
table,
data,
emptyTitle,
onRowClick,
dense,
virtualization,
height,
footerContent,
showPagination,
}: {
table: ReturnType<typeof useReactTable<TData>>
data: TData[]
emptyTitle: string
onRowClick?: (row: TData) => void
dense: boolean
virtualization: boolean
height: number
footerContent?: ReactNode
showPagination: boolean
}) {
return (
<DataGridContainer border={false}>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: false,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
>
{virtualization ? (
<>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
{showPagination ? <DataGridPaginationBar /> : null}
</>
) : (
<>
<DataGridTable footerContent={footerContent} />
{showPagination ? <DataGridPaginationBar /> : null}
</>
)}
</DataGrid>
</DataGridContainer>
)
}
export function DataGridCard<TData extends object>({ export function DataGridCard<TData extends object>({
title, title,
description, description,
@@ -119,10 +181,19 @@ export function DataGridCard<TData extends object>({
enableColumnPinning: pinLastColumn, enableColumnPinning: pinLastColumn,
}) })
const hasHeader = Boolean(title || description || actions)
if (data.length === 0) { if (data.length === 0) {
if (!hasHeader) {
return ( return (
<Card className={className}> <div className={className}>
{(title || actions) && ( <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
)
}
return (
<Card className={cn('ring-0 shadow-none', className)}>
<CardHeader className="flex flex-row items-center justify-between gap-2"> <CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1"> <div className="space-y-1">
{title ? <CardTitle>{title}</CardTitle> : null} {title ? <CardTitle>{title}</CardTitle> : null}
@@ -130,63 +201,41 @@ export function DataGridCard<TData extends object>({
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardHeader> </CardHeader>
)} <CardContent>
<CardContent className="p-4">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} /> <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</CardContent> </CardContent>
</Card> </Card>
) )
} }
const gridBody = (
<DataGridCardBody
table={table}
data={data}
emptyTitle={emptyTitle}
onRowClick={onRowClick}
dense={dense}
virtualization={virtualization}
height={height}
footerContent={footerContent}
showPagination={showPagination}
/>
)
if (!hasHeader) {
return <div className={className}>{gridBody}</div>
}
return ( return (
<Card className={className}> <Card className={cn('ring-0 shadow-none', className)}>
{(title || actions) && ( <CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1"> <div className="space-y-1">
{title ? <CardTitle>{title}</CardTitle> : null} {title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null} {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div> </div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null} {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardHeader> </CardHeader>
)} <CardContent className="p-0 pt-2">{gridBody}</CardContent>
<CardContent className={dense ? 'p-3' : 'p-4'}>
<DataGridContainer>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: false,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
>
{virtualization ? (
<>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
{showPagination ? <DataGridPaginationBar /> : null}
</>
) : (
<>
<DataGridTable footerContent={footerContent} />
{showPagination ? <DataGridPaginationBar /> : null}
</>
)}
</DataGrid>
</DataGridContainer>
</CardContent>
</Card> </Card>
) )
} }
+2 -2
View File
@@ -34,8 +34,8 @@ export function dataGridCellWithFlag(
secondary?: ReactNode, secondary?: ReactNode,
) { ) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5">
<span className="size-4 shrink-0 leading-none">{flag}</span> <span className="flex size-4 shrink-0 items-center justify-center">{flag}</span>
{secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>} {secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>}
</div> </div>
) )
@@ -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 (
<div className={cn('relative w-full', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
id={id}
type="button"
variant="outline"
className={cn(
'w-full justify-start pe-9 font-normal',
!selected && 'text-muted-foreground',
)}
>
<CalendarIcon data-icon="inline-start" />
{label}
</Button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={selected}
onSelect={(date) => {
if (date) {
onChange(format(date, 'yyyy-MM-dd'))
setOpen(false)
}
}}
defaultMonth={selected}
locale={ru}
weekStartsOn={1}
/>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute end-1 top-1/2 -translate-y-1/2"
aria-label="Очистить дату"
onClick={() => onChange('')}
>
<XIcon />
</Button>
) : null}
</div>
)
}
@@ -30,7 +30,8 @@ import {
saveFilterPresets, saveFilterPresets,
type VpsFilterPreset, type VpsFilterPreset,
} from '@/components/vps-filters' } 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' import type { Provider, ProviderAccount, Vps } from '@/types/entities'
interface VpsFiltersToolbarProps { interface VpsFiltersToolbarProps {
@@ -187,7 +188,7 @@ export function VpsFiltersToolbar({
const countryOpts: FilterOption<string>[] = countryOptions.map((c) => ({ const countryOpts: FilterOption<string>[] = countryOptions.map((c) => ({
value: c.value, value: c.value,
label: c.label, label: c.label,
icon: c.code ? <span className="size-4 leading-none">{getCountryFlagEmojiByCode(c.code)}</span> : undefined, icon: c.code ? <CountryFlag code={c.code} /> : <CountryFlag country={c.value} />,
metadata: { count: count((v) => (v.country ?? '').trim() === c.value) }, metadata: { count: count((v) => (v.country ?? '').trim() === c.value) },
})) }))
+65
View File
@@ -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: 'Выберите период…',
}
+29 -3
View File
@@ -1,4 +1,9 @@
import type { Settings, RatesData, Vps, Provider } from '@/types/entities' 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<string, { code: string }> = Object.fromEntries(
COUNTRIES.map((c) => [c.nameEn.toLowerCase(), c]),
)
export function uid(): string { export function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
@@ -30,11 +35,32 @@ const COUNTRY_CODE_BY_NAME: Record<string, string> = {
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ', 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 { export function getCountryFlagEmoji(country?: string): string {
if (!country) return '🌐' const code = resolveCountryCode(country)
const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
if (!code) return '🌐' if (!code) return '🌐'
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('') return getCountryFlagEmojiByCode(code)
} }
/** Флаг по ISO 3166-1 alpha-2 коду страны. */ /** Флаг по ISO 3166-1 alpha-2 коду страны. */
+11 -33
View File
@@ -4,8 +4,6 @@ import { useState, useMemo } from 'react'
import { Controller } from 'react-hook-form' import { Controller } from 'react-hook-form'
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react' import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { format, parseISO, isValid } from 'date-fns'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client' import { api, ApiError } from '@/lib/api-client'
import { vpsSchema, type VpsFormValues } from '@/lib/schemas' 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 { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
import type { DataTableColumn } from '@/components/data-table-card' import type { DataTableColumn } from '@/components/data-table-card'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
@@ -33,10 +32,7 @@ import {
NumberFieldIncrement, NumberFieldIncrement,
NumberFieldInput, NumberFieldInput,
} from '@/components/reui/number-field' } from '@/components/reui/number-field'
import { import { FormDatePicker } from '@/components/form-date-picker'
DateSelector,
type DateSelectorValue,
} from '@/components/reui/date-selector'
import { import {
applyVpsFilters, applyVpsFilters,
buildDefaultVpsFilters, buildDefaultVpsFilters,
@@ -45,7 +41,7 @@ import {
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar' import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
import type { Vps } from '@/types/entities' 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 { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo' import { COUNTRIES, COUNTRY_BY_NAME_RU, listCities } from '@cfdm/shared/geo'
@@ -167,7 +163,7 @@ function VpsPage() {
value: name, value: name,
label: name, label: name,
code: ref?.code, code: ref?.code,
leading: ref ? getCountryFlagEmojiByCode(ref.code) : getCountryFlagEmoji(name), leading: <CountryFlag code={ref?.code} country={name} />,
} }
}) })
}, [snapshot?.vps]) }, [snapshot?.vps])
@@ -245,14 +241,9 @@ function VpsPage() {
const country = v.country?.trim() const country = v.country?.trim()
const city = v.city?.trim() const city = v.city?.trim()
if (!country && !city) return <span className="text-muted-foreground">—</span> if (!country && !city) return <span className="text-muted-foreground">—</span>
const countryEntry = country ? COUNTRY_BY_NAME_RU[country] : undefined
const flag = countryEntry
? getCountryFlagEmojiByCode(countryEntry.code)
: country
? getCountryFlagEmoji(country)
: null
const primary = country || city || '—' const primary = country || city || '—'
const secondary = country && city ? city : undefined const secondary = country && city ? city : undefined
const flag = country ? <CountryFlag country={country} /> : null
return flag return flag
? dataGridCellWithFlag(flag, primary, secondary) ? dataGridCellWithFlag(flag, primary, secondary)
: dataGridCellStack(primary, secondary) : dataGridCellStack(primary, secondary)
@@ -599,26 +590,13 @@ function VpsPage() {
<Controller <Controller
control={form.control} control={form.control}
name="paidUntil" name="paidUntil"
render={({ field }) => { render={({ field }) => (
const strVal = (field.value as string | undefined) ?? '' <FormDatePicker
const parsed = strVal ? parseISO(strVal) : undefined id="vps-paid"
const dateVal: DateSelectorValue | undefined = value={(field.value as string | undefined) ?? ''}
parsed && isValid(parsed) onChange={field.onChange}
? { period: 'day', operator: 'is', startDate: parsed }
: undefined
return (
<DateSelector
value={dateVal}
onChange={(v) => {
const d = v.startDate
field.onChange(d ? format(d, 'yyyy-MM-dd') : '')
}}
allowRange={false}
defaultPeriodType="day"
showInput
/> />
) )}
}}
/> />
</FormField> </FormField>
<FormField label="Заметки" htmlFor="vps-notes"> <FormField label="Заметки" htmlFor="vps-notes">