feat(web): компактные KPI-карточки и унификация фильтров таблиц
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Сделать метрики плотнее и информативнее, вынести общий toolbar фильтров с chips и счётчиком результатов, улучшить читаемость DataGrid через dense и зебру. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
c80090ed08
commit
2e1ca9f2a7
16 files changed
+682
-222
No files matched your search
@@ -53,6 +53,8 @@ import {
|
||||
import {
|
||||
applyAccountFilters,
|
||||
buildDefaultAccountFilters,
|
||||
hasActiveAccountFilters,
|
||||
matchesAccountFilterPreset,
|
||||
type AccountFiltersState,
|
||||
} from '@/components/account-filters'
|
||||
import { AccountFiltersToolbar } from '@/components/account-filters-toolbar'
|
||||
@@ -213,31 +215,41 @@ function AccountsPage() {
|
||||
if (!snapshot) return []
|
||||
const accounts = snapshot.providerAccounts
|
||||
const atRisk = buildAtRiskAccounts(accounts, snapshot.providers, snapshot.syncLog ?? [])
|
||||
const lowBalanceCount = countLowBalanceAccounts(accounts, healthCtx)
|
||||
const defaultFilters = buildDefaultAccountFilters()
|
||||
return [
|
||||
{
|
||||
label: 'Всего аккаунтов',
|
||||
value: accounts.length,
|
||||
onClick: () => setFilters(buildDefaultAccountFilters()),
|
||||
icon: <UserRoundIcon className="size-4" />,
|
||||
active: !health && !hasActiveAccountFilters(filters),
|
||||
onClick: () => setFilters(defaultFilters),
|
||||
},
|
||||
{
|
||||
label: 'Готовы к синку',
|
||||
value: syncableCount,
|
||||
onClick: () => setFilters({ ...buildDefaultAccountFilters(), syncableOnly: true }),
|
||||
icon: <RefreshCwIcon className="size-4" />,
|
||||
active: matchesAccountFilterPreset(filters, { syncableOnly: true }),
|
||||
onClick: () => setFilters({ ...defaultFilters, syncableOnly: true }),
|
||||
},
|
||||
{
|
||||
label: 'С проблемами',
|
||||
value: countAccountsWithIssues(accounts, healthCtx),
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
variant: atRisk.length ? ('warning' as const) : ('default' as const),
|
||||
onClick: () => setFilters({ ...buildDefaultAccountFilters(), issuesOnly: true }),
|
||||
active: matchesAccountFilterPreset(filters, { issuesOnly: true }),
|
||||
onClick: () => setFilters({ ...defaultFilters, issuesOnly: true }),
|
||||
},
|
||||
{
|
||||
label: 'Низкий баланс',
|
||||
value: countLowBalanceAccounts(accounts, healthCtx),
|
||||
variant: countLowBalanceAccounts(accounts, healthCtx) ? ('destructive' as const) : ('default' as const),
|
||||
onClick: () => setFilters({ ...buildDefaultAccountFilters(), lowBalanceOnly: true }),
|
||||
value: lowBalanceCount,
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
variant: lowBalanceCount ? ('destructive' as const) : ('default' as const),
|
||||
active: matchesAccountFilterPreset(filters, { lowBalanceOnly: true }),
|
||||
onClick: () => setFilters({ ...defaultFilters, lowBalanceOnly: true }),
|
||||
},
|
||||
]
|
||||
}, [snapshot, syncableCount, healthCtx])
|
||||
}, [snapshot, syncableCount, healthCtx, filters, health])
|
||||
|
||||
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||
{
|
||||
@@ -418,6 +430,8 @@ function AccountsPage() {
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
providers={snapshot.providers}
|
||||
shownCount={filteredAccounts.length}
|
||||
totalCount={snapshot.providerAccounts.length}
|
||||
/>
|
||||
) : null}
|
||||
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||
@@ -426,7 +440,19 @@ function AccountsPage() {
|
||||
data={filteredAccounts}
|
||||
rowId={(a) => a.id}
|
||||
pinLastColumn
|
||||
emptyTitle={health || filters.search ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
|
||||
emptyTitle={health || hasActiveAccountFilters(filters) ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
|
||||
emptyDescription={
|
||||
health || hasActiveAccountFilters(filters)
|
||||
? 'Измените фильтры или сбросьте их'
|
||||
: undefined
|
||||
}
|
||||
emptyAction={
|
||||
health || hasActiveAccountFilters(filters) ? (
|
||||
<Button variant="outline" onClick={() => setFilters(buildDefaultAccountFilters())}>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
ArrowLeftRightIcon,
|
||||
CoinsIcon,
|
||||
StickyNoteIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ScaleIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -190,11 +193,23 @@ function BalancePage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionCards
|
||||
items={[
|
||||
{ label: 'Всего приходов', value: formatCurrency(totalCredit, baseCurrency) },
|
||||
{ label: 'Всего списаний', value: formatCurrency(totalDebit, baseCurrency) },
|
||||
{
|
||||
label: 'Чистый баланс (ledger)',
|
||||
label: 'Всего приходов',
|
||||
value: formatCurrency(totalCredit, baseCurrency),
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
hint: baseCurrency,
|
||||
},
|
||||
{
|
||||
label: 'Всего списаний',
|
||||
value: formatCurrency(totalDebit, baseCurrency),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
hint: baseCurrency,
|
||||
},
|
||||
{
|
||||
label: 'Чистый баланс',
|
||||
value: formatCurrency(totalCredit - totalDebit, baseCurrency),
|
||||
icon: <ScaleIcon className="size-4" />,
|
||||
hint: `${rows.length} записей`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -127,7 +127,7 @@ function DashboardPage() {
|
||||
icon: ExternalLinkIcon,
|
||||
sortable: false,
|
||||
cell: (row) => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: row.to })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: row.to })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
@@ -195,7 +195,7 @@ function DashboardPage() {
|
||||
header: '',
|
||||
sortable: false,
|
||||
cell: () => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/accounts' })}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts' })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
@@ -240,6 +240,12 @@ function DashboardPage() {
|
||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
||||
badge:
|
||||
stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
< 14 дн
|
||||
</Badge>
|
||||
) : undefined,
|
||||
onClick: () => navigate({ to: '/accounts' }),
|
||||
},
|
||||
{
|
||||
@@ -247,6 +253,12 @@ function DashboardPage() {
|
||||
value: stats?.expiringWithin7Days ?? 0,
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default',
|
||||
badge:
|
||||
(stats?.expiringWithin7Days ?? 0) > 0 ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
внимание
|
||||
</Badge>
|
||||
) : undefined,
|
||||
onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { DownloadIcon } from 'lucide-react'
|
||||
import { DownloadIcon, TrendingUpIcon, CreditCardIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -75,11 +75,22 @@ function ReportsPage() {
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Расход/мес (в валюте VPS)',
|
||||
label: 'Расход/мес',
|
||||
value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB'),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
hint: 'в валюте VPS',
|
||||
},
|
||||
{
|
||||
label: 'Платежей',
|
||||
value: snap.payments.length,
|
||||
icon: <CreditCardIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: 'Активных VPS',
|
||||
value: snap.vps.filter((v) => v.status === 'active').length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: `из ${snap.vps.length}`,
|
||||
},
|
||||
{ label: 'Платежей всего', value: snap.payments.length },
|
||||
{ label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length },
|
||||
]}
|
||||
/>
|
||||
<ChartsGrid>
|
||||
|
||||
@@ -75,9 +75,24 @@ function ResourcesPage() {
|
||||
<>
|
||||
<SectionCards
|
||||
items={[
|
||||
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
|
||||
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
|
||||
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
|
||||
{
|
||||
label: 'vCPU',
|
||||
value: totals.vcpu,
|
||||
icon: <CpuIcon className="size-4" />,
|
||||
hint: `${active.length} VPS`,
|
||||
},
|
||||
{
|
||||
label: 'RAM',
|
||||
value: totals.ram,
|
||||
icon: <MemoryStickIcon className="size-4" />,
|
||||
hint: 'GB',
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
value: totals.disk,
|
||||
icon: <HardDriveIcon className="size-4" />,
|
||||
hint: 'GB',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -461,6 +461,8 @@ function VpsPage() {
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={filterCountryOptions}
|
||||
cityOptions={filterCityOptions}
|
||||
shownCount={filteredVps.length}
|
||||
totalCount={snap.vps.length}
|
||||
/>
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
@@ -495,6 +497,8 @@ function VpsPage() {
|
||||
projectNameOptions={projectNameOptions}
|
||||
countryOptions={filterCountryOptions}
|
||||
cityOptions={filterCityOptions}
|
||||
shownCount={filteredVps.length}
|
||||
totalCount={snap.vps.length}
|
||||
/>
|
||||
{tableSections.map((section) => (
|
||||
<DataGridCard
|
||||
@@ -505,6 +509,7 @@ function VpsPage() {
|
||||
rowId={(v) => v.id}
|
||||
emptyTitle="VPS не найдены"
|
||||
pinLastColumn
|
||||
dense={filters.tableCompact}
|
||||
enableRowSelection
|
||||
onRowSelectionChange={setSelectedIds}
|
||||
virtualization={section.items.length > 200}
|
||||
|
||||
Reference in new issue
Block a user