fix(web): флаги стран, FormDatePicker и плоские таблицы без двойной рамки
SVG-флаги через flagcdn и resolveCountryCode; поле «Оплачено до» — компактный Popover+Calendar на русском; DataGridCard без вложенной обводки. Co-authored-by: Cursor <[email protected]>
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'
|
} 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,28 +181,19 @@ export function DataGridCard<TData extends object>({
|
|||||||
enableColumnPinning: pinLastColumn,
|
enableColumnPinning: pinLastColumn,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (data.length === 0) {
|
const hasHeader = Boolean(title || description || actions)
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (data.length === 0) {
|
||||||
<Card className={className}>
|
if (!hasHeader) {
|
||||||
{(title || actions) && (
|
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">
|
<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}
|
||||||
@@ -148,45 +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={dense ? 'p-3' : 'p-4'}>
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
<DataGridContainer>
|
</CardContent>
|
||||||
<DataGrid
|
</Card>
|
||||||
table={table}
|
)
|
||||||
recordCount={data.length}
|
}
|
||||||
onRowClick={onRowClick}
|
|
||||||
emptyMessage={emptyTitle}
|
const gridBody = (
|
||||||
tableLayout={{
|
<DataGridCardBody
|
||||||
dense,
|
table={table}
|
||||||
rowBorder: true,
|
data={data}
|
||||||
headerSticky: true,
|
emptyTitle={emptyTitle}
|
||||||
headerBackground: true,
|
onRowClick={onRowClick}
|
||||||
headerBorder: true,
|
dense={dense}
|
||||||
width: 'auto',
|
virtualization={virtualization}
|
||||||
columnsVisibility: false,
|
height={height}
|
||||||
columnsResizable: false,
|
footerContent={footerContent}
|
||||||
columnsPinnable: false,
|
showPagination={showPagination}
|
||||||
columnsMovable: false,
|
/>
|
||||||
rowsDraggable: false,
|
)
|
||||||
rowsPinnable: false,
|
|
||||||
}}
|
if (!hasHeader) {
|
||||||
>
|
return <div className={className}>{gridBody}</div>
|
||||||
{virtualization ? (
|
}
|
||||||
<>
|
|
||||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
return (
|
||||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
<Card className={cn('ring-0 shadow-none', className)}>
|
||||||
</DataGridScrollArea>
|
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
||||||
{showPagination ? <DataGridPaginationBar /> : null}
|
<div className="space-y-1">
|
||||||
</>
|
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||||
) : (
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
<>
|
</div>
|
||||||
<DataGridTable footerContent={footerContent} />
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
{showPagination ? <DataGridPaginationBar /> : null}
|
</CardHeader>
|
||||||
</>
|
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
|
||||||
)}
|
|
||||||
</DataGrid>
|
|
||||||
</DataGridContainer>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 коду страны. */
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user