Files
vps-tracker/apps/web/src/routes/_auth/tariffs.tsx
T
DenozordecandCursor 031fc3aaa0
Docker / build (push) Has been cancelled
fix(userapi): валюта и ставки Macloud/VDSina при синке
Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 02:48:04 +07:00

405 lines
14 KiB
TypeScript

import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
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,
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,
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 } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
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],
)
const syncTariffsMut = useMutation({
mutationFn: async () => {
if (!snapshot) return { tariffsCount: 0 }
const accounts = billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers)
let tariffsCount = 0
for (const a of accounts) {
const res = (await api.syncAccount(a.id)) as {
synced?: { tariffsCount?: number }
}
tariffsCount += res?.synced?.tariffsCount ?? 0
}
return { tariffsCount, accounts: accounts.length }
},
onSuccess: ({ tariffsCount, accounts }) => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
if (accounts === 0) {
toast.error('Нет аккаунтов BILLmanager с настроенным API')
return
}
toast.success(`Загружено тарифов: ${tariffsCount}`)
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка загрузки тарифов'),
})
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>
),
},
{
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,
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
title="Активные тарифы"
description="Тарифы, загруженные из BILLmanager vds.order"
actions={
syncableCount > 0 ? (
<Button variant="outline" disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
<RefreshCwIcon data-icon="inline-start" />
Загрузить тарифы
</Button>
) : undefined
}
data={snapshot}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
empty={snapshot?.activeTariffs.length === 0}
emptyTitle="Тарифы не загружены"
emptyDescription="Синхронизация аккаунта BILLmanager загружает тарифы вместе с VPS и платежами"
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
{syncableCount > 0 ? (
<Button disabled={syncTariffsMut.isPending} onClick={() => syncTariffsMut.mutate()}>
<RefreshCwIcon data-icon="inline-start" />
Загрузить тарифы
</Button>
) : null}
<Button variant="outline" render={<Link to="/accounts" />}>
Перейти к аккаунтам
</Button>
</div>
}
>
{(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}
/>
)
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>
)
}