Валюта VPS берётся из baseCurrency хостера, ставки — из тарифных планов с fallback и parsePlanCost. На списке VPS цены в валюте провайдера. Расширен parseTariffPrice для форматов UserAPI. Добавлены фильтры на странице тарифов. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
6c5eb04fae
commit
031fc3aaa0
14 files changed
+1508
-144
No files matched your search
@@ -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))
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user