From 2e1ca9f2a71a2d66f9525a5fa98a418db0daa123 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 28 Jun 2026 18:40:26 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D1=8B=D0=B5=20KPI-=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=BD=D0=B8=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сделать метрики плотнее и информативнее, вынести общий toolbar фильтров с chips и счётчиком результатов, улучшить читаемость DataGrid через dense и зебру. Co-authored-by: Cursor --- .../components/account-filters-toolbar.tsx | 169 +++++---- apps/web/src/components/account-filters.ts | 15 + apps/web/src/components/data-grid-card.tsx | 6 +- apps/web/src/components/data-grid-cells.tsx | 8 +- apps/web/src/components/data-grid-types.ts | 7 + apps/web/src/components/list-filters-bar.tsx | 171 +++++++++ apps/web/src/components/section-cards.tsx | 36 +- apps/web/src/components/skeletons.tsx | 5 +- .../src/components/vps-filters-toolbar.tsx | 343 ++++++++++++------ apps/web/src/components/vps-filters.tsx | 20 +- apps/web/src/routes/_auth/accounts.tsx | 42 ++- apps/web/src/routes/_auth/balance.tsx | 21 +- apps/web/src/routes/_auth/dashboard.tsx | 16 +- apps/web/src/routes/_auth/reports.tsx | 19 +- apps/web/src/routes/_auth/resources.tsx | 21 +- apps/web/src/routes/_auth/vps.tsx | 5 + 16 files changed, 682 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/components/list-filters-bar.tsx diff --git a/apps/web/src/components/account-filters-toolbar.tsx b/apps/web/src/components/account-filters-toolbar.tsx index 3aee75f..2d83f8b 100644 --- a/apps/web/src/components/account-filters-toolbar.tsx +++ b/apps/web/src/components/account-filters-toolbar.tsx @@ -1,9 +1,11 @@ -import { SearchIcon, XIcon } from 'lucide-react' +import { useMemo } from 'react' -import { Input } from '@cfdm/ui/components/input' -import { Button } from '@cfdm/ui/components/button' -import { Checkbox } from '@cfdm/ui/components/checkbox' import { SelectField } from '@/components/select-field' +import { + ListFiltersBar, + FilterToggleChip, + type FilterChip, +} from '@/components/list-filters-bar' import { type AccountFiltersState, buildDefaultAccountFilters, @@ -16,80 +18,105 @@ interface AccountFiltersToolbarProps { filters: AccountFiltersState onChange: (next: AccountFiltersState) => void providers: Provider[] + shownCount: number + totalCount: number } -export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) { - const active = hasActiveAccountFilters(filters) +export function AccountFiltersToolbar({ + filters, + onChange, + providers, + shownCount, + totalCount, +}: AccountFiltersToolbarProps) { + const chips = useMemo((): FilterChip[] => { + const out: FilterChip[] = [] + if (filters.search.trim()) { + out.push({ + id: 'search', + label: `Поиск: ${filters.search.trim()}`, + onRemove: () => onChange({ ...filters, search: '' }), + }) + } + if (filters.providerIds[0]) { + const provider = providers.find((p) => p.id === filters.providerIds[0]) + out.push({ + id: 'provider', + label: `Хостер: ${provider?.name ?? filters.providerIds[0]}`, + onRemove: () => onChange({ ...filters, providerIds: [] }), + }) + } + if (filters.billingMode) { + out.push({ + id: 'billing', + label: `Биллинг: ${billingModeLabel(filters.billingMode)}`, + onRemove: () => onChange({ ...filters, billingMode: '' }), + }) + } + return out + }, [filters, onChange, providers]) + + const toggle = (key: 'syncableOnly' | 'issuesOnly' | 'lowBalanceOnly') => { + onChange({ ...filters, [key]: !filters[key] }) + } return ( -
-
-
- - onChange({ ...filters, search: e.target.value })} + onChange({ ...filters, search }), + placeholder: 'Поиск по названию или логину', + }} + controls={ + <> + onChange({ ...filters, providerIds: v ? [v] : [] })} + options={providers.map((p) => ({ value: p.id, label: p.name }))} /> -
- onChange({ ...filters, providerIds: v ? [v] : [] })} - options={providers.map((p) => ({ value: p.id, label: p.name }))} - /> - - onChange({ - ...filters, - billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'], - }) - } - options={[ - { value: 'monthly', label: billingModeLabel('monthly') }, - { value: 'daily', label: billingModeLabel('daily') }, - ]} - /> - {active ? ( - - ) : null} -
-
- - - -
-
+ toggle('lowBalanceOnly')} + /> + + } + chips={chips} + shown={shownCount} + total={totalCount} + showReset={hasActiveAccountFilters(filters)} + onReset={() => onChange(buildDefaultAccountFilters())} + /> ) } diff --git a/apps/web/src/components/account-filters.ts b/apps/web/src/components/account-filters.ts index 5d80917..b8db577 100644 --- a/apps/web/src/components/account-filters.ts +++ b/apps/web/src/components/account-filters.ts @@ -36,6 +36,21 @@ export function hasActiveAccountFilters(filters: AccountFiltersState): boolean { ) } +export function matchesAccountFilterPreset( + filters: AccountFiltersState, + preset: Partial, +): boolean { + const expected = { ...buildDefaultAccountFilters(), ...preset } + return ( + filters.search === expected.search && + filters.providerIds.join(',') === expected.providerIds.join(',') && + filters.billingMode === expected.billingMode && + filters.syncableOnly === expected.syncableOnly && + filters.issuesOnly === expected.issuesOnly && + filters.lowBalanceOnly === expected.lowBalanceOnly + ) +} + export function applyAccountFilters( accounts: ProviderAccount[], filters: AccountFiltersState, diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 7623c60..43464c1 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -111,6 +111,7 @@ function DataGridCardBody({ emptyMessage={emptyTitle} tableLayout={{ dense, + stripped: true, rowBorder: true, headerSticky: true, headerBackground: true, @@ -123,6 +124,9 @@ function DataGridCardBody({ rowsDraggable: false, rowsPinnable: false, }} + tableClassNames={{ + header: 'text-xs font-medium text-muted-foreground', + }} > {virtualization ? ( <> @@ -156,7 +160,7 @@ export function DataGridCard({ pagination, pageSize = 10, footerContent, - dense = false, + dense = true, pinLastColumn = false, initialSorting, virtualization = false, diff --git a/apps/web/src/components/data-grid-cells.tsx b/apps/web/src/components/data-grid-cells.tsx index 0d24daf..df705d7 100644 --- a/apps/web/src/components/data-grid-cells.tsx +++ b/apps/web/src/components/data-grid-cells.tsx @@ -8,9 +8,11 @@ export function dataGridCellStack( className?: string, ) { return ( -
- {primary} - {secondary ? {secondary} : null} +
+ {primary} + {secondary ? ( + {secondary} + ) : null}
) } diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts index 196ce96..15bda03 100644 --- a/apps/web/src/components/data-grid-types.ts +++ b/apps/web/src/components/data-grid-types.ts @@ -12,3 +12,10 @@ export interface DataTableColumn { className?: string headerClassName?: string } + +/** Унифицированные классы колонок для DataGridCard. */ +export const COL = { + num: 'w-28 text-right tabular-nums', + date: 'w-32 text-right tabular-nums text-muted-foreground', + actions: 'w-24 text-right', +} as const diff --git a/apps/web/src/components/list-filters-bar.tsx b/apps/web/src/components/list-filters-bar.tsx new file mode 100644 index 0000000..e25f177 --- /dev/null +++ b/apps/web/src/components/list-filters-bar.tsx @@ -0,0 +1,171 @@ +import type { ReactNode } from 'react' +import { SearchIcon, XIcon } from 'lucide-react' + +import { Input } from '@cfdm/ui/components/input' +import { Button } from '@cfdm/ui/components/button' +import { Badge } from '@cfdm/ui/components/badge' +import { cn } from '@cfdm/ui/lib/utils' + +export interface FilterChip { + id: string + label: string + onRemove: () => void +} + +interface ListFiltersSearchProps { + value: string + onChange: (value: string) => void + placeholder: string + className?: string + name?: string + autoComplete?: string + spellCheck?: boolean +} + +export function ListFiltersSearch({ + value, + onChange, + placeholder, + className, + name, + autoComplete = 'off', + spellCheck = false, +}: ListFiltersSearchProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-8" + autoComplete={autoComplete} + name={name} + spellCheck={spellCheck} + /> +
+ ) +} + +export function FilterActiveChips({ chips }: { chips: FilterChip[] }) { + if (chips.length === 0) return null + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ ) +} + +export function FilterResultsCount({ + shown, + total, + suffix, +}: { + shown: number + total: number + suffix?: ReactNode +}) { + return ( +
+ + Показано {shown} из {total} + + {suffix ? {suffix} : null} +
+ ) +} + +export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) { + if (!visible) return null + return ( + + ) +} + +interface FilterToggleChipProps { + label: string + active: boolean + onClick: () => void +} + +export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) { + return ( + + ) +} + +interface ListFiltersBarProps { + search?: ListFiltersSearchProps + controls?: ReactNode + chips?: FilterChip[] + shown?: number + total?: number + resultsSuffix?: ReactNode + onReset?: () => void + showReset?: boolean + toggles?: ReactNode +} + +export function ListFiltersBar({ + search, + controls, + chips, + shown, + total, + resultsSuffix, + onReset, + showReset, + toggles, +}: ListFiltersBarProps) { + const hasMeta = + chips?.length || + (shown != null && total != null) || + resultsSuffix || + (showReset && onReset) + + return ( +
+ {search ? : null} + {controls || toggles ? ( +
+ {controls} + {toggles} + {onReset ? : null} +
+ ) : null} + {hasMeta ? ( +
+ {chips?.length ? : null} + {shown != null && total != null ? ( + + ) : resultsSuffix ? ( +
{resultsSuffix}
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx index d0a65b7..c15645c 100644 --- a/apps/web/src/components/section-cards.tsx +++ b/apps/web/src/components/section-cards.tsx @@ -7,7 +7,9 @@ export interface SectionCardItem { value: string | number | ReactElement hint?: ReactNode icon?: ReactNode + badge?: ReactNode variant?: 'default' | 'warning' | 'destructive' + active?: boolean onClick?: () => void } @@ -23,28 +25,44 @@ function sectionGridClass(count: number): string { if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3' if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4' if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5' - return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6' + return 'sm:grid-cols-2 lg:grid-cols-3' } export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) { return ( -
+
{items.map((item, idx) => { const clickable = Boolean(item.onClick) const content = ( - -
- {item.label} - {item.icon ? {item.icon} : null} + + {item.icon ? ( + + {item.icon} + + ) : null} +
+
+ {item.label} + {item.badge ? {item.badge} : null} +
+
+ {item.value} + {item.hint ? ( + · {item.hint} + ) : null} +
- {item.value} - {item.hint ? {item.hint} : null}
) return ( ({ - label: , - value: , + icon: , + label: , + value: , }))} /> ) diff --git a/apps/web/src/components/vps-filters-toolbar.tsx b/apps/web/src/components/vps-filters-toolbar.tsx index e358790..5f78cf9 100644 --- a/apps/web/src/components/vps-filters-toolbar.tsx +++ b/apps/web/src/components/vps-filters-toolbar.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react' -import { SearchIcon, SlidersHorizontalIcon, SaveIcon, Trash2Icon, XIcon } from 'lucide-react' +import { SlidersHorizontalIcon, SaveIcon, Trash2Icon } from 'lucide-react' -import { Input } from '@cfdm/ui/components/input' import { Button } from '@cfdm/ui/components/button' import { Checkbox } from '@cfdm/ui/components/checkbox' import { Label } from '@cfdm/ui/components/label' @@ -14,6 +13,8 @@ import { import { Slider } from '@cfdm/ui/components/slider' import { PlusIcon } from 'lucide-react' +import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar' + import { Filters, createFilter, @@ -25,6 +26,7 @@ import { import { type VpsFiltersState, buildDefaultVpsFilters, + hasActiveVpsFilters, stateToActiveFilters, loadFilterPresets, saveFilterPresets, @@ -43,6 +45,8 @@ interface VpsFiltersToolbarProps { countryOptions: { value: string; label: string; code?: string }[] cityOptions: { value: string; label: string }[] projectNameOptions: string[] + shownCount: number + totalCount: number } const RU_I18N: FilterI18nConfig = { @@ -165,6 +169,8 @@ export function VpsFiltersToolbar({ countryOptions, cityOptions, projectNameOptions, + shownCount, + totalCount, }: VpsFiltersToolbarProps) { const [presets, setPresets] = useState(() => loadFilterPresets()) @@ -308,7 +314,136 @@ export function VpsFiltersToolbar({ onChange(filtersToState(next, filters)) } - const hasActive = reuiFilters.length > 0 || filters.search || filters.groupByProject || filters.tableCompact + const chips = useMemo((): FilterChip[] => { + const out: FilterChip[] = [] + const providerById = new Map(providers.map((p) => [p.id, p.name])) + const accountById = new Map(providerAccounts.map((a) => [a.id, a.name])) + + if (filters.search) { + out.push({ + id: 'search', + label: `Поиск: ${filters.search}`, + onRemove: () => onChange({ ...filters, search: '' }), + }) + } + if (filters.providerId.length) { + const names = filters.providerId.map((id) => providerById.get(id) ?? id).join(', ') + out.push({ + id: 'providerId', + label: `Хостер: ${names}`, + onRemove: () => onChange({ ...filters, providerId: [] }), + }) + } + if (filters.providerAccountId.length) { + const names = filters.providerAccountId.map((id) => accountById.get(id) ?? id).join(', ') + out.push({ + id: 'providerAccountId', + label: `Аккаунт: ${names}`, + onRemove: () => onChange({ ...filters, providerAccountId: [] }), + }) + } + if (filters.country.length) { + out.push({ + id: 'country', + label: `Страна: ${filters.country.join(', ')}`, + onRemove: () => onChange({ ...filters, country: [] }), + }) + } + if (filters.city.length) { + out.push({ + id: 'city', + label: `Город: ${filters.city.join(', ')}`, + onRemove: () => onChange({ ...filters, city: [] }), + }) + } + if (filters.datacenter) { + out.push({ + id: 'datacenter', + label: `ДЦ: ${filters.datacenter}`, + onRemove: () => onChange({ ...filters, datacenter: '' }), + }) + } + if (filters.status.length) { + out.push({ + id: 'status', + label: `Статус: ${filters.status.map(vpsStatusLabel).join(', ')}`, + onRemove: () => onChange({ ...filters, status: [] }), + }) + } + if (filters.environment.length) { + out.push({ + id: 'environment', + label: `Окружение: ${filters.environment.map(environmentLabel).join(', ')}`, + onRemove: () => onChange({ ...filters, environment: [] }), + }) + } + if (filters.tariffType.length) { + out.push({ + id: 'tariffType', + label: `Тариф: ${filters.tariffType.map(tariffTypeLabel).join(', ')}`, + onRemove: () => onChange({ ...filters, tariffType: [] }), + }) + } + if (filters.monitoring.length) { + out.push({ + id: 'monitoring', + label: `Мониторинг: ${filters.monitoring.join(', ')}`, + onRemove: () => onChange({ ...filters, monitoring: [] }), + }) + } + if (filters.backup.length) { + out.push({ + id: 'backup', + label: `Бэкап: ${filters.backup.join(', ')}`, + onRemove: () => onChange({ ...filters, backup: [] }), + }) + } + if (filters.project.length) { + out.push({ + id: 'project', + label: `Проект: ${filters.project.map((p) => (p === '__none__' ? 'Без проекта' : p)).join(', ')}`, + onRemove: () => onChange({ ...filters, project: [] }), + }) + } + if (filters.minVcpu != null) { + out.push({ + id: 'minVcpu', + label: `vCPU ≥ ${filters.minVcpu}`, + onRemove: () => onChange({ ...filters, minVcpu: null }), + }) + } + if (filters.minRamGb != null) { + out.push({ + id: 'minRamGb', + label: `RAM ≥ ${filters.minRamGb} GB`, + onRemove: () => onChange({ ...filters, minRamGb: null }), + }) + } + if (filters.minDiskGb != null) { + out.push({ + id: 'minDiskGb', + label: `Disk ≥ ${filters.minDiskGb} GB`, + onRemove: () => onChange({ ...filters, minDiskGb: null }), + }) + } + if (filters.groupByProject) { + out.push({ + id: 'groupByProject', + label: 'Группировка по проекту', + onRemove: () => onChange({ ...filters, groupByProject: false }), + }) + } + if (filters.tableCompact) { + out.push({ + id: 'tableCompact', + label: 'Компактная таблица', + onRemove: () => onChange({ ...filters, tableCompact: false }), + }) + } + return out + }, [filters, onChange, providers, providerAccounts]) + + const hasActive = hasActiveVpsFilters(filters) const savePreset = () => { const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`) @@ -331,113 +466,109 @@ export function VpsFiltersToolbar({ const reset = () => onChange(buildDefaultVpsFilters()) return ( -
-
- - onChange({ ...filters, search: e.target.value })} - className="pl-8" - autoComplete="off" - name="vps-inventory-search" - spellCheck={false} - /> -
+ onChange({ ...filters, search }), + placeholder: 'Поиск: IP, DNS, проект, назначение, ОС', + name: 'vps-inventory-search', + }} + controls={ + <> + + + Фильтр + + } + /> -
- - - Фильтр - - } - /> - - - - - Вид - - } - /> - -
-
- - - -
- - - -
-
- - -
- {presets.length === 0 ? ( -

Нет сохранённых пресетов

- ) : ( -
- {presets.map((p) => ( -
- - -
- ))} + } + /> + +
+
+ + +
- )} -
-
- - - {hasActive ? ( - - ) : null} -
-
+ + +
+
+ + +
+ {presets.length === 0 ? ( +

Нет сохранённых пресетов

+ ) : ( +
+ {presets.map((p) => ( +
+ + +
+ ))} +
+ )} +
+
+ + + + } + chips={chips} + shown={shownCount} + total={totalCount} + showReset={hasActive} + onReset={reset} + /> ) } diff --git a/apps/web/src/components/vps-filters.tsx b/apps/web/src/components/vps-filters.tsx index 08f01df..806943d 100644 --- a/apps/web/src/components/vps-filters.tsx +++ b/apps/web/src/components/vps-filters.tsx @@ -219,17 +219,17 @@ export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] { export function countActiveFilters(filters: VpsFiltersState): number { let n = 0 if (filters.search) n++ - n += filters.providerId.length - n += filters.providerAccountId.length - n += filters.country.length - n += filters.city.length + if (filters.providerId.length) n++ + if (filters.providerAccountId.length) n++ + if (filters.country.length) n++ + if (filters.city.length) n++ if (filters.datacenter) n++ - n += filters.status.length - n += filters.environment.length - n += filters.tariffType.length - n += filters.monitoring.length - n += filters.backup.length - n += filters.project.length + if (filters.status.length) n++ + if (filters.environment.length) n++ + if (filters.tariffType.length) n++ + if (filters.monitoring.length) n++ + if (filters.backup.length) n++ + if (filters.project.length) n++ if (filters.minVcpu != null) n++ if (filters.minRamGb != null) n++ if (filters.minDiskGb != null) n++ diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 6e61f8e..64acf1d 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -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: , + active: !health && !hasActiveAccountFilters(filters), + onClick: () => setFilters(defaultFilters), }, { label: 'Готовы к синку', value: syncableCount, - onClick: () => setFilters({ ...buildDefaultAccountFilters(), syncableOnly: true }), + icon: , + active: matchesAccountFilterPreset(filters, { syncableOnly: true }), + onClick: () => setFilters({ ...defaultFilters, syncableOnly: true }), }, { label: 'С проблемами', value: countAccountsWithIssues(accounts, healthCtx), + icon: , 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: , + 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[] = [ { @@ -418,6 +430,8 @@ function AccountsPage() { filters={filters} onChange={setFilters} providers={snapshot.providers} + shownCount={filteredAccounts.length} + totalCount={snapshot.providerAccounts.length} /> ) : null} {health ? : 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) ? ( + + ) : undefined + } />
)} diff --git a/apps/web/src/routes/_auth/balance.tsx b/apps/web/src/routes/_auth/balance.tsx index 4c192c3..4bb46ef 100644 --- a/apps/web/src/routes/_auth/balance.tsx +++ b/apps/web/src/routes/_auth/balance.tsx @@ -10,6 +10,9 @@ import { ArrowLeftRightIcon, CoinsIcon, StickyNoteIcon, + ArrowDownIcon, + ArrowUpIcon, + ScaleIcon, } from 'lucide-react' import { toast } from 'sonner' @@ -190,11 +193,23 @@ function BalancePage() {
, + hint: baseCurrency, + }, + { + label: 'Всего списаний', + value: formatCurrency(totalDebit, baseCurrency), + icon: , + hint: baseCurrency, + }, + { + label: 'Чистый баланс', value: formatCurrency(totalCredit - totalDebit, baseCurrency), + icon: , + hint: `${rows.length} записей`, }, ]} /> diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 52b7243..dd29069 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -127,7 +127,7 @@ function DashboardPage() { icon: ExternalLinkIcon, sortable: false, cell: (row) => ( - ), @@ -195,7 +195,7 @@ function DashboardPage() { header: '', sortable: false, cell: () => ( - ), @@ -240,6 +240,12 @@ function DashboardPage() { value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—', icon: , variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default', + badge: + stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? ( + + < 14 дн + + ) : undefined, onClick: () => navigate({ to: '/accounts' }), }, { @@ -247,6 +253,12 @@ function DashboardPage() { value: stats?.expiringWithin7Days ?? 0, icon: , variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default', + badge: + (stats?.expiringWithin7Days ?? 0) > 0 ? ( + + внимание + + ) : undefined, onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }), }, { diff --git a/apps/web/src/routes/_auth/reports.tsx b/apps/web/src/routes/_auth/reports.tsx index dd940f9..8a29fb1 100644 --- a/apps/web/src/routes/_auth/reports.tsx +++ b/apps/web/src/routes/_auth/reports.tsx @@ -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() { , + hint: 'в валюте VPS', + }, + { + label: 'Платежей', + value: snap.payments.length, + icon: , + }, + { + label: 'Активных VPS', + value: snap.vps.filter((v) => v.status === 'active').length, + icon: , + hint: `из ${snap.vps.length}`, }, - { label: 'Платежей всего', value: snap.payments.length }, - { label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length }, ]} /> diff --git a/apps/web/src/routes/_auth/resources.tsx b/apps/web/src/routes/_auth/resources.tsx index 312e6eb..5308ba7 100644 --- a/apps/web/src/routes/_auth/resources.tsx +++ b/apps/web/src/routes/_auth/resources.tsx @@ -75,9 +75,24 @@ function ResourcesPage() { <> }, - { label: 'RAM (GB)', value: totals.ram, icon: }, - { label: 'Disk (GB)', value: totals.disk, icon: }, + { + label: 'vCPU', + value: totals.vcpu, + icon: , + hint: `${active.length} VPS`, + }, + { + label: 'RAM', + value: totals.ram, + icon: , + hint: 'GB', + }, + { + label: 'Disk', + value: totals.disk, + icon: , + hint: 'GB', + }, ]} /> diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 81b2626..a1c9352 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -461,6 +461,8 @@ function VpsPage() { projectNameOptions={projectNameOptions} countryOptions={filterCountryOptions} cityOptions={filterCityOptions} + shownCount={filteredVps.length} + totalCount={snap.vps.length} /> {tableSections.map((section) => ( v.id} emptyTitle="VPS не найдены" pinLastColumn + dense={filters.tableCompact} enableRowSelection onRowSelectionChange={setSelectedIds} virtualization={section.items.length > 200}