diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 508219b..5a8302e 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -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) => ( -
- {a.name} - {providerById.get(a.providerId)?.name ?? '—'} -
- ), + icon: UserRoundIcon, + cell: (a) => dataGridCellStack(a.name, providerById.get(a.providerId)?.name ?? '—'), + }, + { + key: 'login', + header: 'Логин', + icon: KeyRoundIcon, + cell: (a) => {a.login || '—'}, }, - { key: 'login', header: 'Логин', cell: (a) => {a.login || '—'} }, { key: 'creds', header: 'API-доступ', + icon: PlugIcon, cell: (a) => {a.apiCredentialsSet ? 'установлены' : 'нет'}, }, { key: 'mode', header: 'Биллинг', + icon: ReceiptIcon, cell: (a) => {billingModeLabel(a.billingMode ?? 'monthly')}, }, { 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 const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD' - return {formatCurrency(Number(a.balance_api ?? 0), cur)} + return {formatCurrency(Number(a.balance_api ?? 0), cur)} }, }, { key: 'actions', header: '', + sortable: false, className: 'w-32 text-right', cell: (a) => { const provider = providerById.get(a.providerId) diff --git a/apps/web/src/routes/_auth/balance.tsx b/apps/web/src/routes/_auth/balance.tsx index f242fb9..6d78c44 100644 --- a/apps/web/src/routes/_auth/balance.tsx +++ b/apps/web/src/routes/_auth/balance.tsx @@ -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[] = [ - { key: 'date', header: 'Дата', cell: (r) => {r.date} }, + { + key: 'date', + header: 'Дата', + icon: CalendarIcon, + cell: (r) => {r.date}, + }, { 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) => ( @@ -95,16 +109,26 @@ function BalancePage() { { key: 'amount', header: 'Сумма', + icon: CoinsIcon, + headerClassName: 'text-right', + className: 'text-right', + sortValue: (r) => Number(r.amount), cell: (r) => ( - + {r.direction === 'credit' ? '+' : '−'}{formatCurrency(Number(r.amount), r.currency ?? 'RUB')} ), }, - { key: 'note', header: 'Заметка', cell: (r) => {r.note || '—'} }, + { + key: 'note', + header: 'Заметка', + icon: StickyNoteIcon, + cell: (r) => {r.note || '—'}, + }, { key: 'actions', header: '', + sortable: false, className: 'w-16 text-right', cell: (r) => ( @@ -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[] = [ + { + key: 'title', + header: 'Проблема', + icon: AlertTriangleIcon, + cell: (row) => ( +
+ + {dataGridCellStack(row.title, row.hint)} +
+ ), + }, + { + key: 'count', + header: 'Кол-во', + icon: HashIcon, + headerClassName: 'text-right', + className: 'text-right', + cell: (row) => {row.count}, + }, + { + key: 'action', + header: 'Действие', + icon: ExternalLinkIcon, + sortable: false, + cell: (row) => ( + + ), + }, + ] + + const vpsColumns: DataTableColumn[] = [ + { + 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) => {v.project || '—'}, + }, + { + key: 'status', + header: 'Статус', + icon: CircleDotIcon, + cell: (v) => ( + + {vpsStatusLabel(v.status)} + + ), + }, + { + 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) => ( + + {formatInBaseCurrency( + v.tariffType === 'daily' + ? Number(v.dailyRate || 0) * 30 + : Number(v.monthlyRate || 0), + v.currency, + snap.settings, + ratesData, + )} + + ), + }, + ] + return ( <> всё ок
) } - columns={[ - { - id: 'title', - header: 'Проблема', - cell: ({ row }) => ( -
- -
- {row.original.title} - {row.original.hint ? ( - {row.original.hint} - ) : null} -
-
- ), - }, - { - id: 'count', - header: 'Кол-во', - cell: ({ row }) => {row.original.count}, - meta: { align: 'right' }, - }, - { - id: 'action', - header: 'Действие', - cell: ({ row }) => ( - - ), - }, - ] 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() { {row.original.ip || row.original.dns}, - }, - { - id: 'project', - header: 'Проект', - cell: ({ row }) => {row.original.project || '—'}, - }, - { - id: 'status', - header: 'Статус', - cell: ({ row }) => ( - - {vpsStatusLabel(row.original.status)} - - ), - }, - { - id: 'rate', - header: 'Ставка/мес', - cell: ({ row }) => ( - - {formatInBaseCurrency( - row.original.tariffType === 'daily' - ? Number(row.original.dailyRate || 0) * 30 - : Number(row.original.monthlyRate || 0), - row.original.currency, - snap.settings, - ratesData, - )} - - ), - }, - ] as ColumnDef[]} + columns={columnDefFromDataTable(vpsColumns)} data={activeVps.slice(0, 8)} rowId={(v) => v.id} pagination={false} diff --git a/apps/web/src/routes/_auth/payments.tsx b/apps/web/src/routes/_auth/payments.tsx index 0db0ad9..8889b87 100644 --- a/apps/web/src/routes/_auth/payments.tsx +++ b/apps/web/src/routes/_auth/payments.tsx @@ -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[] = [ - { key: 'date', header: 'Дата', cell: (p) => {p.date} }, + { + key: 'date', + header: 'Дата', + icon: CalendarIcon, + cell: (p) => {p.date}, + }, { 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) => {paymentTypeLabel(p.type)}, }, { key: 'amount', header: 'Сумма', - cell: (p) => {formatCurrency(p.amount, p.currency)}, + icon: CoinsIcon, + headerClassName: 'text-right', + className: 'text-right', + cell: (p) => ( + + {formatCurrency(p.amount, p.currency)} + + ), + }, + { + key: 'note', + header: 'Заметка', + icon: StickyNoteIcon, + cell: (p) => {p.note || '—'}, }, - { key: 'note', header: 'Заметка', cell: (p) => {p.note || '—'} }, { key: 'actions', header: '', + sortable: false, className: 'w-24 text-right', cell: (p) => (
diff --git a/apps/web/src/routes/_auth/providers.tsx b/apps/web/src/routes/_auth/providers.tsx index d75aeb7..07ede40 100644 --- a/apps/web/src/routes/_auth/providers.tsx +++ b/apps/web/src/routes/_auth/providers.tsx @@ -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) => ( -
- {p.website ? ( - - ) : ( - - )} - {p.name} -
- ), + icon: BuildingIcon, + cell: (p) => { + const icon = p.website ? ( + + ) : ( + + ) + return dataGridCellWithIcon(icon, {p.name}) + }, + }, + { + key: 'api', + header: 'API', + icon: PlugIcon, + cell: (p) => {p.apiType}, + }, + { + key: 'cur', + header: 'Валюта', + icon: CircleDollarSignIcon, + cell: (p) => {p.baseCurrency ?? '—'}, }, - { key: 'api', header: 'API', cell: (p) => {p.apiType} }, - { key: 'cur', header: 'Валюта', cell: (p) => {p.baseCurrency ?? '—'} }, { key: 'actions', header: '', + sortable: false, className: 'w-24 text-right', cell: (p) => (
diff --git a/apps/web/src/routes/_auth/tariffs.tsx b/apps/web/src/routes/_auth/tariffs.tsx index 248a9c5..431a0b5 100644 --- a/apps/web/src/routes/_auth/tariffs.tsx +++ b/apps/web/src/routes/_auth/tariffs.tsx @@ -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) => {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) : '' + }, 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) => ( {t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB @@ -52,9 +63,18 @@ function TariffsPage() { { key: 'price', header: 'Цена/мес', - cell: (t) => {formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}, + 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: 'disk', header: 'Диск', cell: (t) => {t.diskType ?? '—'} }, ] return ( diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index c13eb8a..c9e00c5 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -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) => ( -
- {v.ip || '—'} - {v.dns ? {v.dns} : null} -
- ), + 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) => {v.project || '—'} }, + { + 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 + 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) => {v.project || '—'}, + }, { key: 'specs', header: 'Ресурсы', + icon: CpuIcon, + sortValue: (v) => v.vcpu, cell: (v) => ( {v.vcpu} vCPU / {v.ramGb} GB / {v.diskGb} GB @@ -244,6 +278,7 @@ function VpsPage() { { key: 'status', header: 'Статус', + icon: CircleDotIcon, cell: (v) => ( {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 ( - - {formatInProviderCurrency(amount, currency, provider, snapshot?.settings ?? [], ratesData)} - {tariffTypeLabel(v.tariffType)} - + return dataGridCellStack( + formatInProviderCurrency(amount, currency, provider, snapshot?.settings ?? [], ratesData), + tariffTypeLabel(v.tariffType), ) }, }, { key: 'actions', header: '', + sortable: false, className: 'w-24 text-right', cell: (v) => (