feat(web): компактные KPI-карточки и унификация фильтров таблиц
Docker / build (push) Has been cancelled

Сделать метрики плотнее и информативнее, вынести общий toolbar фильтров с chips и счётчиком результатов, улучшить читаемость DataGrid через dense и зебру.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-06-28 18:40:26 +07:00
1 parent c80090ed08
commit 2e1ca9f2a7
16 files changed
+682 -222

No files matched your search

+34 -8
View File
@@ -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>
)}
+18 -3
View File
@@ -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} записей`,
},
]}
/>
+14 -2
View File
@@ -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">
&lt; 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' } }),
},
{
+15 -4
View File
@@ -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>
+18 -3
View File
@@ -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',
},
]}
/>
+5
View File
@@ -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}