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,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 { SelectField } from '@/components/select-field'
|
||||||
|
import {
|
||||||
|
ListFiltersBar,
|
||||||
|
FilterToggleChip,
|
||||||
|
type FilterChip,
|
||||||
|
} from '@/components/list-filters-bar'
|
||||||
import {
|
import {
|
||||||
type AccountFiltersState,
|
type AccountFiltersState,
|
||||||
buildDefaultAccountFilters,
|
buildDefaultAccountFilters,
|
||||||
@@ -16,80 +18,105 @@ interface AccountFiltersToolbarProps {
|
|||||||
filters: AccountFiltersState
|
filters: AccountFiltersState
|
||||||
onChange: (next: AccountFiltersState) => void
|
onChange: (next: AccountFiltersState) => void
|
||||||
providers: Provider[]
|
providers: Provider[]
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountFiltersToolbar({ filters, onChange, providers }: AccountFiltersToolbarProps) {
|
export function AccountFiltersToolbar({
|
||||||
const active = hasActiveAccountFilters(filters)
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<ListFiltersBar
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
search={{
|
||||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-xs">
|
value: filters.search,
|
||||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
onChange: (search) => onChange({ ...filters, search }),
|
||||||
<Input
|
placeholder: 'Поиск по названию или логину',
|
||||||
className="pl-8"
|
}}
|
||||||
placeholder="Поиск по названию или логину"
|
controls={
|
||||||
value={filters.search}
|
<>
|
||||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
<SelectField
|
||||||
|
triggerClassName="w-full sm:w-48"
|
||||||
|
placeholder="Все хостеры"
|
||||||
|
value={filters.providerIds[0] ?? null}
|
||||||
|
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
|
||||||
|
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<SelectField
|
||||||
<SelectField
|
triggerClassName="w-full sm:w-40"
|
||||||
triggerClassName="w-full sm:w-48"
|
placeholder="Любой биллинг"
|
||||||
placeholder="Все хостеры"
|
value={filters.billingMode || null}
|
||||||
value={filters.providerIds[0] ?? null}
|
onValueChange={(v) =>
|
||||||
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
|
onChange({
|
||||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
...filters,
|
||||||
/>
|
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
|
||||||
<SelectField
|
})
|
||||||
triggerClassName="w-full sm:w-40"
|
}
|
||||||
placeholder="Любой биллинг"
|
options={[
|
||||||
value={filters.billingMode || null}
|
{ value: 'monthly', label: billingModeLabel('monthly') },
|
||||||
onValueChange={(v) =>
|
{ value: 'daily', label: billingModeLabel('daily') },
|
||||||
onChange({
|
]}
|
||||||
...filters,
|
|
||||||
billingMode: (v === 'daily' || v === 'monthly' ? v : '') as AccountFiltersState['billingMode'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
options={[
|
|
||||||
{ value: 'monthly', label: billingModeLabel('monthly') },
|
|
||||||
{ value: 'daily', label: billingModeLabel('daily') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{active ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onChange(buildDefaultAccountFilters())}
|
|
||||||
>
|
|
||||||
<XIcon data-icon="inline-start" />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<Checkbox
|
|
||||||
checked={filters.syncableOnly}
|
|
||||||
onCheckedChange={(v) => onChange({ ...filters, syncableOnly: v === true })}
|
|
||||||
/>
|
/>
|
||||||
<span>Готовы к синку</span>
|
</>
|
||||||
</label>
|
}
|
||||||
<label className="flex items-center gap-2 text-sm">
|
toggles={
|
||||||
<Checkbox
|
<>
|
||||||
checked={filters.issuesOnly}
|
<FilterToggleChip
|
||||||
onCheckedChange={(v) => onChange({ ...filters, issuesOnly: v === true })}
|
label="Готовы к синку"
|
||||||
|
active={filters.syncableOnly}
|
||||||
|
onClick={() => toggle('syncableOnly')}
|
||||||
/>
|
/>
|
||||||
<span>С проблемами</span>
|
<FilterToggleChip
|
||||||
</label>
|
label="С проблемами"
|
||||||
<label className="flex items-center gap-2 text-sm">
|
active={filters.issuesOnly}
|
||||||
<Checkbox
|
onClick={() => toggle('issuesOnly')}
|
||||||
checked={filters.lowBalanceOnly}
|
|
||||||
onCheckedChange={(v) => onChange({ ...filters, lowBalanceOnly: v === true })}
|
|
||||||
/>
|
/>
|
||||||
<span>Низкий баланс</span>
|
<FilterToggleChip
|
||||||
</label>
|
label="Низкий баланс"
|
||||||
</div>
|
active={filters.lowBalanceOnly}
|
||||||
</div>
|
onClick={() => toggle('lowBalanceOnly')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
chips={chips}
|
||||||
|
shown={shownCount}
|
||||||
|
total={totalCount}
|
||||||
|
showReset={hasActiveAccountFilters(filters)}
|
||||||
|
onReset={() => onChange(buildDefaultAccountFilters())}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ export function hasActiveAccountFilters(filters: AccountFiltersState): boolean {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchesAccountFilterPreset(
|
||||||
|
filters: AccountFiltersState,
|
||||||
|
preset: Partial<AccountFiltersState>,
|
||||||
|
): 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(
|
export function applyAccountFilters(
|
||||||
accounts: ProviderAccount[],
|
accounts: ProviderAccount[],
|
||||||
filters: AccountFiltersState,
|
filters: AccountFiltersState,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function DataGridCardBody<TData extends object>({
|
|||||||
emptyMessage={emptyTitle}
|
emptyMessage={emptyTitle}
|
||||||
tableLayout={{
|
tableLayout={{
|
||||||
dense,
|
dense,
|
||||||
|
stripped: true,
|
||||||
rowBorder: true,
|
rowBorder: true,
|
||||||
headerSticky: true,
|
headerSticky: true,
|
||||||
headerBackground: true,
|
headerBackground: true,
|
||||||
@@ -123,6 +124,9 @@ function DataGridCardBody<TData extends object>({
|
|||||||
rowsDraggable: false,
|
rowsDraggable: false,
|
||||||
rowsPinnable: false,
|
rowsPinnable: false,
|
||||||
}}
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
header: 'text-xs font-medium text-muted-foreground',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{virtualization ? (
|
{virtualization ? (
|
||||||
<>
|
<>
|
||||||
@@ -156,7 +160,7 @@ export function DataGridCard<TData extends object>({
|
|||||||
pagination,
|
pagination,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
footerContent,
|
footerContent,
|
||||||
dense = false,
|
dense = true,
|
||||||
pinLastColumn = false,
|
pinLastColumn = false,
|
||||||
initialSorting,
|
initialSorting,
|
||||||
virtualization = false,
|
virtualization = false,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export function dataGridCellStack(
|
|||||||
className?: string,
|
className?: string,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
|
||||||
<span className="font-medium">{primary}</span>
|
<span className="truncate font-medium">{primary}</span>
|
||||||
{secondary ? <span className="text-xs text-muted-foreground">{secondary}</span> : null}
|
{secondary ? (
|
||||||
|
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,10 @@ export interface DataTableColumn<T> {
|
|||||||
className?: string
|
className?: string
|
||||||
headerClassName?: 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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={cn('relative w-full', className)}>
|
||||||
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
name={name}
|
||||||
|
spellCheck={spellCheck}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterActiveChips({ chips }: { chips: FilterChip[] }) {
|
||||||
|
if (chips.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Badge key={chip.id} variant="secondary" className="gap-1 pr-1 font-normal">
|
||||||
|
<span className="max-w-[12rem] truncate">{chip.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={chip.onRemove}
|
||||||
|
className="rounded-sm p-0.5 hover:bg-muted"
|
||||||
|
aria-label={`Убрать фильтр: ${chip.label}`}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterResultsCount({
|
||||||
|
shown,
|
||||||
|
total,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
shown: number
|
||||||
|
total: number
|
||||||
|
suffix?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Показано {shown} из {total}
|
||||||
|
</span>
|
||||||
|
{suffix ? <span className="text-muted-foreground/80">{suffix}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterResetButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
|
||||||
|
if (!visible) return null
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onClick}>
|
||||||
|
<XIcon data-icon="inline-start" />
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterToggleChipProps {
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterToggleChip({ label, active, onClick }: FilterToggleChipProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={active ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(active && 'border-primary/40')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{search ? <ListFiltersSearch {...search} /> : null}
|
||||||
|
{controls || toggles ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{controls}
|
||||||
|
{toggles}
|
||||||
|
{onReset ? <FilterResetButton onClick={onReset} visible={Boolean(showReset)} /> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasMeta ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{chips?.length ? <FilterActiveChips chips={chips} /> : null}
|
||||||
|
{shown != null && total != null ? (
|
||||||
|
<FilterResultsCount shown={shown} total={total} suffix={resultsSuffix} />
|
||||||
|
) : resultsSuffix ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{resultsSuffix}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ export interface SectionCardItem {
|
|||||||
value: string | number | ReactElement
|
value: string | number | ReactElement
|
||||||
hint?: ReactNode
|
hint?: ReactNode
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
badge?: ReactNode
|
||||||
variant?: 'default' | 'warning' | 'destructive'
|
variant?: 'default' | 'warning' | 'destructive'
|
||||||
|
active?: boolean
|
||||||
onClick?: () => void
|
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 === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
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'
|
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 }) {
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4', sectionGridClass(items.length), className)}>
|
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
const clickable = Boolean(item.onClick)
|
const clickable = Boolean(item.onClick)
|
||||||
const content = (
|
const content = (
|
||||||
<CardContent className="flex flex-col gap-1 p-4">
|
<CardContent className="flex items-start gap-2.5 px-3 py-2.5">
|
||||||
<div className="flex items-center justify-between">
|
{item.icon ? (
|
||||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||||
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||||
|
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
|
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
|
||||||
|
{item.hint ? (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
|
|
||||||
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={typeof item.label === 'string' ? item.label : idx}
|
key={typeof item.label === 'string' ? item.label : idx}
|
||||||
className={cn('gap-0', VARIANT_CLASS[item.variant ?? 'default'], clickable && 'cursor-pointer transition-colors hover:bg-muted/40')}
|
className={cn(
|
||||||
|
'gap-0',
|
||||||
|
VARIANT_CLASS[item.variant ?? 'default'],
|
||||||
|
item.active && 'border-primary ring-1 ring-primary/30',
|
||||||
|
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||||
|
)}
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
role={clickable ? 'button' : undefined}
|
role={clickable ? 'button' : undefined}
|
||||||
tabIndex={clickable ? 0 : undefined}
|
tabIndex={clickable ? 0 : undefined}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
|||||||
return (
|
return (
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={Array.from({ length: count }, (_, i) => ({
|
items={Array.from({ length: count }, (_, i) => ({
|
||||||
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
|
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
||||||
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
|
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
||||||
|
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
import { Label } from '@cfdm/ui/components/label'
|
import { Label } from '@cfdm/ui/components/label'
|
||||||
@@ -14,6 +13,8 @@ import {
|
|||||||
import { Slider } from '@cfdm/ui/components/slider'
|
import { Slider } from '@cfdm/ui/components/slider'
|
||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Filters,
|
Filters,
|
||||||
createFilter,
|
createFilter,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
type VpsFiltersState,
|
type VpsFiltersState,
|
||||||
buildDefaultVpsFilters,
|
buildDefaultVpsFilters,
|
||||||
|
hasActiveVpsFilters,
|
||||||
stateToActiveFilters,
|
stateToActiveFilters,
|
||||||
loadFilterPresets,
|
loadFilterPresets,
|
||||||
saveFilterPresets,
|
saveFilterPresets,
|
||||||
@@ -43,6 +45,8 @@ interface VpsFiltersToolbarProps {
|
|||||||
countryOptions: { value: string; label: string; code?: string }[]
|
countryOptions: { value: string; label: string; code?: string }[]
|
||||||
cityOptions: { value: string; label: string }[]
|
cityOptions: { value: string; label: string }[]
|
||||||
projectNameOptions: string[]
|
projectNameOptions: string[]
|
||||||
|
shownCount: number
|
||||||
|
totalCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const RU_I18N: FilterI18nConfig = {
|
const RU_I18N: FilterI18nConfig = {
|
||||||
@@ -165,6 +169,8 @@ export function VpsFiltersToolbar({
|
|||||||
countryOptions,
|
countryOptions,
|
||||||
cityOptions,
|
cityOptions,
|
||||||
projectNameOptions,
|
projectNameOptions,
|
||||||
|
shownCount,
|
||||||
|
totalCount,
|
||||||
}: VpsFiltersToolbarProps) {
|
}: VpsFiltersToolbarProps) {
|
||||||
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
const [presets, setPresets] = useState<VpsFilterPreset[]>(() => loadFilterPresets())
|
||||||
|
|
||||||
@@ -308,7 +314,136 @@ export function VpsFiltersToolbar({
|
|||||||
onChange(filtersToState(next, filters))
|
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 savePreset = () => {
|
||||||
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
|
const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`)
|
||||||
@@ -331,113 +466,109 @@ export function VpsFiltersToolbar({
|
|||||||
const reset = () => onChange(buildDefaultVpsFilters())
|
const reset = () => onChange(buildDefaultVpsFilters())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<ListFiltersBar
|
||||||
<div className="relative w-full">
|
search={{
|
||||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
value: filters.search,
|
||||||
<Input
|
onChange: (search) => onChange({ ...filters, search }),
|
||||||
placeholder="Поиск: IP, DNS, проект, назначение, ОС"
|
placeholder: 'Поиск: IP, DNS, проект, назначение, ОС',
|
||||||
value={filters.search}
|
name: 'vps-inventory-search',
|
||||||
onChange={(e) => onChange({ ...filters, search: e.target.value })}
|
}}
|
||||||
className="pl-8"
|
controls={
|
||||||
autoComplete="off"
|
<>
|
||||||
name="vps-inventory-search"
|
<Filters
|
||||||
spellCheck={false}
|
filters={reuiFilters as unknown as Filter[]}
|
||||||
/>
|
fields={fields as unknown as FilterFieldConfig[]}
|
||||||
</div>
|
onChange={handleFiltersChange}
|
||||||
|
i18n={RU_I18N}
|
||||||
|
size="sm"
|
||||||
|
allowMultiple={false}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Фильтр
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Popover>
|
||||||
<Filters
|
<PopoverTrigger
|
||||||
filters={reuiFilters as unknown as Filter[]}
|
render={
|
||||||
fields={fields as unknown as FilterFieldConfig[]}
|
<Button variant="ghost" size="sm">
|
||||||
onChange={handleFiltersChange}
|
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||||
i18n={RU_I18N}
|
Вид
|
||||||
size="sm"
|
|
||||||
allowMultiple={false}
|
|
||||||
trigger={
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Фильтр
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<SlidersHorizontalIcon data-icon="inline-start" />
|
|
||||||
Вид
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent align="end" className="w-64 p-3">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Отображение</Label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={filters.groupByProject}
|
|
||||||
onCheckedChange={(v) => onChange({ ...filters, groupByProject: Boolean(v) })}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Группировать по проекту</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={filters.tableCompact}
|
|
||||||
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Компактная таблица</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs text-muted-foreground">Пресеты</Label>
|
|
||||||
<Button variant="ghost" size="sm" onClick={savePreset} className="h-7 px-2">
|
|
||||||
<SaveIcon className="size-3.5" />
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
{presets.length === 0 ? (
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Нет сохранённых пресетов</p>
|
<PopoverContent align="end" className="w-64 p-3">
|
||||||
) : (
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-2">
|
||||||
{presets.map((p) => (
|
<Label className="text-xs text-muted-foreground">Отображение</Label>
|
||||||
<div key={p.name} className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-accent">
|
<label className="flex items-center gap-2">
|
||||||
<button
|
<Checkbox
|
||||||
type="button"
|
checked={filters.groupByProject}
|
||||||
onClick={() => applyPreset(p)}
|
onCheckedChange={(v) => onChange({ ...filters, groupByProject: Boolean(v) })}
|
||||||
className="flex-1 truncate text-start text-sm"
|
/>
|
||||||
>
|
<span className="text-sm">Группировать по проекту</span>
|
||||||
{p.name}
|
</label>
|
||||||
</button>
|
<label className="flex items-center gap-2">
|
||||||
<button
|
<Checkbox
|
||||||
type="button"
|
checked={filters.tableCompact}
|
||||||
onClick={() => deletePreset(p.name)}
|
onCheckedChange={(v) => onChange({ ...filters, tableCompact: Boolean(v) })}
|
||||||
aria-label="Удалить пресет"
|
/>
|
||||||
className="text-muted-foreground hover:text-foreground"
|
<span className="text-sm">Компактная таблица</span>
|
||||||
>
|
</label>
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{hasActive ? (
|
<Separator />
|
||||||
<Button variant="ghost" size="sm" onClick={reset}>
|
|
||||||
<XIcon data-icon="inline-start" />
|
<div className="flex flex-col gap-2">
|
||||||
Сбросить
|
<div className="flex items-center justify-between">
|
||||||
</Button>
|
<Label className="text-xs text-muted-foreground">Пресеты</Label>
|
||||||
) : null}
|
<Button variant="ghost" size="sm" onClick={savePreset} className="h-7 px-2">
|
||||||
</div>
|
<SaveIcon className="size-3.5" />
|
||||||
</div>
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{presets.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Нет сохранённых пресетов</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(p)}
|
||||||
|
className="flex-1 truncate text-start text-sm"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deletePreset(p.name)}
|
||||||
|
aria-label="Удалить пресет"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
chips={chips}
|
||||||
|
shown={shownCount}
|
||||||
|
total={totalCount}
|
||||||
|
showReset={hasActive}
|
||||||
|
onReset={reset}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,17 +219,17 @@ export function stateToActiveFilters(state: VpsFiltersState): ActiveFilter[] {
|
|||||||
export function countActiveFilters(filters: VpsFiltersState): number {
|
export function countActiveFilters(filters: VpsFiltersState): number {
|
||||||
let n = 0
|
let n = 0
|
||||||
if (filters.search) n++
|
if (filters.search) n++
|
||||||
n += filters.providerId.length
|
if (filters.providerId.length) n++
|
||||||
n += filters.providerAccountId.length
|
if (filters.providerAccountId.length) n++
|
||||||
n += filters.country.length
|
if (filters.country.length) n++
|
||||||
n += filters.city.length
|
if (filters.city.length) n++
|
||||||
if (filters.datacenter) n++
|
if (filters.datacenter) n++
|
||||||
n += filters.status.length
|
if (filters.status.length) n++
|
||||||
n += filters.environment.length
|
if (filters.environment.length) n++
|
||||||
n += filters.tariffType.length
|
if (filters.tariffType.length) n++
|
||||||
n += filters.monitoring.length
|
if (filters.monitoring.length) n++
|
||||||
n += filters.backup.length
|
if (filters.backup.length) n++
|
||||||
n += filters.project.length
|
if (filters.project.length) n++
|
||||||
if (filters.minVcpu != null) n++
|
if (filters.minVcpu != null) n++
|
||||||
if (filters.minRamGb != null) n++
|
if (filters.minRamGb != null) n++
|
||||||
if (filters.minDiskGb != null) n++
|
if (filters.minDiskGb != null) n++
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyAccountFilters,
|
applyAccountFilters,
|
||||||
buildDefaultAccountFilters,
|
buildDefaultAccountFilters,
|
||||||
|
hasActiveAccountFilters,
|
||||||
|
matchesAccountFilterPreset,
|
||||||
type AccountFiltersState,
|
type AccountFiltersState,
|
||||||
} from '@/components/account-filters'
|
} from '@/components/account-filters'
|
||||||
import { AccountFiltersToolbar } from '@/components/account-filters-toolbar'
|
import { AccountFiltersToolbar } from '@/components/account-filters-toolbar'
|
||||||
@@ -213,31 +215,41 @@ function AccountsPage() {
|
|||||||
if (!snapshot) return []
|
if (!snapshot) return []
|
||||||
const accounts = snapshot.providerAccounts
|
const accounts = snapshot.providerAccounts
|
||||||
const atRisk = buildAtRiskAccounts(accounts, snapshot.providers, snapshot.syncLog ?? [])
|
const atRisk = buildAtRiskAccounts(accounts, snapshot.providers, snapshot.syncLog ?? [])
|
||||||
|
const lowBalanceCount = countLowBalanceAccounts(accounts, healthCtx)
|
||||||
|
const defaultFilters = buildDefaultAccountFilters()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Всего аккаунтов',
|
label: 'Всего аккаунтов',
|
||||||
value: accounts.length,
|
value: accounts.length,
|
||||||
onClick: () => setFilters(buildDefaultAccountFilters()),
|
icon: <UserRoundIcon className="size-4" />,
|
||||||
|
active: !health && !hasActiveAccountFilters(filters),
|
||||||
|
onClick: () => setFilters(defaultFilters),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Готовы к синку',
|
label: 'Готовы к синку',
|
||||||
value: syncableCount,
|
value: syncableCount,
|
||||||
onClick: () => setFilters({ ...buildDefaultAccountFilters(), syncableOnly: true }),
|
icon: <RefreshCwIcon className="size-4" />,
|
||||||
|
active: matchesAccountFilterPreset(filters, { syncableOnly: true }),
|
||||||
|
onClick: () => setFilters({ ...defaultFilters, syncableOnly: true }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'С проблемами',
|
label: 'С проблемами',
|
||||||
value: countAccountsWithIssues(accounts, healthCtx),
|
value: countAccountsWithIssues(accounts, healthCtx),
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
variant: atRisk.length ? ('warning' as const) : ('default' as const),
|
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: 'Низкий баланс',
|
label: 'Низкий баланс',
|
||||||
value: countLowBalanceAccounts(accounts, healthCtx),
|
value: lowBalanceCount,
|
||||||
variant: countLowBalanceAccounts(accounts, healthCtx) ? ('destructive' as const) : ('default' as const),
|
icon: <WalletIcon className="size-4" />,
|
||||||
onClick: () => setFilters({ ...buildDefaultAccountFilters(), lowBalanceOnly: true }),
|
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>[] = [
|
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||||
{
|
{
|
||||||
@@ -418,6 +430,8 @@ function AccountsPage() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
providers={snapshot.providers}
|
providers={snapshot.providers}
|
||||||
|
shownCount={filteredAccounts.length}
|
||||||
|
totalCount={snapshot.providerAccounts.length}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||||
@@ -426,7 +440,19 @@ function AccountsPage() {
|
|||||||
data={filteredAccounts}
|
data={filteredAccounts}
|
||||||
rowId={(a) => a.id}
|
rowId={(a) => a.id}
|
||||||
pinLastColumn
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
ArrowLeftRightIcon,
|
ArrowLeftRightIcon,
|
||||||
CoinsIcon,
|
CoinsIcon,
|
||||||
StickyNoteIcon,
|
StickyNoteIcon,
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
ScaleIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -190,11 +193,23 @@ function BalancePage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
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),
|
value: formatCurrency(totalCredit - totalDebit, baseCurrency),
|
||||||
|
icon: <ScaleIcon className="size-4" />,
|
||||||
|
hint: `${rows.length} записей`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function DashboardPage() {
|
|||||||
icon: ExternalLinkIcon,
|
icon: ExternalLinkIcon,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: row.to })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: row.to })}>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@@ -195,7 +195,7 @@ function DashboardPage() {
|
|||||||
header: '',
|
header: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
cell: () => (
|
cell: () => (
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/accounts' })}>
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts' })}>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@@ -240,6 +240,12 @@ function DashboardPage() {
|
|||||||
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
value: stats?.minRunwayDays != null ? `${stats.minRunwayDays} дн` : '—',
|
||||||
icon: <ClockIcon className="size-4" />,
|
icon: <ClockIcon className="size-4" />,
|
||||||
variant: stats?.minRunwayDays != null && stats.minRunwayDays < 14 ? 'warning' : 'default',
|
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' }),
|
onClick: () => navigate({ to: '/accounts' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -247,6 +253,12 @@ function DashboardPage() {
|
|||||||
value: stats?.expiringWithin7Days ?? 0,
|
value: stats?.expiringWithin7Days ?? 0,
|
||||||
icon: <AlertTriangleIcon className="size-4" />,
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
variant: (stats?.expiringWithin7Days ?? 0) > 0 ? 'warning' : 'default',
|
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' } }),
|
onClick: () => navigate({ to: '/vps', search: { health: 'paid-overdue' } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -75,11 +75,22 @@ function ReportsPage() {
|
|||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: 'Расход/мес (в валюте VPS)',
|
label: 'Расход/мес',
|
||||||
value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB'),
|
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>
|
<ChartsGrid>
|
||||||
|
|||||||
@@ -75,9 +75,24 @@ function ResourcesPage() {
|
|||||||
<>
|
<>
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
|
{
|
||||||
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
|
label: 'vCPU',
|
||||||
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
|
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}
|
projectNameOptions={projectNameOptions}
|
||||||
countryOptions={filterCountryOptions}
|
countryOptions={filterCountryOptions}
|
||||||
cityOptions={filterCityOptions}
|
cityOptions={filterCityOptions}
|
||||||
|
shownCount={filteredVps.length}
|
||||||
|
totalCount={snap.vps.length}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Ничего не найдено"
|
title="Ничего не найдено"
|
||||||
@@ -495,6 +497,8 @@ function VpsPage() {
|
|||||||
projectNameOptions={projectNameOptions}
|
projectNameOptions={projectNameOptions}
|
||||||
countryOptions={filterCountryOptions}
|
countryOptions={filterCountryOptions}
|
||||||
cityOptions={filterCityOptions}
|
cityOptions={filterCityOptions}
|
||||||
|
shownCount={filteredVps.length}
|
||||||
|
totalCount={snap.vps.length}
|
||||||
/>
|
/>
|
||||||
{tableSections.map((section) => (
|
{tableSections.map((section) => (
|
||||||
<DataGridCard
|
<DataGridCard
|
||||||
@@ -505,6 +509,7 @@ function VpsPage() {
|
|||||||
rowId={(v) => v.id}
|
rowId={(v) => v.id}
|
||||||
emptyTitle="VPS не найдены"
|
emptyTitle="VPS не найдены"
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
dense={filters.tableCompact}
|
||||||
enableRowSelection
|
enableRowSelection
|
||||||
onRowSelectionChange={setSelectedIds}
|
onRowSelectionChange={setSelectedIds}
|
||||||
virtualization={section.items.length > 200}
|
virtualization={section.items.length > 200}
|
||||||
|
|||||||
Reference in New Issue
Block a user