diff --git a/apps/api/src/services/tariffs.test.ts b/apps/api/src/services/tariffs.test.ts index b129b60..ebd7d91 100644 --- a/apps/api/src/services/tariffs.test.ts +++ b/apps/api/src/services/tariffs.test.ts @@ -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, + }) }) }) diff --git a/apps/api/src/services/userapi/mappers.test.ts b/apps/api/src/services/userapi/mappers.test.ts index 08bfdee..6baf58f 100644 --- a/apps/api/src/services/userapi/mappers.test.ts +++ b/apps/api/src/services/userapi/mappers.test.ts @@ -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', () => { diff --git a/apps/api/src/services/userapi/mappers.ts b/apps/api/src/services/userapi/mappers.ts index 0c598dc..d55d4fa 100644 --- a/apps/api/src/services/userapi/mappers.ts +++ b/apps/api/src/services/userapi/mappers.ts @@ -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 = { 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, ): { 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, ): 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), diff --git a/apps/api/src/services/userapi/operations.ts b/apps/api/src/services/userapi/operations.ts index 520bcfe..5ca7d0e 100644 --- a/apps/api/src/services/userapi/operations.ts +++ b/apps/api/src/services/userapi/operations.ts @@ -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 { const { baseUrl: url, token } = parseCredentials(baseUrl, credentials) const data = await userApiRequest(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 { + 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 { - 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 { 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)) } } diff --git a/apps/api/src/services/userapi/sync.test.ts b/apps/api/src/services/userapi/sync.test.ts index b8e1efc..f5a9162 100644 --- a/apps/api/src/services/userapi/sync.test.ts +++ b/apps/api/src/services/userapi/sync.test.ts @@ -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() + 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) }) }) diff --git a/apps/api/src/services/userapi/sync.ts b/apps/api/src/services/userapi/sync.ts index 9197c0e..4f6dbc6 100644 --- a/apps/api/src/services/userapi/sync.ts +++ b/apps/api/src/services/userapi/sync.ts @@ -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 diff --git a/apps/web/src/components/tariff-filters-toolbar.tsx b/apps/web/src/components/tariff-filters-toolbar.tsx new file mode 100644 index 0000000..f4911f2 --- /dev/null +++ b/apps/web/src/components/tariff-filters-toolbar.tsx @@ -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 ( +
+ onChange([v ?? 0])} + min={min} + max={max} + size="sm" + > + + + + + + +
+ ) +} + +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 | Filter)[] { + const out: (Filter | Filter)[] = [] + if (state.providerId.length) out.push(createFilter('providerId', 'is_any_of', state.providerId)) + if (state.providerAccountId.length) out.push(createFilter('providerAccountId', 'is_any_of', state.providerAccountId)) + if (state.country.length) out.push(createFilter('country', 'is_any_of', state.country)) + if (state.location.length) out.push(createFilter('location', 'is_any_of', state.location)) + if (state.datacenter) out.push(createFilter('datacenter', 'contains', [state.datacenter])) + if (state.diskType.length) out.push(createFilter('diskType', 'is_any_of', state.diskType)) + if (state.currency.length) out.push(createFilter('currency', 'is_any_of', state.currency)) + if (state.minVcpu != null) out.push(createFilter('minVcpu', 'is', [state.minVcpu])) + if (state.minRamGb != null) out.push(createFilter('minRamGb', 'is', [state.minRamGb])) + if (state.minDiskGb != null) out.push(createFilter('minDiskGb', 'is', [state.minDiskGb])) + if (state.minPrice != null) out.push(createFilter('minPrice', 'is', [state.minPrice])) + if (state.maxPrice != null) out.push(createFilter('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 | Filter)[]>(() => stateToFilters(filters), [filters]) + + const fields = useMemo<(FilterFieldConfig | FilterFieldConfig)[]>(() => { + const count = (pred: (t: ActiveTariff) => boolean) => tariffs.filter(pred).length + + const providerOpts: FilterOption[] = providers.map((p) => ({ + value: p.id, + label: p.name, + metadata: { count: count((t) => getTariffProviderId(t, providerAccounts) === p.id) }, + })) + + const accountOpts: FilterOption[] = providerAccounts.map((a) => ({ + value: a.id, + label: a.name, + metadata: { count: count((t) => t.providerAccountId === a.id) }, + })) + + const countryOpts: FilterOption[] = countryOptions.map((c) => ({ + value: c.value, + label: c.label, + icon: c.code ? : , + metadata: { count: count((t) => (t.country ?? '').trim() === c.value) }, + })) + + const locationOpts: FilterOption[] = locationOptions.map((l) => ({ + value: l.value, + label: l.label, + metadata: { count: count((t) => (t.location ?? '').trim() === l.value) }, + })) + + const diskTypeOpts: FilterOption[] = diskTypeOptions.map((d) => ({ + value: d, + label: d, + metadata: { count: count((t) => (t.diskType ?? '').trim() === d) }, + })) + + const currencyOpts: FilterOption[] = 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) => + renderMinNumberField(0, 32, values, onCh), + }, + { + key: 'minRamGb', + label: 'RAM ≥', + type: 'custom' as const, + defaultOperator: 'is', + operators: [{ value: 'is', label: '≥' }], + customRenderer: ({ values, onChange: onCh }: CustomRendererProps) => + renderMinNumberField(0, 256, values, onCh), + }, + { + key: 'minDiskGb', + label: 'Disk ≥', + type: 'custom' as const, + defaultOperator: 'is', + operators: [{ value: 'is', label: '≥' }], + customRenderer: ({ values, onChange: onCh }: CustomRendererProps) => + renderMinNumberField(0, 2000, values, onCh), + }, + { + key: 'minPrice', + label: 'Цена ≥', + type: 'custom' as const, + defaultOperator: 'is', + operators: [{ value: 'is', label: '≥' }], + customRenderer: ({ values, onChange: onCh }: CustomRendererProps) => + renderMinNumberField(0, 100_000, values, onCh), + }, + { + key: 'maxPrice', + label: 'Цена ≤', + type: 'custom' as const, + defaultOperator: 'is', + operators: [{ value: 'is', label: '≤' }], + customRenderer: ({ values, onChange: onCh }: CustomRendererProps) => + 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 ( + onChange({ ...filters, search }), + placeholder: 'Поиск: название, ID, дата-центр, локация', + name: 'tariffs-search', + }} + controls={ + <> + + + Фильтр + + } + /> + + + + + Вид + + } + /> + +
+
+ + +
+ + {columnVisibilityOptions && columnVisibilityOptions.length > 0 && onColumnVisibilityChange ? ( + <> + +
+ + {columnVisibilityOptions.map((col) => ( + + ))} +
+ + ) : null} +
+
+
+ + } + toggles={ + onChange({ ...filters, hideZeroPrice: !filters.hideZeroPrice })} + /> + } + chips={chips} + shown={shownCount} + total={totalCount} + showReset={hasActive} + onReset={() => onChange(buildDefaultTariffFilters())} + /> + ) +} diff --git a/apps/web/src/components/tariff-filters.test.ts b/apps/web/src/components/tariff-filters.test.ts new file mode 100644 index 0000000..ec63c11 --- /dev/null +++ b/apps/web/src/components/tariff-filters.test.ts @@ -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 => ({ + 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) + }) +}) diff --git a/apps/web/src/components/tariff-filters.ts b/apps/web/src/components/tariff-filters.ts new file mode 100644 index 0000000..a1ed34e --- /dev/null +++ b/apps/web/src/components/tariff-filters.ts @@ -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 = (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) +} diff --git a/apps/web/src/routes/_auth/tariffs.tsx b/apps/web/src/routes/_auth/tariffs.tsx index 760e722..faedbd6 100644 --- a/apps/web/src/routes/_auth/tariffs.tsx +++ b/apps/web/src/routes/_auth/tariffs.tsx @@ -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(buildDefaultTariffFilters()) + const [columnVisibility, setColumnVisibility] = useState(() => ({ + ...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() + 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() + 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() + 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() + 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[] = [ - { - key: 'name', - header: 'Тариф', - icon: ServerIcon, - cell: (t) => {t.name || `#${t.pricelistId ?? t.id}`}, - }, - { - 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[] = useMemo( + () => [ + { + key: 'name', + header: 'Тариф', + icon: ServerIcon, + cell: (t) => ( + {t.name || `#${tariffDisplayId(t)}`} + ), }, - 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) => ( - - {t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB - - ), - }, - { - key: 'price', - header: 'Цена/мес', - icon: CoinsIcon, - headerClassName: 'text-right', - className: 'text-right', - sortValue: (t) => Number(t.monthlyRate ?? 0), - cell: (t) => ( - - {formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')} - - ), - }, - { - key: 'disk', - header: 'Диск', - icon: HardDriveIcon, - cell: (t) => {t.diskType ?? '—'}, - }, - ] + { + key: 'specs', + header: 'Ресурсы', + icon: CpuIcon, + sortValue: (t) => t.vcpu ?? 0, + cell: (t) => ( + + {t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB + + ), + }, + { + key: 'price', + header: 'Цена/мес', + icon: CoinsIcon, + headerClassName: 'text-right', + className: 'text-right', + sortValue: (t) => Number(t.monthlyRate ?? 0), + cell: (t) => ( + + {formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')} + + ), + }, + { + key: 'disk', + header: 'Диск', + icon: HardDriveIcon, + sortValue: (t) => t.diskType ?? '', + cell: (t) => {t.diskType ?? '—'}, + }, + { + key: 'location', + header: 'Локация', + icon: MapPinIcon, + sortValue: (t) => t.location ?? '', + cell: (t) => ( + {t.location ?? '—'} + ), + }, + { + 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 ( + + + {country} + + ) + }, + }, + { + key: 'datacenterName', + header: 'Дата-центр', + icon: MapPinIcon, + sortValue: (t) => t.datacenterName ?? '', + cell: (t) => ( + {t.datacenterName ?? '—'} + ), + }, + ], + [snapshot, providerById], + ) + + const columnVisibilityOptions = useMemo( + () => dataGridColumnVisibilityOptions(columns), + [columns], + ) return ( } > - {(snap) => ( -
- {tariffDiffs.length > 0 ? ( - - Расхождение тариф vs VPS ({tariffDiffs.length}) - - {tariffDiffs.slice(0, 5).map((d) => ( - - - {d.vpsLabel} - - {' '}({d.tariffName}): {d.issues.join('; ')} - - ))} - {tariffDiffs.length > 5 ? …и ещё {tariffDiffs.length - 5} : null} - - - ) : null} - t.id} + {(snap) => { + const zeroResults = hasTariffZeroResults(filters, snap.activeTariffs.length, filteredTariffs.length) + + const toolbar = ( + -
- )} + ) + + if (zeroResults) { + return ( +
+ {tariffDiffs.length > 0 ? ( + + Расхождение тариф vs VPS ({tariffDiffs.length}) + + {tariffDiffs.slice(0, 5).map((d) => ( + + + {d.vpsLabel} + + {' '}({d.tariffName}): {d.issues.join('; ')} + + ))} + {tariffDiffs.length > 5 ? …и ещё {tariffDiffs.length - 5} : null} + + + ) : null} + {toolbar} + setFilters(buildDefaultTariffFilters())}> + Сбросить фильтры + + } + /> +
+ ) + } + + return ( +
+ {tariffDiffs.length > 0 ? ( + + Расхождение тариф vs VPS ({tariffDiffs.length}) + + {tariffDiffs.slice(0, 5).map((d) => ( + + + {d.vpsLabel} + + {' '}({d.tariffName}): {d.issues.join('; ')} + + ))} + {tariffDiffs.length > 5 ? …и ещё {tariffDiffs.length - 5} : null} + + + ) : null} + {toolbar} + t.id} + emptyTitle="Тарифы не найдены" + dense={filters.tableCompact} + virtualization={filteredTariffs.length > 200} + height={560} + enableColumnVisibility + columnVisibility={columnVisibility} + onColumnVisibilityChange={setColumnVisibility} + columnVisibilityTrigger={false} + /> +
+ ) + }}
) } diff --git a/apps/web/src/routes/_auth/vps.$vpsId.tsx b/apps/web/src/routes/_auth/vps.$vpsId.tsx index 6616d40..89937cd 100644 --- a/apps/web/src/routes/_auth/vps.$vpsId.tsx +++ b/apps/web/src/routes/_auth/vps.$vpsId.tsx @@ -24,7 +24,6 @@ import { getPaidUntilDate } from '@/lib/paid-until' import { effectiveVpsTariffCurrency, formatCurrency, - formatInProviderCurrency, tariffTypeLabel, vpsStatusLabel, paymentTypeLabel, @@ -215,12 +214,13 @@ function VpsDetailPage() { (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)) }, }, { diff --git a/apps/web/src/types/entities.ts b/apps/web/src/types/entities.ts index 8eabf71..08e8ab6 100644 --- a/apps/web/src/types/entities.ts +++ b/apps/web/src/types/entities.ts @@ -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 { diff --git a/packages/db/src/repositories/tariffs.ts b/packages/db/src/repositories/tariffs.ts index 4d3ddb5..647c0d9 100644 --- a/packages/db/src/repositories/tariffs.ts +++ b/packages/db/src/repositories/tariffs.ts @@ -10,21 +10,73 @@ export type ActiveTariffDto = Omit & 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 = { + '₽': '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 {