fix(web): флаги стран, FormDatePicker и плоские таблицы без двойной рамки
SVG-флаги через flagcdn и resolveCountryCode; поле «Оплачено до» — компактный Popover+Calendar на русском; DataGridCard без вложенной обводки. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TData extends object> {
|
||||
|
||||
function DataGridPaginationBar() {
|
||||
return (
|
||||
<div className="border-t px-4 py-2.5">
|
||||
<div className="px-4 py-2.5">
|
||||
<DataGridPagination {...PAGINATION_LABELS} />
|
||||
</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>({
|
||||
title,
|
||||
description,
|
||||
@@ -119,28 +181,19 @@ export function DataGridCard<TData extends object>({
|
||||
enableColumnPinning: pinLastColumn,
|
||||
})
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || actions) && (
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || actions) && (
|
||||
if (data.length === 0) {
|
||||
if (!hasHeader) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<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">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
@@ -148,45 +201,41 @@ export function DataGridCard<TData extends object>({
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<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>
|
||||
<CardContent>
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</CardContent>
|
||||
</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 (
|
||||
<Card className={cn('ring-0 shadow-none', className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function dataGridCellWithFlag(
|
||||
secondary?: ReactNode,
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-4 shrink-0 leading-none">{flag}</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">{flag}</span>
|
||||
{secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>}
|
||||
</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,
|
||||
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<string>[] = countryOptions.map((c) => ({
|
||||
value: c.value,
|
||||
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) },
|
||||
}))
|
||||
|
||||
|
||||
@@ -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: 'Выберите период…',
|
||||
}
|
||||
@@ -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<string, { code: string }> = 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<string, string> = {
|
||||
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 коду страны. */
|
||||
|
||||
@@ -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: <CountryFlag code={ref?.code} country={name} />,
|
||||
}
|
||||
})
|
||||
}, [snapshot?.vps])
|
||||
@@ -245,14 +241,9 @@ function VpsPage() {
|
||||
const country = v.country?.trim()
|
||||
const city = v.city?.trim()
|
||||
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 secondary = country && city ? city : undefined
|
||||
const flag = country ? <CountryFlag country={country} /> : null
|
||||
return flag
|
||||
? dataGridCellWithFlag(flag, primary, secondary)
|
||||
: dataGridCellStack(primary, secondary)
|
||||
@@ -599,26 +590,13 @@ function VpsPage() {
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="paidUntil"
|
||||
render={({ field }) => {
|
||||
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 (
|
||||
<DateSelector
|
||||
value={dateVal}
|
||||
onChange={(v) => {
|
||||
const d = v.startDate
|
||||
field.onChange(d ? format(d, 'yyyy-MM-dd') : '')
|
||||
}}
|
||||
allowRange={false}
|
||||
defaultPeriodType="day"
|
||||
showInput
|
||||
/>
|
||||
)
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormDatePicker
|
||||
id="vps-paid"
|
||||
value={(field.value as string | undefined) ?? ''}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Заметки" htmlFor="vps-notes">
|
||||
|
||||
Reference in New Issue
Block a user