fix(userapi): валюта и ставки Macloud/VDSina при синке
Docker / build (push) Has been cancelled

Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 02:48:04 +07:00
co-authored by Cursor
parent 6c5eb04fae
commit 031fc3aaa0
14 changed files with 1508 additions and 144 deletions
@@ -0,0 +1,519 @@
import { useMemo } from 'react'
import { SlidersHorizontalIcon, PlusIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
import { Separator } from '@cfdm/ui/components/separator'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@cfdm/ui/components/popover'
import {
ListFiltersBar,
FilterToggleChip,
type FilterChip,
} from '@/components/list-filters-bar'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import {
Filters,
createFilter,
type Filter,
type FilterFieldConfig,
type FilterI18nConfig,
type FilterOption,
type CustomRendererProps,
} from '@/components/reui/filters'
import {
type TariffFiltersState,
buildDefaultTariffFilters,
hasActiveTariffFilters,
} from '@/components/tariff-filters'
import { CountryFlag } from '@/components/country-flag'
import type { ActiveTariff, Provider, ProviderAccount } from '@/types/entities'
interface TariffsFiltersToolbarProps {
filters: TariffFiltersState
onChange: (next: TariffFiltersState) => void
providers: Provider[]
providerAccounts: ProviderAccount[]
tariffs: ActiveTariff[]
countryOptions: { value: string; label: string; code?: string }[]
locationOptions: { value: string; label: string }[]
diskTypeOptions: string[]
currencyOptions: string[]
shownCount: number
totalCount: number
columnVisibilityOptions?: DataGridColumnVisibilityOption[]
columnVisibility?: VisibilityState
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
}
function renderMinNumberField(
min: number,
max: number,
values: number[],
onChange: (v: number[]) => void,
) {
return (
<div className="px-2 py-1">
<NumberField
value={values[0] ?? null}
onValueChange={(v) => onChange([v ?? 0])}
min={min}
max={max}
size="sm"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
)
}
const RU_I18N: FilterI18nConfig = {
addFilter: 'Фильтр',
searchFields: 'Поиск поля…',
noFieldsFound: 'Поля не найдены.',
noResultsFound: 'Нет вариантов',
select: 'Выбрать…',
true: 'Да',
false: 'Нет',
min: 'Мин',
max: 'Макс',
to: 'до',
typeAndPressEnter: 'Введите и нажмите Enter',
selected: 'выбрано',
selectedCount: 'выбрано',
percent: '%',
defaultCurrency: '₽',
defaultColor: '#000000',
addFilterTitle: 'Добавить фильтр',
operators: {
is: '=',
isNot: '≠',
isAnyOf: 'любое из',
isNotAnyOf: 'не любое из',
includesAll: 'включает все',
excludesAll: 'исключает все',
before: 'до',
after: 'после',
between: 'между',
notBetween: 'не между',
contains: 'содержит',
notContains: 'не содержит',
startsWith: 'начинается с',
endsWith: 'заканчивается на',
isExactly: 'точно',
equals: '=',
notEquals: '≠',
greaterThan: '>',
lessThan: '<',
overlaps: 'пересекается',
includes: 'включает',
excludes: 'исключает',
includesAllOf: 'включает все из',
includesAnyOf: 'включает любое из',
empty: 'пусто',
notEmpty: 'не пусто',
},
placeholders: {
enterField: (t) => `Введите ${t}`,
selectField: 'Выбрать…',
searchField: (n) => `Поиск: ${n.toLowerCase()}`,
enterKey: 'Введите ключ…',
enterValue: 'Введите значение…',
},
helpers: {
formatOperator: (op) => op.replace(/_/g, ' '),
},
validation: {
invalidEmail: 'Некорректный email',
invalidUrl: 'Некорректный URL',
invalidTel: 'Некорректный телефон',
invalid: 'Некорректный формат',
},
}
function getTariffProviderId(
tariff: ActiveTariff,
providerAccounts: ProviderAccount[],
): string | undefined {
if (tariff.providerId) return tariff.providerId
return providerAccounts.find((a) => a.id === tariff.providerAccountId)?.providerId
}
function stateToFilters(state: TariffFiltersState): (Filter<string> | Filter<number>)[] {
const out: (Filter<string> | Filter<number>)[] = []
if (state.providerId.length) out.push(createFilter<string>('providerId', 'is_any_of', state.providerId))
if (state.providerAccountId.length) out.push(createFilter<string>('providerAccountId', 'is_any_of', state.providerAccountId))
if (state.country.length) out.push(createFilter<string>('country', 'is_any_of', state.country))
if (state.location.length) out.push(createFilter<string>('location', 'is_any_of', state.location))
if (state.datacenter) out.push(createFilter<string>('datacenter', 'contains', [state.datacenter]))
if (state.diskType.length) out.push(createFilter<string>('diskType', 'is_any_of', state.diskType))
if (state.currency.length) out.push(createFilter<string>('currency', 'is_any_of', state.currency))
if (state.minVcpu != null) out.push(createFilter<number>('minVcpu', 'is', [state.minVcpu]))
if (state.minRamGb != null) out.push(createFilter<number>('minRamGb', 'is', [state.minRamGb]))
if (state.minDiskGb != null) out.push(createFilter<number>('minDiskGb', 'is', [state.minDiskGb]))
if (state.minPrice != null) out.push(createFilter<number>('minPrice', 'is', [state.minPrice]))
if (state.maxPrice != null) out.push(createFilter<number>('maxPrice', 'is', [state.maxPrice]))
return out
}
function filtersToState(filters: Filter[], base: TariffFiltersState): TariffFiltersState {
const next = buildDefaultTariffFilters()
next.search = base.search
next.hideZeroPrice = base.hideZeroPrice
next.tableCompact = base.tableCompact
for (const f of filters) {
switch (f.field) {
case 'providerId': next.providerId = f.values as string[]; break
case 'providerAccountId': next.providerAccountId = f.values as string[]; break
case 'country': next.country = f.values as string[]; break
case 'location': next.location = f.values as string[]; break
case 'datacenter': next.datacenter = (f.values[0] as string) ?? ''; break
case 'diskType': next.diskType = f.values as string[]; break
case 'currency': next.currency = f.values as string[]; break
case 'minVcpu': next.minVcpu = (f.values[0] as number) ?? null; break
case 'minRamGb': next.minRamGb = (f.values[0] as number) ?? null; break
case 'minDiskGb': next.minDiskGb = (f.values[0] as number) ?? null; break
case 'minPrice': next.minPrice = (f.values[0] as number) ?? null; break
case 'maxPrice': next.maxPrice = (f.values[0] as number) ?? null; break
}
}
return next
}
export function TariffsFiltersToolbar({
filters,
onChange,
providers,
providerAccounts,
tariffs,
countryOptions,
locationOptions,
diskTypeOptions,
currencyOptions,
shownCount,
totalCount,
columnVisibilityOptions,
columnVisibility,
onColumnVisibilityChange,
}: TariffsFiltersToolbarProps) {
const reuiFilters = useMemo<(Filter<string> | Filter<number>)[]>(() => stateToFilters(filters), [filters])
const fields = useMemo<(FilterFieldConfig<string> | FilterFieldConfig<number>)[]>(() => {
const count = (pred: (t: ActiveTariff) => boolean) => tariffs.filter(pred).length
const providerOpts: FilterOption<string>[] = providers.map((p) => ({
value: p.id,
label: p.name,
metadata: { count: count((t) => getTariffProviderId(t, providerAccounts) === p.id) },
}))
const accountOpts: FilterOption<string>[] = providerAccounts.map((a) => ({
value: a.id,
label: a.name,
metadata: { count: count((t) => t.providerAccountId === a.id) },
}))
const countryOpts: FilterOption<string>[] = countryOptions.map((c) => ({
value: c.value,
label: c.label,
icon: c.code ? <CountryFlag code={c.code} /> : <CountryFlag country={c.value} />,
metadata: { count: count((t) => (t.country ?? '').trim() === c.value) },
}))
const locationOpts: FilterOption<string>[] = locationOptions.map((l) => ({
value: l.value,
label: l.label,
metadata: { count: count((t) => (t.location ?? '').trim() === l.value) },
}))
const diskTypeOpts: FilterOption<string>[] = diskTypeOptions.map((d) => ({
value: d,
label: d,
metadata: { count: count((t) => (t.diskType ?? '').trim() === d) },
}))
const currencyOpts: FilterOption<string>[] = currencyOptions.map((c) => ({
value: c,
label: c,
metadata: { count: count((t) => (t.currency ?? '').trim() === c) },
}))
return [
{ key: 'providerId', label: 'Хостер', type: 'multiselect' as const, options: providerOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'providerAccountId', label: 'Аккаунт', type: 'multiselect' as const, options: accountOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'country', label: 'Страна', type: 'multiselect' as const, options: countryOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'location', label: 'Локация', type: 'multiselect' as const, options: locationOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'datacenter', label: 'Дата-центр', type: 'text' as const, placeholder: 'Напр. Frankfurt', defaultOperator: 'contains' },
{ key: 'diskType', label: 'Тип диска', type: 'multiselect' as const, options: diskTypeOpts, searchable: true, defaultOperator: 'is_any_of' },
{ key: 'currency', label: 'Валюта', type: 'multiselect' as const, options: currencyOpts, defaultOperator: 'is_any_of' },
{
key: 'minVcpu',
label: 'vCPU ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
renderMinNumberField(0, 32, values, onCh),
},
{
key: 'minRamGb',
label: 'RAM ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
renderMinNumberField(0, 256, values, onCh),
},
{
key: 'minDiskGb',
label: 'Disk ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
renderMinNumberField(0, 2000, values, onCh),
},
{
key: 'minPrice',
label: 'Цена ≥',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≥' }],
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
renderMinNumberField(0, 100_000, values, onCh),
},
{
key: 'maxPrice',
label: 'Цена ≤',
type: 'custom' as const,
defaultOperator: 'is',
operators: [{ value: 'is', label: '≤' }],
customRenderer: ({ values, onChange: onCh }: CustomRendererProps<number>) =>
renderMinNumberField(0, 100_000, values, onCh),
},
]
}, [providers, providerAccounts, tariffs, countryOptions, locationOptions, diskTypeOptions, currencyOptions])
const handleFiltersChange = (next: Filter[]) => {
onChange(filtersToState(next, filters))
}
const chips = useMemo((): FilterChip[] => {
const out: FilterChip[] = []
const providerById = new Map(providers.map((p) => [p.id, p.name]))
const accountById = new Map(providerAccounts.map((a) => [a.id, a.name]))
if (filters.search) {
out.push({
id: 'search',
label: `Поиск: ${filters.search}`,
onRemove: () => onChange({ ...filters, search: '' }),
})
}
if (filters.providerId.length) {
const names = filters.providerId.map((id) => providerById.get(id) ?? id).join(', ')
out.push({
id: 'providerId',
label: `Хостер: ${names}`,
onRemove: () => onChange({ ...filters, providerId: [] }),
})
}
if (filters.providerAccountId.length) {
const names = filters.providerAccountId.map((id) => accountById.get(id) ?? id).join(', ')
out.push({
id: 'providerAccountId',
label: `Аккаунт: ${names}`,
onRemove: () => onChange({ ...filters, providerAccountId: [] }),
})
}
if (filters.country.length) {
out.push({
id: 'country',
label: `Страна: ${filters.country.join(', ')}`,
onRemove: () => onChange({ ...filters, country: [] }),
})
}
if (filters.location.length) {
out.push({
id: 'location',
label: `Локация: ${filters.location.join(', ')}`,
onRemove: () => onChange({ ...filters, location: [] }),
})
}
if (filters.datacenter) {
out.push({
id: 'datacenter',
label: `ДЦ: ${filters.datacenter}`,
onRemove: () => onChange({ ...filters, datacenter: '' }),
})
}
if (filters.diskType.length) {
out.push({
id: 'diskType',
label: `Диск: ${filters.diskType.join(', ')}`,
onRemove: () => onChange({ ...filters, diskType: [] }),
})
}
if (filters.currency.length) {
out.push({
id: 'currency',
label: `Валюта: ${filters.currency.join(', ')}`,
onRemove: () => onChange({ ...filters, currency: [] }),
})
}
if (filters.minVcpu != null) {
out.push({
id: 'minVcpu',
label: `vCPU ≥ ${filters.minVcpu}`,
onRemove: () => onChange({ ...filters, minVcpu: null }),
})
}
if (filters.minRamGb != null) {
out.push({
id: 'minRamGb',
label: `RAM ≥ ${filters.minRamGb} GB`,
onRemove: () => onChange({ ...filters, minRamGb: null }),
})
}
if (filters.minDiskGb != null) {
out.push({
id: 'minDiskGb',
label: `Disk ≥ ${filters.minDiskGb} GB`,
onRemove: () => onChange({ ...filters, minDiskGb: null }),
})
}
if (filters.minPrice != null) {
out.push({
id: 'minPrice',
label: `Цена ≥ ${filters.minPrice}`,
onRemove: () => onChange({ ...filters, minPrice: null }),
})
}
if (filters.maxPrice != null) {
out.push({
id: 'maxPrice',
label: `Цена ≤ ${filters.maxPrice}`,
onRemove: () => onChange({ ...filters, maxPrice: null }),
})
}
if (filters.hideZeroPrice) {
out.push({
id: 'hideZeroPrice',
label: 'Скрыть нулевые цены',
onRemove: () => onChange({ ...filters, hideZeroPrice: false }),
})
}
if (filters.tableCompact) {
out.push({
id: 'tableCompact',
label: 'Компактная таблица',
onRemove: () => onChange({ ...filters, tableCompact: false }),
})
}
return out
}, [filters, onChange, providers, providerAccounts])
const hasActive = hasActiveTariffFilters(filters)
return (
<ListFiltersBar
search={{
value: filters.search,
onChange: (search) => onChange({ ...filters, search }),
placeholder: 'Поиск: название, ID, дата-центр, локация',
name: 'tariffs-search',
}}
controls={
<>
<Filters
filters={reuiFilters as unknown as Filter[]}
fields={fields as unknown as FilterFieldConfig[]}
onChange={handleFiltersChange}
i18n={RU_I18N}
size="sm"
allowMultiple={false}
trigger={
<Button variant="outline" size="sm">
<PlusIcon data-icon="inline-start" />
Фильтр
</Button>
}
/>
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" size="sm">
<SlidersHorizontalIcon data-icon="inline-start" />
Вид
</Button>
}
/>
<PopoverContent align="end" className="w-64 p-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">Отображение</Label>
<label className="flex items-center gap-2">
<Checkbox
checked={filters.tableCompact}
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
/>
<span className="text-sm">Компактная таблица</span>
</label>
</div>
{columnVisibilityOptions && columnVisibilityOptions.length > 0 && onColumnVisibilityChange ? (
<>
<Separator />
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
<Label className="text-xs text-muted-foreground">Колонки</Label>
{columnVisibilityOptions.map((col) => (
<label key={col.id} className="flex items-center gap-2">
<Checkbox
checked={columnVisibility?.[col.id] !== false}
onCheckedChange={(v) => onColumnVisibilityChange(col.id, Boolean(v))}
/>
<span className="text-sm">{col.label}</span>
</label>
))}
</div>
</>
) : null}
</div>
</PopoverContent>
</Popover>
</>
}
toggles={
<FilterToggleChip
label="Скрыть нулевые цены"
active={filters.hideZeroPrice}
onClick={() => onChange({ ...filters, hideZeroPrice: !filters.hideZeroPrice })}
/>
}
chips={chips}
shown={shownCount}
total={totalCount}
showReset={hasActive}
onReset={() => onChange(buildDefaultTariffFilters())}
/>
)
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
applyTariffFilters,
buildDefaultTariffFilters,
hasActiveTariffFilters,
hasTariffZeroResults,
} from '@/components/tariff-filters'
import type { ActiveTariff, ProviderAccount } from '@/types/entities'
const accounts: ProviderAccount[] = [
{
id: 'acc-1',
providerId: 'prov-1',
name: 'Account 1',
},
]
const ctx = { providerAccounts: accounts }
const baseTariff = (overrides: Partial<ActiveTariff> = {}): ActiveTariff => ({
id: 't-1',
providerAccountId: 'acc-1',
providerId: 'prov-1',
name: 'Basic VPS',
vcpu: 2,
ramGb: 4,
diskGb: 40,
diskType: 'SSD',
monthlyRate: 500,
currency: 'RUB',
location: 'Moscow',
country: 'Россия',
datacenterName: 'MSK-1',
...overrides,
})
describe('applyTariffFilters', () => {
it('скрывает нулевые цены по умолчанию', () => {
const items = [
baseTariff({ id: 't-paid', monthlyRate: 100 }),
baseTariff({ id: 't-zero', monthlyRate: 0 }),
baseTariff({ id: 't-null', monthlyRate: undefined }),
]
const filters = buildDefaultTariffFilters()
const result = applyTariffFilters(items, filters, ctx)
expect(result.map((t) => t.id)).toEqual(['t-paid'])
})
it('показывает нулевые цены когда hideZeroPrice выключен', () => {
const items = [
baseTariff({ id: 't-paid', monthlyRate: 100 }),
baseTariff({ id: 't-zero', monthlyRate: 0 }),
]
const filters = { ...buildDefaultTariffFilters(), hideZeroPrice: false }
const result = applyTariffFilters(items, filters, ctx)
expect(result.map((t) => t.id)).toEqual(['t-paid', 't-zero'])
})
it('фильтрует по поиску в названии', () => {
const items = [
baseTariff({ id: 't-1', name: 'Premium VPS' }),
baseTariff({ id: 't-2', name: 'Basic VPS' }),
]
const filters = { ...buildDefaultTariffFilters(), search: 'premium', hideZeroPrice: false }
const result = applyTariffFilters(items, filters, ctx)
expect(result.map((t) => t.id)).toEqual(['t-1'])
})
it('фильтрует по providerId через providerAccountId', () => {
const items = [
baseTariff({ id: 't-1', providerId: 'prov-1', providerAccountId: 'acc-1' }),
baseTariff({ id: 't-2', providerId: 'prov-2', providerAccountId: 'acc-2' }),
]
const filters = {
...buildDefaultTariffFilters(),
providerId: ['prov-2'],
hideZeroPrice: false,
}
const result = applyTariffFilters(items, filters, ctx)
expect(result.map((t) => t.id)).toEqual(['t-2'])
})
it('фильтрует по диапазону цены', () => {
const items = [
baseTariff({ id: 't-low', monthlyRate: 100 }),
baseTariff({ id: 't-mid', monthlyRate: 500 }),
baseTariff({ id: 't-high', monthlyRate: 1000 }),
]
const filters = {
...buildDefaultTariffFilters(),
minPrice: 200,
maxPrice: 800,
hideZeroPrice: false,
}
const result = applyTariffFilters(items, filters, ctx)
expect(result.map((t) => t.id)).toEqual(['t-mid'])
})
})
describe('hasActiveTariffFilters', () => {
it('не считает hideZeroPrice=true активным отклонением', () => {
expect(hasActiveTariffFilters(buildDefaultTariffFilters())).toBe(false)
})
it('считает hideZeroPrice=false активным отклонением', () => {
expect(hasActiveTariffFilters({ ...buildDefaultTariffFilters(), hideZeroPrice: false })).toBe(true)
})
})
describe('hasTariffZeroResults', () => {
it('true когда все отфильтрованы нулевыми ценами', () => {
expect(hasTariffZeroResults(buildDefaultTariffFilters(), 5, 0)).toBe(true)
})
it('false когда нет данных', () => {
expect(hasTariffZeroResults(buildDefaultTariffFilters(), 0, 0)).toBe(false)
})
})
+234
View File
@@ -0,0 +1,234 @@
import type { ActiveTariff, ProviderAccount } from '@/types/entities'
export interface TariffFiltersState {
search: string
providerId: string[]
providerAccountId: string[]
country: string[]
location: string[]
datacenter: string
diskType: string[]
currency: string[]
minVcpu: number | null
minRamGb: number | null
minDiskGb: number | null
minPrice: number | null
maxPrice: number | null
hideZeroPrice: boolean
tableCompact: boolean
}
export interface TariffFilterContext {
providerAccounts: ProviderAccount[]
}
export function buildDefaultTariffFilters(): TariffFiltersState {
return {
search: '',
providerId: [],
providerAccountId: [],
country: [],
location: [],
datacenter: '',
diskType: [],
currency: [],
minVcpu: null,
minRamGb: null,
minDiskGb: null,
minPrice: null,
maxPrice: null,
hideZeroPrice: true,
tableCompact: false,
}
}
const matchesAny = <T,>(item: T | undefined, values: T[]): boolean => {
if (values.length === 0) return true
if (item == null) return false
return values.includes(item)
}
const matchesText = (
item: string | undefined | null,
values: string[],
operator: string,
): boolean => {
if (values.length === 0) return true
const v = (item ?? '').toLowerCase()
const q = (values[0] ?? '').toLowerCase()
if (!q) return true
switch (operator) {
case 'not_contains':
return !v.includes(q)
case 'starts_with':
return v.startsWith(q)
case 'ends_with':
return v.endsWith(q)
case 'is':
return v === q
default:
return v.includes(q)
}
}
const matchesNumberGte = (
item: number | undefined | null,
values: number[],
): boolean => {
if (values.length === 0) return true
const threshold = values[0]
if (threshold == null) return true
return Number(item ?? 0) >= threshold
}
const matchesNumberLte = (
item: number | undefined | null,
values: number[],
): boolean => {
if (values.length === 0) return true
const threshold = values[0]
if (threshold == null) return true
return Number(item ?? 0) <= threshold
}
function getTariffProviderId(tariff: ActiveTariff, ctx: TariffFilterContext): string | undefined {
if (tariff.providerId) return tariff.providerId
return ctx.providerAccounts.find((a) => a.id === tariff.providerAccountId)?.providerId
}
function tariffExternalId(tariff: ActiveTariff): string {
return tariff.externalId ?? tariff.pricelistId ?? ''
}
function isZeroPrice(monthlyRate: number | null | undefined): boolean {
return monthlyRate == null || monthlyRate <= 0
}
interface ActiveFilter {
field: string
operator: string
values: unknown[]
}
export function applyTariffFilters(
items: ActiveTariff[],
filters: TariffFiltersState | ActiveFilter[],
ctx: TariffFilterContext,
): ActiveTariff[] {
const state = Array.isArray(filters) ? null : filters
const activeFilters: ActiveFilter[] = Array.isArray(filters)
? filters
: stateToActiveFilters(filters)
const search = activeFilters.find((f) => f.field === 'search')?.values?.[0] as string ?? ''
const searchLower = search.toLowerCase()
const hideZeroPrice = state?.hideZeroPrice ?? true
return items.filter((item) => {
if (hideZeroPrice && isZeroPrice(item.monthlyRate)) return false
if (searchLower) {
const haystack = [
item.name,
tariffExternalId(item),
item.datacenterName,
item.location,
]
.map((s) => (s ?? '').toLowerCase())
.join(' ')
if (!haystack.includes(searchLower)) return false
}
for (const f of activeFilters) {
if (f.field === 'search') continue
switch (f.field) {
case 'providerId':
if (!matchesAny(getTariffProviderId(item, ctx), f.values as string[])) return false
break
case 'providerAccountId':
if (!matchesAny(item.providerAccountId, f.values as string[])) return false
break
case 'country':
if (!matchesAny((item.country ?? '').trim() || undefined, f.values as string[])) return false
break
case 'location':
if (!matchesAny((item.location ?? '').trim() || undefined, f.values as string[])) return false
break
case 'datacenter':
if (!matchesText(item.datacenterName, f.values as string[], f.operator)) return false
break
case 'diskType':
if (!matchesAny((item.diskType ?? '').trim() || undefined, f.values as string[])) return false
break
case 'currency':
if (!matchesAny((item.currency ?? '').trim() || undefined, f.values as string[])) return false
break
case 'minVcpu':
if (!matchesNumberGte(item.vcpu, f.values as number[])) return false
break
case 'minRamGb':
if (!matchesNumberGte(item.ramGb, f.values as number[])) return false
break
case 'minDiskGb':
if (!matchesNumberGte(item.diskGb, f.values as number[])) return false
break
case 'minPrice':
if (!matchesNumberGte(item.monthlyRate, f.values as number[])) return false
break
case 'maxPrice':
if (!matchesNumberLte(item.monthlyRate, f.values as number[])) return false
break
}
}
return true
})
}
export function stateToActiveFilters(state: TariffFiltersState): ActiveFilter[] {
const out: ActiveFilter[] = []
if (state.search) out.push({ field: 'search', operator: 'contains', values: [state.search] })
if (state.providerId.length) out.push({ field: 'providerId', operator: 'is_any_of', values: state.providerId })
if (state.providerAccountId.length) out.push({ field: 'providerAccountId', operator: 'is_any_of', values: state.providerAccountId })
if (state.country.length) out.push({ field: 'country', operator: 'is_any_of', values: state.country })
if (state.location.length) out.push({ field: 'location', operator: 'is_any_of', values: state.location })
if (state.datacenter) out.push({ field: 'datacenter', operator: 'contains', values: [state.datacenter] })
if (state.diskType.length) out.push({ field: 'diskType', operator: 'is_any_of', values: state.diskType })
if (state.currency.length) out.push({ field: 'currency', operator: 'is_any_of', values: state.currency })
if (state.minVcpu != null) out.push({ field: 'minVcpu', operator: 'gte', values: [state.minVcpu] })
if (state.minRamGb != null) out.push({ field: 'minRamGb', operator: 'gte', values: [state.minRamGb] })
if (state.minDiskGb != null) out.push({ field: 'minDiskGb', operator: 'gte', values: [state.minDiskGb] })
if (state.minPrice != null) out.push({ field: 'minPrice', operator: 'gte', values: [state.minPrice] })
if (state.maxPrice != null) out.push({ field: 'maxPrice', operator: 'lte', values: [state.maxPrice] })
return out
}
export function countActiveTariffFilters(filters: TariffFiltersState): number {
let n = 0
if (filters.search) n++
if (filters.providerId.length) n++
if (filters.providerAccountId.length) n++
if (filters.country.length) n++
if (filters.location.length) n++
if (filters.datacenter) n++
if (filters.diskType.length) n++
if (filters.currency.length) n++
if (filters.minVcpu != null) n++
if (filters.minRamGb != null) n++
if (filters.minDiskGb != null) n++
if (filters.minPrice != null) n++
if (filters.maxPrice != null) n++
return n
}
export function hasActiveTariffFilters(filters: TariffFiltersState): boolean {
const defaults = buildDefaultTariffFilters()
return (
countActiveTariffFilters(filters) > 0 ||
filters.tableCompact !== defaults.tableCompact ||
filters.hideZeroPrice !== defaults.hideZeroPrice
)
}
export function hasTariffZeroResults(filters: TariffFiltersState, total: number, shown: number): boolean {
return total > 0 && shown === 0 && (hasActiveTariffFilters(filters) || filters.hideZeroPrice)
}
+302 -78
View File
@@ -1,23 +1,48 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { toast } from 'sonner'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import {
DataGridCard,
columnDefFromDataGrid,
loadStoredColumnVisibility,
dataGridColumnVisibilityOptions,
} from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon, RefreshCwIcon } from 'lucide-react'
import {
ServerIcon,
UserRoundIcon,
CpuIcon,
CoinsIcon,
HardDriveIcon,
RefreshCwIcon,
MapPinIcon,
GlobeIcon,
} from 'lucide-react'
import type { ActiveTariff } from '@/types/entities'
import { providerByIdMap, accountSelectLabel, billmanagerSyncableAccounts } from '@/lib/billmanager'
import { formatCurrency } from '@/lib/format'
import { computeTariffDiffs } from '@/lib/tariff-diff'
import {
applyTariffFilters,
buildDefaultTariffFilters,
hasTariffZeroResults,
type TariffFiltersState,
} from '@/components/tariff-filters'
import { TariffsFiltersToolbar } from '@/components/tariff-filters-toolbar'
import { CountryFlag } from '@/components/country-flag'
import { COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
export const Route = createFileRoute('/_auth/tariffs')({
loader: ({ context: { queryClient } }) =>
@@ -25,14 +50,90 @@ export const Route = createFileRoute('/_auth/tariffs')({
component: TariffsPage,
})
const INITIAL_COLUMN_VISIBILITY: VisibilityState = {
location: false,
country: false,
datacenterName: false,
}
function tariffDisplayId(t: ActiveTariff): string {
return t.externalId ?? t.pricelistId ?? t.id
}
function TariffsPage() {
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [filters, setFilters] = useState<TariffFiltersState>(buildDefaultTariffFilters())
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({
...INITIAL_COLUMN_VISIBILITY,
...(loadStoredColumnVisibility('tariffs-column-visibility') ?? {}),
}))
useEffect(() => {
localStorage.setItem('tariffs-column-visibility', JSON.stringify(columnVisibility))
}, [columnVisibility])
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const syncableCount = snapshot
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
: 0
const filterCtx = useMemo(
() => ({ providerAccounts: snapshot?.providerAccounts ?? [] }),
[snapshot?.providerAccounts],
)
const filteredTariffs = useMemo(
() => applyTariffFilters(snapshot?.activeTariffs ?? [], filters, filterCtx),
[snapshot?.activeTariffs, filters, filterCtx],
)
const countryOptions = useMemo(() => {
const names = new Set<string>()
for (const t of snapshot?.activeTariffs ?? []) {
const c = (t.country ?? '').trim()
if (c) names.add(c)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
return {
value: name,
label: name,
code: ref?.code,
}
})
}, [snapshot?.activeTariffs])
const locationOptions = useMemo(() => {
const names = new Set<string>()
for (const t of snapshot?.activeTariffs ?? []) {
const loc = (t.location ?? '').trim()
if (loc) names.add(loc)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((value) => ({
value,
label: value,
}))
}, [snapshot?.activeTariffs])
const diskTypeOptions = useMemo(() => {
const types = new Set<string>()
for (const t of snapshot?.activeTariffs ?? []) {
const d = (t.diskType ?? '').trim()
if (d) types.add(d)
}
return [...types].sort((a, b) => a.localeCompare(b, 'ru'))
}, [snapshot?.activeTariffs])
const currencyOptions = useMemo(() => {
const currencies = new Set<string>()
for (const t of snapshot?.activeTariffs ?? []) {
const c = (t.currency ?? '').trim()
if (c) currencies.add(c)
}
return [...currencies].sort((a, b) => a.localeCompare(b, 'ru'))
}, [snapshot?.activeTariffs])
const tariffDiffs = useMemo(
() => (snapshot ? computeTariffDiffs(snapshot.vps, snapshot.activeTariffs) : []),
[snapshot],
@@ -62,59 +163,117 @@ function TariffsPage() {
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка загрузки тарифов'),
})
const columns: DataGridColumn<ActiveTariff>[] = [
{
key: 'name',
header: 'Тариф',
icon: ServerIcon,
cell: (t) => <span className="font-medium">{t.name || `#${t.pricelistId ?? t.id}`}</span>,
},
{
key: 'account',
header: 'Аккаунт',
icon: UserRoundIcon,
sortValue: (t) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : ''
const handleColumnVisibilityChange = (columnId: string, visible: boolean) => {
setColumnVisibility((prev) => {
const next = { ...prev }
if (visible) {
delete next[columnId]
} else {
next[columnId] = false
}
return next
})
}
const columns: DataGridColumn<ActiveTariff>[] = useMemo(
() => [
{
key: 'name',
header: 'Тариф',
icon: ServerIcon,
cell: (t) => (
<span className="font-medium">{t.name || `#${tariffDisplayId(t)}`}</span>
),
},
cell: (t) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
if (!acc) return '—'
const providerName = providerById.get(acc.providerId)?.name ?? '—'
return dataGridCellStack(acc.name, providerName)
{
key: 'account',
header: 'Аккаунт',
icon: UserRoundIcon,
sortValue: (t) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
return acc ? accountSelectLabel(acc, providerById) : ''
},
cell: (t) => {
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
if (!acc) return '—'
const providerName = providerById.get(acc.providerId)?.name ?? '—'
return dataGridCellStack(acc.name, providerName)
},
},
},
{
key: 'specs',
header: 'Ресурсы',
icon: CpuIcon,
sortValue: (t) => t.vcpu ?? 0,
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB
</span>
),
},
{
key: 'price',
header: 'Цена/мес',
icon: CoinsIcon,
headerClassName: 'text-right',
className: 'text-right',
sortValue: (t) => Number(t.monthlyRate ?? 0),
cell: (t) => (
<span className="tabular-nums font-medium">
{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}
</span>
),
},
{
key: 'disk',
header: 'Диск',
icon: HardDriveIcon,
cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge>,
},
]
{
key: 'specs',
header: 'Ресурсы',
icon: CpuIcon,
sortValue: (t) => t.vcpu ?? 0,
cell: (t) => (
<span className="tabular-nums text-muted-foreground">
{t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB
</span>
),
},
{
key: 'price',
header: 'Цена/мес',
icon: CoinsIcon,
headerClassName: 'text-right',
className: 'text-right',
sortValue: (t) => Number(t.monthlyRate ?? 0),
cell: (t) => (
<span className="tabular-nums font-medium">
{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}
</span>
),
},
{
key: 'disk',
header: 'Диск',
icon: HardDriveIcon,
sortValue: (t) => t.diskType ?? '',
cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge>,
},
{
key: 'location',
header: 'Локация',
icon: MapPinIcon,
sortValue: (t) => t.location ?? '',
cell: (t) => (
<span className="text-muted-foreground">{t.location ?? '—'}</span>
),
},
{
key: 'country',
header: 'Страна',
icon: GlobeIcon,
sortValue: (t) => t.country ?? '',
cell: (t) => {
const country = (t.country ?? '').trim()
if (!country) return '—'
const ref = COUNTRY_BY_NAME_RU[country.toLowerCase()]
return (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<CountryFlag code={ref?.code} country={country} />
{country}
</span>
)
},
},
{
key: 'datacenterName',
header: 'Дата-центр',
icon: MapPinIcon,
sortValue: (t) => t.datacenterName ?? '',
cell: (t) => (
<span className="text-muted-foreground">{t.datacenterName ?? '—'}</span>
),
},
],
[snapshot, providerById],
)
const columnVisibilityOptions = useMemo(
() => dataGridColumnVisibilityOptions(columns),
[columns],
)
return (
<CrudListPage
@@ -150,31 +309,96 @@ function TariffsPage() {
</div>
}
>
{(snap) => (
<div className="flex flex-col gap-4">
{tariffDiffs.length > 0 ? (
<Alert>
<AlertTitle>Расхождение тариф vs VPS ({tariffDiffs.length})</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
{tariffDiffs.slice(0, 5).map((d) => (
<span key={d.vpsId}>
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
{d.vpsLabel}
</Link>
{' '}({d.tariffName}): {d.issues.join('; ')}
</span>
))}
{tariffDiffs.length > 5 ? <span>и ещё {tariffDiffs.length - 5}</span> : null}
</AlertDescription>
</Alert>
) : null}
<DataGridCard
columns={columnDefFromDataGrid(columns)}
data={snap.activeTariffs}
rowId={(t) => t.id}
{(snap) => {
const zeroResults = hasTariffZeroResults(filters, snap.activeTariffs.length, filteredTariffs.length)
const toolbar = (
<TariffsFiltersToolbar
filters={filters}
onChange={setFilters}
providers={snap.providers}
providerAccounts={snap.providerAccounts}
tariffs={snap.activeTariffs}
countryOptions={countryOptions}
locationOptions={locationOptions}
diskTypeOptions={diskTypeOptions}
currencyOptions={currencyOptions}
shownCount={filteredTariffs.length}
totalCount={snap.activeTariffs.length}
columnVisibilityOptions={columnVisibilityOptions}
columnVisibility={columnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
/>
</div>
)}
)
if (zeroResults) {
return (
<div className="flex flex-col gap-4">
{tariffDiffs.length > 0 ? (
<Alert>
<AlertTitle>Расхождение тариф vs VPS ({tariffDiffs.length})</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
{tariffDiffs.slice(0, 5).map((d) => (
<span key={d.vpsId}>
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
{d.vpsLabel}
</Link>
{' '}({d.tariffName}): {d.issues.join('; ')}
</span>
))}
{tariffDiffs.length > 5 ? <span>и ещё {tariffDiffs.length - 5}</span> : null}
</AlertDescription>
</Alert>
) : null}
{toolbar}
<EmptyState
title="Ничего не найдено"
description="По текущим фильтрам тарифы не найдены"
action={
<Button variant="outline" onClick={() => setFilters(buildDefaultTariffFilters())}>
Сбросить фильтры
</Button>
}
/>
</div>
)
}
return (
<div className="flex flex-col gap-4">
{tariffDiffs.length > 0 ? (
<Alert>
<AlertTitle>Расхождение тариф vs VPS ({tariffDiffs.length})</AlertTitle>
<AlertDescription className="flex flex-col gap-1">
{tariffDiffs.slice(0, 5).map((d) => (
<span key={d.vpsId}>
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
{d.vpsLabel}
</Link>
{' '}({d.tariffName}): {d.issues.join('; ')}
</span>
))}
{tariffDiffs.length > 5 ? <span>и ещё {tariffDiffs.length - 5}</span> : null}
</AlertDescription>
</Alert>
) : null}
{toolbar}
<DataGridCard
columns={columnDefFromDataGrid(columns)}
data={filteredTariffs}
rowId={(t) => t.id}
emptyTitle="Тарифы не найдены"
dense={filters.tableCompact}
virtualization={filteredTariffs.length > 200}
height={560}
enableColumnVisibility
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnVisibilityTrigger={false}
/>
</div>
)
}}
</CrudListPage>
)
}
+6 -6
View File
@@ -24,7 +24,6 @@ import { getPaidUntilDate } from '@/lib/paid-until'
import {
effectiveVpsTariffCurrency,
formatCurrency,
formatInProviderCurrency,
tariffTypeLabel,
vpsStatusLabel,
paymentTypeLabel,
@@ -215,12 +214,13 @@ function VpsDetailPage() {
<InfoRow label="Тип" value={tariffTypeLabel(row.tariffType)} />
<InfoRow
label="Ставка"
value={formatInProviderCurrency(
row.tariffType === 'daily' ? Number(row.dailyRate || 0) * 30 : Number(row.monthlyRate || 0),
value={formatCurrency(
row.monthlyRate != null
? Number(row.monthlyRate)
: row.tariffType === 'daily'
? Number(row.dailyRate || 0) * 30
: Number(row.monthlyRate || 0),
effectiveVpsTariffCurrency(row, provider),
provider,
snapshot?.settings ?? [],
null,
)}
/>
<InfoRow
+14 -7
View File
@@ -6,7 +6,7 @@ import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import type { VpsFormValues } from '@/lib/schemas'
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatInProviderCurrency, vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { normalizeRatesPayload, effectiveVpsTariffCurrency, formatCurrency, vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
@@ -408,15 +408,22 @@ function VpsPage() {
key: 'tariff',
header: 'Тариф',
icon: CreditCardIcon,
sortValue: (v) => (v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)),
sortValue: (v) =>
v.monthlyRate != null
? Number(v.monthlyRate)
: v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: 0,
cell: (v) => {
const provider = providerById.get(v.providerId)
const currency = effectiveVpsTariffCurrency(v, provider)
const amount = v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
return dataGridCellStack(
formatInProviderCurrency(amount, currency, provider, snapshot?.settings ?? [], ratesData),
tariffTypeLabel(v.tariffType),
)
const amount =
v.monthlyRate != null
? Number(v.monthlyRate)
: v.tariffType === 'daily'
? Number(v.dailyRate || 0) * 30
: Number(v.monthlyRate || 0)
return dataGridCellStack(formatCurrency(amount, currency), tariffTypeLabel(v.tariffType))
},
},
{
+4
View File
@@ -133,6 +133,8 @@ export interface Settings {
export interface ActiveTariff {
id: string
providerAccountId: string
providerId?: string
externalId?: string
pricelistId?: string
name?: string
vcpu?: number
@@ -145,6 +147,8 @@ export interface ActiveTariff {
datacenterName?: string
location?: string
country?: string
orderAvailable?: boolean
virtualization?: string
}
export interface SyncLogRow {