feat(web): иконки заголовков и rich cells во всех таблицах
Lucide-иконки в заголовках, двухстрочные ячейки аккаунтов, флаги локации VPS, favicon хостеров. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon, UserRoundIcon, KeyRoundIcon, PlugIcon, ReceiptIcon, WalletIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
@@ -97,37 +98,45 @@ function AccountsPage() {
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Аккаунт',
|
||||
cell: (a) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{providerById.get(a.providerId)?.name ?? '—'}</span>
|
||||
</div>
|
||||
),
|
||||
icon: UserRoundIcon,
|
||||
cell: (a) => dataGridCellStack(a.name, providerById.get(a.providerId)?.name ?? '—'),
|
||||
},
|
||||
{
|
||||
key: 'login',
|
||||
header: 'Логин',
|
||||
icon: KeyRoundIcon,
|
||||
cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span>,
|
||||
},
|
||||
{ key: 'login', header: 'Логин', cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span> },
|
||||
{
|
||||
key: 'creds',
|
||||
header: 'API-доступ',
|
||||
icon: PlugIcon,
|
||||
cell: (a) => <Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>{a.apiCredentialsSet ? 'установлены' : 'нет'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
header: 'Биллинг',
|
||||
icon: ReceiptIcon,
|
||||
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'Баланс (API)',
|
||||
icon: WalletIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
sortValue: (a) => Number(a.balance_api ?? 0),
|
||||
cell: (a) => {
|
||||
const provider = providerById.get(a.providerId)
|
||||
if (!accountBillmanagerUiReady(a, provider)) return <span className="text-muted-foreground">—</span>
|
||||
const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD'
|
||||
return <span className="tabular-nums">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
|
||||
return <span className="tabular-nums font-medium">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-32 text-right',
|
||||
cell: (a) => {
|
||||
const provider = providerById.get(a.providerId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon, ArrowDownUpIcon } from 'lucide-react'
|
||||
import { PlusIcon, Trash2Icon, ArrowDownUpIcon, CalendarIcon, UserRoundIcon, ArrowLeftRightIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
@@ -73,18 +74,31 @@ function BalancePage() {
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<BalanceLedgerRow>[] = [
|
||||
{ key: 'date', header: 'Дата', cell: (r) => <span className="tabular-nums">{r.date}</span> },
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Дата',
|
||||
icon: CalendarIcon,
|
||||
cell: (r) => <span className="tabular-nums">{r.date}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
icon: UserRoundIcon,
|
||||
sortValue: (r) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === r.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : ''
|
||||
},
|
||||
cell: (r) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === r.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
if (!acc) return '—'
|
||||
const providerName = providerById.get(acc.providerId)?.name ?? '—'
|
||||
return dataGridCellStack(acc.name, providerName)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dir',
|
||||
header: 'Движение',
|
||||
icon: ArrowLeftRightIcon,
|
||||
cell: (r) => (
|
||||
<Badge variant={r.direction === 'credit' ? 'default' : 'destructive'}>
|
||||
<ArrowDownUpIcon data-icon="inline-start" />
|
||||
@@ -95,16 +109,26 @@ function BalancePage() {
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Сумма',
|
||||
icon: CoinsIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
sortValue: (r) => Number(r.amount),
|
||||
cell: (r) => (
|
||||
<span className={`tabular-nums ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
|
||||
<span className={`tabular-nums font-medium ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
|
||||
{r.direction === 'credit' ? '+' : '−'}{formatCurrency(Number(r.amount), r.currency ?? 'RUB')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'note', header: 'Заметка', cell: (r) => <span className="text-muted-foreground">{r.note || '—'}</span> },
|
||||
{
|
||||
key: 'note',
|
||||
header: 'Заметка',
|
||||
icon: StickyNoteIcon,
|
||||
cell: (r) => <span className="text-muted-foreground">{r.note || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-16 text-right',
|
||||
cell: (r) => (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ServerIcon, AlertTriangleIcon, WalletIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
ServerIcon,
|
||||
AlertTriangleIcon,
|
||||
WalletIcon,
|
||||
TrendingUpIcon,
|
||||
HashIcon,
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
FolderKanbanIcon,
|
||||
CircleDotIcon,
|
||||
CoinsIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataGridCard } from '@/components/data-grid-card'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
|
||||
import { computeInventoryHealth } from '@/lib/inventory-health'
|
||||
import { formatInBaseCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||
import { vpsStatusLabel } from '@/lib/format'
|
||||
import { formatInBaseCurrency, normalizeRatesPayload, vpsStatusLabel } from '@/lib/format'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -23,6 +36,8 @@ export const Route = createFileRoute('/_auth/dashboard')({
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
type InventoryIssue = { key: string; title: string; count: number; to: string; hint?: string }
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
@@ -56,6 +71,88 @@ function DashboardPage() {
|
||||
0,
|
||||
)
|
||||
|
||||
const issueColumns: DataTableColumn<InventoryIssue>[] = [
|
||||
{
|
||||
key: 'title',
|
||||
header: 'Проблема',
|
||||
icon: AlertTriangleIcon,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 text-destructive" />
|
||||
{dataGridCellStack(row.title, row.hint)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
header: 'Кол-во',
|
||||
icon: HashIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
cell: (row) => <span className="tabular-nums font-medium">{row.count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Действие',
|
||||
icon: ExternalLinkIcon,
|
||||
sortable: false,
|
||||
cell: (row) => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: row.to })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const vpsColumns: DataTableColumn<Vps>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP / DNS',
|
||||
icon: GlobeIcon,
|
||||
sortValue: (v) => v.ip || v.dns || '',
|
||||
cell: (v) => dataGridCellStack(v.ip || v.dns || '—', v.dns && v.ip ? v.dns : undefined),
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
header: 'Проект',
|
||||
icon: FolderKanbanIcon,
|
||||
cell: (v) => <span className="text-muted-foreground">{v.project || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
icon: CircleDotIcon,
|
||||
cell: (v) => (
|
||||
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
header: 'Ставка/мес',
|
||||
icon: CoinsIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
sortValue: (v) =>
|
||||
v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: Number(v.monthlyRate || 0),
|
||||
cell: (v) => (
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatInBaseCurrency(
|
||||
v.tariffType === 'daily'
|
||||
? Number(v.dailyRate || 0) * 30
|
||||
: Number(v.monthlyRate || 0),
|
||||
v.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCards
|
||||
@@ -95,38 +192,7 @@ function DashboardPage() {
|
||||
<Badge variant="secondary">всё ок</Badge>
|
||||
)
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'title',
|
||||
header: 'Проблема',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 text-destructive" />
|
||||
<div className="flex flex-col">
|
||||
<span>{row.original.title}</span>
|
||||
{row.original.hint ? (
|
||||
<span className="text-xs text-muted-foreground">{row.original.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'count',
|
||||
header: 'Кол-во',
|
||||
cell: ({ row }) => <span className="text-right tabular-nums">{row.original.count}</span>,
|
||||
meta: { align: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
header: 'Действие',
|
||||
cell: ({ row }) => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: row.original.to })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<{ key: string; title: string; count: number; to: string; hint?: string }>[]}
|
||||
columns={columnDefFromDataTable(issueColumns)}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
@@ -136,43 +202,7 @@ function DashboardPage() {
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
columns={[
|
||||
{
|
||||
id: 'ip',
|
||||
header: 'IP / DNS',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.ip || row.original.dns}</span>,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
header: 'Проект',
|
||||
cell: ({ row }) => <span className="text-muted-foreground">{row.original.project || '—'}</span>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'rate',
|
||||
header: 'Ставка/мес',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-right tabular-nums">
|
||||
{formatInBaseCurrency(
|
||||
row.original.tariffType === 'daily'
|
||||
? Number(row.original.dailyRate || 0) * 30
|
||||
: Number(row.original.monthlyRate || 0),
|
||||
row.original.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<typeof activeVps[number]>[]}
|
||||
columns={columnDefFromDataTable(vpsColumns)}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, CalendarIcon, UserRoundIcon, TagIcon, CoinsIcon, StickyNoteIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -11,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
@@ -81,29 +82,55 @@ function PaymentsPage() {
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<Payment>[] = [
|
||||
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Дата',
|
||||
icon: CalendarIcon,
|
||||
cell: (p) => <span className="tabular-nums">{p.date}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
icon: UserRoundIcon,
|
||||
sortValue: (p) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === p.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : ''
|
||||
},
|
||||
cell: (p) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === p.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
if (!acc) return '—'
|
||||
const providerName = providerById.get(acc.providerId)?.name ?? '—'
|
||||
return dataGridCellStack(acc.name, providerName)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Тип',
|
||||
icon: TagIcon,
|
||||
cell: (p) => <span>{paymentTypeLabel(p.type)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Сумма',
|
||||
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
|
||||
icon: CoinsIcon,
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right',
|
||||
cell: (p) => (
|
||||
<span className="tabular-nums font-medium">
|
||||
{formatCurrency(p.amount, p.currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
header: 'Заметка',
|
||||
icon: StickyNoteIcon,
|
||||
cell: (p) => <span className="text-muted-foreground">{p.note || '—'}</span>,
|
||||
},
|
||||
{ key: 'note', header: 'Заметка', cell: (p) => <span className="text-muted-foreground">{p.note || '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-24 text-right',
|
||||
cell: (p) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, BuildingIcon } from 'lucide-react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, BuildingIcon, PlugIcon, CircleDollarSignIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
@@ -86,22 +87,32 @@ function ProvidersPage() {
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Хостер',
|
||||
cell: (p) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{p.website ? (
|
||||
<img src={faviconUrlFromWebsite(p.website)} alt="" className="size-4 rounded-sm" />
|
||||
) : (
|
||||
<BuildingIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{p.name}</span>
|
||||
</div>
|
||||
),
|
||||
icon: BuildingIcon,
|
||||
cell: (p) => {
|
||||
const icon = p.website ? (
|
||||
<img src={faviconUrlFromWebsite(p.website)} alt="" className="size-4 rounded-sm" />
|
||||
) : (
|
||||
<BuildingIcon />
|
||||
)
|
||||
return dataGridCellWithIcon(icon, <span className="font-medium">{p.name}</span>)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'api',
|
||||
header: 'API',
|
||||
icon: PlugIcon,
|
||||
cell: (p) => <Badge variant="outline">{p.apiType}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'cur',
|
||||
header: 'Валюта',
|
||||
icon: CircleDollarSignIcon,
|
||||
cell: (p) => <span className="tabular-nums">{p.baseCurrency ?? '—'}</span>,
|
||||
},
|
||||
{ key: 'api', header: 'API', cell: (p) => <Badge variant="outline">{p.apiType}</Badge> },
|
||||
{ key: 'cur', header: 'Валюта', cell: (p) => <span className="tabular-nums">{p.baseCurrency ?? '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-24 text-right',
|
||||
cell: (p) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
@@ -7,10 +7,11 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ServerCogIcon } from 'lucide-react'
|
||||
import { ServerCogIcon, ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon } from 'lucide-react'
|
||||
|
||||
import type { ActiveTariff } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
@@ -30,19 +31,29 @@ function TariffsPage() {
|
||||
{
|
||||
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) : ''
|
||||
},
|
||||
cell: (t) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
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
|
||||
@@ -52,9 +63,18 @@ function TariffsPage() {
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Цена/мес',
|
||||
cell: (t) => <span className="tabular-nums">{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}</span>,
|
||||
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: 'disk', header: 'Диск', cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge> },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { format, parseISO, isValid } from 'date-fns'
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-table-card'
|
||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
@@ -216,25 +217,58 @@ function VpsPage() {
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP / DNS',
|
||||
cell: (v) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{v.ip || '—'}</span>
|
||||
{v.dns ? <span className="text-xs text-muted-foreground">{v.dns}</span> : null}
|
||||
</div>
|
||||
),
|
||||
icon: GlobeIcon,
|
||||
sortValue: (v) => v.ip || v.dns || '',
|
||||
cell: (v) => dataGridCellStack(v.ip || '—', v.dns || undefined),
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
icon: UserRoundIcon,
|
||||
sortValue: (v) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === v.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : ''
|
||||
},
|
||||
cell: (v) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === v.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
if (!acc) return '—'
|
||||
const providerName = providerById.get(acc.providerId)?.name ?? '—'
|
||||
return dataGridCellStack(acc.name, providerName)
|
||||
},
|
||||
},
|
||||
{ key: 'project', header: 'Проект', cell: (v) => <span className="text-muted-foreground">{v.project || '—'}</span> },
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Локация',
|
||||
icon: MapPinIcon,
|
||||
sortValue: (v) => [v.country, v.city].filter(Boolean).join(', '),
|
||||
cell: (v) => {
|
||||
const country = v.country?.trim()
|
||||
const city = v.city?.trim()
|
||||
if (!country && !city) return <span className="text-muted-foreground">—</span>
|
||||
const countryEntry = country ? COUNTRY_BY_NAME_RU[country] : undefined
|
||||
const flag = countryEntry
|
||||
? getCountryFlagEmojiByCode(countryEntry.code)
|
||||
: country
|
||||
? getCountryFlagEmoji(country)
|
||||
: null
|
||||
const primary = country || city || '—'
|
||||
const secondary = country && city ? city : undefined
|
||||
return flag
|
||||
? dataGridCellWithFlag(flag, primary, secondary)
|
||||
: dataGridCellStack(primary, secondary)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
header: 'Проект',
|
||||
icon: FolderKanbanIcon,
|
||||
cell: (v) => <span className="text-muted-foreground">{v.project || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'specs',
|
||||
header: 'Ресурсы',
|
||||
icon: CpuIcon,
|
||||
sortValue: (v) => v.vcpu,
|
||||
cell: (v) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{v.vcpu} vCPU / {v.ramGb} GB / {v.diskGb} GB
|
||||
@@ -244,6 +278,7 @@ function VpsPage() {
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
icon: CircleDotIcon,
|
||||
cell: (v) => (
|
||||
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
@@ -253,21 +288,22 @@ function VpsPage() {
|
||||
{
|
||||
key: 'tariff',
|
||||
header: 'Тариф',
|
||||
icon: CreditCardIcon,
|
||||
sortValue: (v) => (v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 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 (
|
||||
<span className="tabular-nums">
|
||||
{formatInProviderCurrency(amount, currency, provider, snapshot?.settings ?? [], ratesData)}
|
||||
<span className="ml-1 text-xs text-muted-foreground">{tariffTypeLabel(v.tariffType)}</span>
|
||||
</span>
|
||||
return dataGridCellStack(
|
||||
formatInProviderCurrency(amount, currency, provider, snapshot?.settings ?? [], ratesData),
|
||||
tariffTypeLabel(v.tariffType),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-24 text-right',
|
||||
cell: (v) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
Reference in New Issue
Block a user