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) => (
-