Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,12 +2,42 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
|
||||
|
||||
describe('parseTariffPrice', () => {
|
||||
it('parses amount and currency', () => {
|
||||
expect(parseTariffPrice('100.50 RUB')).toEqual({ monthlyRate: 100.5, currency: 'RUB' })
|
||||
expect(parseTariffPrice('12 USD')).toEqual({ monthlyRate: 12, currency: 'USD' })
|
||||
it('parses BILLmanager monthly amount and currency', () => {
|
||||
expect(parseTariffPrice('100.50 RUB')).toEqual({
|
||||
amount: 100.5,
|
||||
monthlyRate: 100.5,
|
||||
currency: 'RUB',
|
||||
period: 'month',
|
||||
})
|
||||
expect(parseTariffPrice('12 USD')).toEqual({
|
||||
amount: 12,
|
||||
monthlyRate: 12,
|
||||
currency: 'USD',
|
||||
period: 'month',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses UserAPI daily formats', () => {
|
||||
expect(parseTariffPrice('1.55 USD/day')).toEqual({
|
||||
amount: 1.55,
|
||||
monthlyRate: 46.5,
|
||||
currency: 'USD',
|
||||
period: 'day',
|
||||
})
|
||||
expect(parseTariffPrice('1.55 ₽/день')).toEqual({
|
||||
amount: 1.55,
|
||||
monthlyRate: 46.5,
|
||||
currency: 'RUB',
|
||||
period: 'day',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty', () => {
|
||||
expect(parseTariffPrice('')).toEqual({ monthlyRate: null, currency: null })
|
||||
expect(parseTariffPrice('')).toEqual({
|
||||
amount: null,
|
||||
monthlyRate: null,
|
||||
currency: null,
|
||||
period: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,7 +49,55 @@ describe('mapServerToVps', () => {
|
||||
expect(vps.notes).toContain('macloud-12345')
|
||||
expect(vps.tariffType).toBe('daily')
|
||||
expect(vps.dailyRate).toBe(1.55)
|
||||
expect(vps.monthlyRate).toBeNull()
|
||||
expect(vps.monthlyRate).toBe(46.5)
|
||||
expect(vps.currency).toBe('RUB')
|
||||
})
|
||||
|
||||
it('parses string plan cost from index', () => {
|
||||
const planIndex = new Map([
|
||||
[
|
||||
'1',
|
||||
{
|
||||
id: 1,
|
||||
name: 'Plan',
|
||||
cost: '2.50',
|
||||
period: 'day',
|
||||
},
|
||||
],
|
||||
])
|
||||
const vps = mapServerToVps(server, 'vdsina', 'prov-1', 'acc-1', planIndex, 'USD')
|
||||
expect(vps.dailyRate).toBe(2.5)
|
||||
expect(vps.currency).toBe('USD')
|
||||
})
|
||||
|
||||
it('falls back to tariff item price when plan index misses', () => {
|
||||
const tariffByPlanId = new Map([
|
||||
[
|
||||
'1',
|
||||
{
|
||||
externalId: '1',
|
||||
datacenterKey: '11',
|
||||
datacenterName: 'Cloud',
|
||||
name: 'Plan',
|
||||
desc: '',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
diskType: 'NVMe',
|
||||
virtualization: 'KVM',
|
||||
channel: '',
|
||||
location: 'Cloud',
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: '3 USD/day',
|
||||
},
|
||||
],
|
||||
])
|
||||
const vps = mapServerToVps(server, 'vdsina', 'prov-1', 'acc-1', undefined, 'USD', tariffByPlanId)
|
||||
expect(vps.dailyRate).toBe(3)
|
||||
expect(vps.monthlyRate).toBe(90)
|
||||
expect(vps.currency).toBe('USD')
|
||||
})
|
||||
|
||||
it('maps monthly plan cost from plan index', () => {
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* UserAPI response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parseTariffPrice } from '@cfdm/db/repositories/tariffs'
|
||||
import type { UserApiType } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
import type { UserApiOperation, UserApiServerDetail, UserApiServerPlan, UserApiPlanCostIndex } from './operations.js'
|
||||
import type {
|
||||
UserApiOperation,
|
||||
UserApiPlanCostIndex,
|
||||
UserApiServerDetail,
|
||||
UserApiServerPlan,
|
||||
UserApiTariffItem,
|
||||
} from './operations.js'
|
||||
import { normalizePlanPeriod, parsePlanCost } from './operations.js'
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
active: 'active',
|
||||
@@ -47,38 +55,78 @@ function calculateConstructorExtraCost(
|
||||
for (const key of ['cpu', 'ram', 'disk'] as const) {
|
||||
const param = plan.params[key]
|
||||
if (!param?.cost) continue
|
||||
const paramCost = parsePlanCost(param.cost) ?? 0
|
||||
const serverRes = serverData[key]
|
||||
const planBase = planData[key]?.value ?? 0
|
||||
const serverTotal = serverRes?.total ?? serverRes?.value ?? planBase
|
||||
const units = Math.max(0, serverTotal - planBase)
|
||||
if (units > 0) extra += units * param.cost
|
||||
if (units > 0) extra += units * paramCost
|
||||
}
|
||||
|
||||
return extra
|
||||
}
|
||||
|
||||
function roundRate(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function ratesFromCost(
|
||||
cost: number,
|
||||
period: 'day' | 'month',
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
|
||||
if (period === 'month') {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(cost) }
|
||||
}
|
||||
const dailyRate = roundRate(cost)
|
||||
return { tariffType: 'daily', dailyRate, monthlyRate: roundRate(dailyRate * 30) }
|
||||
}
|
||||
|
||||
function resolveRatesFromTariffItem(
|
||||
price: string,
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } | null {
|
||||
const parsed = parseTariffPrice(price)
|
||||
if (parsed.amount == null || !Number.isFinite(parsed.amount)) return null
|
||||
|
||||
if (parsed.period === 'day') {
|
||||
const dailyRate = roundRate(parsed.amount)
|
||||
return { tariffType: 'daily', dailyRate, monthlyRate: roundRate(dailyRate * 30) }
|
||||
}
|
||||
|
||||
return {
|
||||
tariffType: 'monthly',
|
||||
dailyRate: null,
|
||||
monthlyRate: roundRate(parsed.amount),
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlanRates(
|
||||
server: UserApiServerDetail,
|
||||
planIndex?: UserApiPlanCostIndex,
|
||||
tariffByPlanId?: Map<string, UserApiTariffItem>,
|
||||
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
|
||||
const planId = server['server-plan']?.id
|
||||
if (planId == null || !planIndex) {
|
||||
if (planId == null) {
|
||||
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
|
||||
const plan = planIndex.get(String(planId))
|
||||
if (!plan || !Number.isFinite(plan.cost)) {
|
||||
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
|
||||
const planKey = String(planId)
|
||||
|
||||
if (planIndex) {
|
||||
const plan = planIndex.get(planKey)
|
||||
const baseCost = plan ? parsePlanCost(plan.cost ?? plan.full_cost) : null
|
||||
if (plan && baseCost != null) {
|
||||
const cost = baseCost + calculateConstructorExtraCost(server, plan)
|
||||
return ratesFromCost(cost, normalizePlanPeriod(plan.period))
|
||||
}
|
||||
}
|
||||
|
||||
const cost = plan.cost + calculateConstructorExtraCost(server, plan)
|
||||
const period = (plan.period || 'day').toLowerCase()
|
||||
|
||||
if (period === 'month') {
|
||||
return { tariffType: 'monthly', dailyRate: null, monthlyRate: cost }
|
||||
const tariff = tariffByPlanId?.get(planKey)
|
||||
if (tariff?.price) {
|
||||
const fromTariff = resolveRatesFromTariffItem(tariff.price)
|
||||
if (fromTariff) return fromTariff
|
||||
}
|
||||
|
||||
return { tariffType: 'daily', dailyRate: cost, monthlyRate: null }
|
||||
return { tariffType: 'daily', dailyRate: null, monthlyRate: null }
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
@@ -133,6 +181,8 @@ export function mapServerToVps(
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
planIndex?: UserApiPlanCostIndex,
|
||||
currency = 'RUB',
|
||||
tariffByPlanId?: Map<string, UserApiTariffItem>,
|
||||
): MappedVps {
|
||||
const { ip, ipv6 } = extractIp(server)
|
||||
const data = server.data ?? {}
|
||||
@@ -149,7 +199,8 @@ export function mapServerToVps(
|
||||
: traffGb > 0
|
||||
? Math.round((traffGb / 1024) * 100) / 100
|
||||
: 0
|
||||
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex)
|
||||
const { tariffType, dailyRate, monthlyRate } = resolvePlanRates(server, planIndex, tariffByPlanId)
|
||||
const resolvedCurrency = (currency || 'RUB').trim().toUpperCase() || 'RUB'
|
||||
|
||||
return {
|
||||
externalId: String(server.id),
|
||||
@@ -178,7 +229,7 @@ export function mapServerToVps(
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType,
|
||||
currency: 'RUB',
|
||||
currency: resolvedCurrency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: dateToIso(server.created),
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface UserApiTariffSpec {
|
||||
}
|
||||
|
||||
export interface UserApiPlanParamCost {
|
||||
cost?: number
|
||||
cost?: number | string
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
@@ -68,8 +68,8 @@ export interface UserApiServerGroup {
|
||||
export interface UserApiServerPlan {
|
||||
id: number
|
||||
name: string
|
||||
cost: number
|
||||
full_cost?: number
|
||||
cost: number | string
|
||||
full_cost?: number | string
|
||||
period?: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
@@ -143,6 +143,33 @@ function parseBalanceAmount(raw: string | number | undefined): number {
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/** Парсит cost/full_cost тарифного плана UserAPI (число или строка). */
|
||||
export function parsePlanCost(raw: string | number | undefined | null): number | null {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
|
||||
const n = Number.parseFloat(String(raw).replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function normalizePlanPeriod(period?: string): 'day' | 'month' {
|
||||
const p = (period || 'day').toLowerCase()
|
||||
if (p === 'month' || p === 'monthly') return 'month'
|
||||
return 'day'
|
||||
}
|
||||
|
||||
/** Формат цены для active_tariffs.price — понятен parseTariffPrice. */
|
||||
export function formatUserApiTariffPrice(
|
||||
cost: number,
|
||||
period: string | undefined,
|
||||
currency: string,
|
||||
): string {
|
||||
const cur = (currency || 'RUB').trim().toUpperCase()
|
||||
if (normalizePlanPeriod(period) === 'day') {
|
||||
return `${cost} ${cur}/day`
|
||||
}
|
||||
return `${cost} ${cur}`
|
||||
}
|
||||
|
||||
function inferDiskType(name: string): string {
|
||||
const upper = name.toUpperCase()
|
||||
if (upper.includes('NVME')) return 'NVMe'
|
||||
@@ -155,10 +182,11 @@ function mapPlanToTariffItem(
|
||||
plan: UserApiServerPlan,
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
currency: string,
|
||||
): UserApiTariffItem {
|
||||
const data = plan.data ?? {}
|
||||
const diskGb = data.disk?.value ?? 0
|
||||
const priceSuffix = plan.period === 'day' ? ' ₽/день' : ' ₽'
|
||||
const cost = parsePlanCost(plan.cost ?? plan.full_cost)
|
||||
const descParts = [plan.description || '']
|
||||
if (plan.has_params) descParts.push('конструктор')
|
||||
return {
|
||||
@@ -177,7 +205,16 @@ function mapPlanToTariffItem(
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: Boolean(plan.active && plan.enable),
|
||||
price: plan.cost != null ? `${plan.cost}${priceSuffix}` : '',
|
||||
price: cost != null ? formatUserApiTariffPrice(cost, plan.period, currency) : '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlanInIndex(plan: UserApiServerPlan): UserApiServerPlan {
|
||||
const cost = parsePlanCost(plan.cost ?? plan.full_cost)
|
||||
return {
|
||||
...plan,
|
||||
cost: cost ?? 0,
|
||||
period: normalizePlanPeriod(plan.period) === 'month' ? 'month' : 'day',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,13 +299,21 @@ export async function fetchDatacenters(
|
||||
return map
|
||||
}
|
||||
|
||||
export async function fetchServerGroups(
|
||||
async function fetchAllServerGroups(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiServerGroup[]> {
|
||||
const { baseUrl: url, token } = parseCredentials(baseUrl, credentials)
|
||||
const data = await userApiRequest<UserApiServerGroup[]>(url, token, '/server-group')
|
||||
return Array.isArray(data) ? data.filter((g) => g.active !== false) : []
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchServerGroups(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiServerGroup[]> {
|
||||
const groups = await fetchAllServerGroups(baseUrl, credentials)
|
||||
return groups.filter((g) => g.active !== false)
|
||||
}
|
||||
|
||||
export async function fetchServerPlans(
|
||||
@@ -285,13 +330,13 @@ export async function fetchPlanCostIndex(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
): Promise<UserApiPlanCostIndex> {
|
||||
const groups = await fetchServerGroups(baseUrl, credentials)
|
||||
const groups = await fetchAllServerGroups(baseUrl, credentials)
|
||||
const index: UserApiPlanCostIndex = new Map()
|
||||
|
||||
for (const group of groups) {
|
||||
const plans = await fetchServerPlans(baseUrl, credentials, group.id)
|
||||
for (const plan of plans) {
|
||||
if (plan?.id != null) index.set(String(plan.id), plan)
|
||||
if (plan?.id != null) index.set(String(plan.id), normalizePlanInIndex(plan))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +346,7 @@ export async function fetchPlanCostIndex(
|
||||
export async function fetchTariffList(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
currency = 'RUB',
|
||||
): Promise<UserApiTariffItem[]> {
|
||||
const groups = await fetchServerGroups(baseUrl, credentials)
|
||||
const items: UserApiTariffItem[] = []
|
||||
@@ -310,7 +356,7 @@ export async function fetchTariffList(
|
||||
const groupKey = String(group.id)
|
||||
for (const plan of plans) {
|
||||
if (plan.active === false || plan.enable === false) continue
|
||||
items.push(mapPlanToTariffItem(plan, groupKey, group.name || ''))
|
||||
items.push(mapPlanToTariffItem(plan, groupKey, group.name || '', currency))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accou
|
||||
import { syncFromUserApi } from './sync.js'
|
||||
import type { UserApiSyncAccount } from './context.js'
|
||||
|
||||
vi.mock('./operations.js', () => ({
|
||||
fetchServersWithDetails: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchTariffList: vi.fn(),
|
||||
fetchPlanCostIndex: vi.fn(),
|
||||
fetchOperations: vi.fn(),
|
||||
}))
|
||||
vi.mock('./operations.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./operations.js')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchServersWithDetails: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchTariffList: vi.fn(),
|
||||
fetchPlanCostIndex: vi.fn(),
|
||||
fetchOperations: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
fetchBalance,
|
||||
@@ -43,6 +47,7 @@ function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
|
||||
balanceUpdatedAt: null,
|
||||
enoughmoneyto: '',
|
||||
balanceAlertBelow: null,
|
||||
providerBaseCurrency: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +89,7 @@ describe('syncFromUserApi', () => {
|
||||
country: '',
|
||||
cpuModel: '',
|
||||
orderAvailable: true,
|
||||
price: '1.55 ₽/день',
|
||||
price: '1.55 RUB/day',
|
||||
},
|
||||
])
|
||||
vi.mocked(fetchPlanCostIndex).mockResolvedValue(
|
||||
@@ -151,7 +156,7 @@ describe('syncFromUserApi', () => {
|
||||
it('syncs vdsina account with vdsina id prefix', async () => {
|
||||
getSqlite()
|
||||
.prepare(
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl) VALUES ('prov-vdsina', 'VDSina', 'vdsina', 'https://userapi.vdsina.com/v1')`,
|
||||
`INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-vdsina', 'VDSina', 'vdsina', 'https://userapi.vdsina.com/v1', 'USD')`,
|
||||
)
|
||||
.run()
|
||||
providerAccountsRepository.create({
|
||||
@@ -161,10 +166,23 @@ describe('syncFromUserApi', () => {
|
||||
apiCredentials: 'secret-token',
|
||||
})
|
||||
|
||||
const result = await syncFromUserApi(makeAccount('vdsina'))
|
||||
const result = await syncFromUserApi({
|
||||
...makeAccount('vdsina'),
|
||||
providerBaseCurrency: 'USD',
|
||||
})
|
||||
|
||||
expect(result.vpsCount).toBe(1)
|
||||
const vps = getSqlite().prepare('SELECT id FROM vps WHERE id = ?').get('vps-vdsina-acc-vdsina-100')
|
||||
const vps = getSqlite()
|
||||
.prepare('SELECT id, currency, dailyRate, monthlyRate FROM vps WHERE id = ?')
|
||||
.get('vps-vdsina-acc-vdsina-100') as {
|
||||
id: string
|
||||
currency: string
|
||||
dailyRate: number
|
||||
monthlyRate: number
|
||||
}
|
||||
expect(vps).toBeTruthy()
|
||||
expect(vps.currency).toBe('USD')
|
||||
expect(vps.dailyRate).toBe(1.55)
|
||||
expect(vps.monthlyRate).toBe(46.5)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,19 +82,31 @@ export async function syncFromUserApi(
|
||||
fetchVpsData
|
||||
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchTariffs
|
||||
? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => [])
|
||||
: [],
|
||||
fetchVpsData ? fetchOperations(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
fetchVpsData
|
||||
? fetchPlanCostIndex(apiBaseUrl, credentials).catch(() => new Map() as UserApiPlanCostIndex)
|
||||
: (new Map() as UserApiPlanCostIndex),
|
||||
])
|
||||
|
||||
const tariffByPlanId = new Map(tariffItems.map((t) => [t.externalId, t]))
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsData) {
|
||||
for (const server of servers) {
|
||||
const vps = mapServerToVps(server, apiType, providerId, accountId, planIndex)
|
||||
const vps = mapServerToVps(
|
||||
server,
|
||||
apiType,
|
||||
providerId,
|
||||
accountId,
|
||||
planIndex,
|
||||
fallbackCurrency,
|
||||
tariffByPlanId,
|
||||
)
|
||||
const id = `vps-${idPrefix}-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,21 +10,73 @@ export type ActiveTariffDto = Omit<Row, 'orderAvailable' | 'ramGb' | 'price'> &
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
/** Парсит строку цены BILLmanager: «100.50 RUB», «€12», «12 USD». */
|
||||
export function parseTariffPrice(price: string | null | undefined): {
|
||||
/** Результат парсинга строки цены тарифа. */
|
||||
export interface ParsedTariffPrice {
|
||||
/** Сумма из строки (суточная для /day, месячная иначе). */
|
||||
amount: number | null
|
||||
monthlyRate: number | null
|
||||
currency: string | null
|
||||
} {
|
||||
period: 'day' | 'month' | null
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
'₽': 'RUB',
|
||||
'€': 'EUR',
|
||||
'$': 'USD',
|
||||
'£': 'GBP',
|
||||
}
|
||||
|
||||
function isDailyTariffPrice(raw: string): boolean {
|
||||
return /\/\s*(?:day|день)/i.test(raw) || /\b(?:day|день)\b/i.test(raw)
|
||||
}
|
||||
|
||||
/** Парсит строку цены: BILLmanager «100.50 RUB», UserAPI «1.55 USD/day», «1.55 ₽/день». */
|
||||
export function parseTariffPrice(price: string | null | undefined): ParsedTariffPrice {
|
||||
const raw = String(price ?? '').trim()
|
||||
if (!raw) return { monthlyRate: null, currency: null }
|
||||
const match = raw.match(/([\d.,]+)\s*([A-Za-z]{3})?/)
|
||||
if (!match) return { monthlyRate: null, currency: null }
|
||||
const monthlyRate = Number.parseFloat(match[1].replace(',', '.'))
|
||||
const currency = match[2]?.toUpperCase() ?? null
|
||||
return {
|
||||
monthlyRate: Number.isFinite(monthlyRate) ? monthlyRate : null,
|
||||
currency,
|
||||
if (!raw) return { amount: null, monthlyRate: null, currency: null, period: null }
|
||||
|
||||
const isDaily = isDailyTariffPrice(raw)
|
||||
|
||||
const isoMatch = raw.match(/([\d.,]+)\s*([A-Za-z]{3})(?:\s*\/\s*(?:day|день))?/i)
|
||||
if (isoMatch) {
|
||||
const amount = Number.parseFloat(isoMatch[1].replace(',', '.'))
|
||||
const currency = isoMatch[2].toUpperCase()
|
||||
const period: 'day' | 'month' = isDaily ? 'day' : 'month'
|
||||
if (!Number.isFinite(amount)) {
|
||||
return { amount: null, monthlyRate: null, currency: null, period: null }
|
||||
}
|
||||
const monthlyRate = period === 'day' ? roundTariffRate(amount * 30) : roundTariffRate(amount)
|
||||
return { amount: roundTariffRate(amount), monthlyRate, currency, period }
|
||||
}
|
||||
|
||||
const symbolMatch = raw.match(/([\d.,]+)\s*([₽€$£])/)
|
||||
if (symbolMatch) {
|
||||
const amount = Number.parseFloat(symbolMatch[1].replace(',', '.'))
|
||||
const currency = CURRENCY_SYMBOLS[symbolMatch[2]] ?? null
|
||||
const period: 'day' | 'month' = isDaily ? 'day' : 'month'
|
||||
if (!Number.isFinite(amount)) {
|
||||
return { amount: null, monthlyRate: null, currency: null, period: null }
|
||||
}
|
||||
const monthlyRate = period === 'day' ? roundTariffRate(amount * 30) : roundTariffRate(amount)
|
||||
return { amount: roundTariffRate(amount), monthlyRate, currency, period }
|
||||
}
|
||||
|
||||
const legacyMatch = raw.match(/([\d.,]+)\s*([A-Za-z]{3})?/)
|
||||
if (legacyMatch) {
|
||||
const amount = Number.parseFloat(legacyMatch[1].replace(',', '.'))
|
||||
const currency = legacyMatch[2]?.toUpperCase() ?? null
|
||||
if (!Number.isFinite(amount)) {
|
||||
return { amount: null, monthlyRate: null, currency: null, period: null }
|
||||
}
|
||||
const monthlyRate = roundTariffRate(amount)
|
||||
return { amount: monthlyRate, monthlyRate, currency, period: 'month' }
|
||||
}
|
||||
|
||||
return { amount: null, monthlyRate: null, currency: null, period: null }
|
||||
}
|
||||
|
||||
function roundTariffRate(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
function toDto(row: Row | undefined): ActiveTariffDto | undefined {
|
||||
|
||||
Reference in New Issue
Block a user