feat(web): унифицировать ReUI PRO — ResourcePage, KpiStatGrid, chrome
Docker / build (push) Failing after 20s

Списки на ResourcePage, KPI на EvoBGP hybrid KpiStatGrid; удалены DataGridCard/SectionCards/ModeToggle; обновлены rules, skill и design contract.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 00:22:49 +07:00
co-authored by Cursor
parent 8a4934940f
commit 9faec59338
72 changed files with 3505 additions and 789 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import { PageShell } from './page-shell'
import { PageHeader } from './page-header'
import { QueryState } from './query-state'
import { EmptyState } from './empty-state'
import { SectionCardsSkeleton } from './skeletons'
import { KpiStatGridSkeleton } from './skeletons'
interface AnalyticsPageProps<T> {
title: string
@@ -49,7 +49,7 @@ export function AnalyticsPage<T>({
isError={isError}
error={error}
onRetry={onRetry}
skeleton={skeleton ?? <SectionCardsSkeleton count={3} />}
skeleton={skeleton ?? <KpiStatGridSkeleton count={3} />}
>
{(snap) =>
analyticsEmpty ? (
+1 -1
View File
@@ -17,7 +17,7 @@ export interface DataGridColumn<T> {
/** @deprecated Используйте DataGridColumn */
export type DataTableColumn<T> = DataGridColumn<T>
/** Унифицированные классы колонок для DataGridCard. */
/** Унифицированные классы колонок для FrameDataGrid. */
export const COL = {
num: 'w-28 text-right tabular-nums',
date: 'w-32 text-right tabular-nums text-muted-foreground',
-29
View File
@@ -1,29 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -26,8 +26,8 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { EmptyState } from './empty-state'
import type { DataGridColumn } from './data-grid-types'
import { EmptyState } from '@/components/empty-state'
import type { DataGridColumn } from '@/components/data-grid-types'
import {
Frame,
FrameDescription,
@@ -81,7 +81,7 @@ export function dataGridColumnVisibilityOptions<T>(
}))
}
export interface DataGridCardProps<TData extends object> {
export interface FrameDataGridProps<TData extends object> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
@@ -161,7 +161,7 @@ function DataGridPaginationBar() {
)
}
function DataGridCardBody<TData extends object>({
function FrameDataGridBody<TData extends object>({
table,
data,
emptyTitle,
@@ -223,7 +223,7 @@ function DataGridCardBody<TData extends object>({
)
}
export function DataGridCard<TData extends object>({
export function FrameDataGrid<TData extends object>({
title,
description,
actions,
@@ -251,7 +251,7 @@ export function DataGridCard<TData extends object>({
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: DataGridCardProps<TData>) {
}: FrameDataGridProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
@@ -378,7 +378,7 @@ export function DataGridCard<TData extends object>({
}
const gridBody = (
<DataGridCardBody
<FrameDataGridBody
table={table}
data={data}
emptyTitle={emptyTitle}
+14 -4
View File
@@ -2,14 +2,24 @@ export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
KpiStatGrid,
type KpiStatCard,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export {
QuickActionGrid,
type QuickActionItem,
} from './quick-action-grid'
FrameDataGrid,
columnDefFromDataGrid,
loadStoredColumnVisibility,
dataGridColumnVisibilityOptions,
type FrameDataGridProps,
type DataGridColumnVisibilityOption,
} from './frame-data-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -1,40 +1,44 @@
import type { ReactNode } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
export interface KpiStatCard {
id: string
label: string
value: string | number
hint?: string
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
/** Semantic color for the primary value */
variant?: KpiStatVariant
footer?: ReactNode
}
/** @deprecated Use KpiStatCard */
export type OpsKpiCard = KpiStatCard
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
interface KpiStatGridProps {
cards: KpiStatCard[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
}
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
@@ -44,103 +48,146 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive',
}
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveFooter(card: KpiStatCard): ReactNode {
if (card.footer) return card.footer
if (card.hint) {
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
{card.hint}
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ card }: { card: KpiStatCard }) {
const footer = resolveFooter(card)
const valueVariant = card.variant ?? 'default'
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{card.icon ? (
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
card.iconClassName ?? DEFAULT_ICON_CLASS,
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{card.icon}
{item.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground text-sm font-medium">{card.label}</span>
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<span
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{card.value}
</span>
{item.value}
</div>
</div>
</div>
)
}
function KpiStatCardItem({ card }: { card: KpiStatCard }) {
const interactive = Boolean(card.to || card.onSelect)
const panelClass = cn(
'relative isolate flex h-full flex-col',
card.selected && 'ring-primary/30 bg-muted/30 ring-1',
interactive &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
)
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
if (card.to) {
return (
return cn(
'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={card.to} search={card.search} className="focus-visible:outline-none">
<KpiStatCardBody card={card} />
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (card.onSelect) {
return (
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<button
type="button"
onClick={card.onSelect}
className="w-full text-start focus-visible:outline-none"
>
<KpiStatCardBody card={card} />
</button>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody card={card} />
</FramePanel>
)
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
@@ -164,23 +211,78 @@ function KpiStatGridSkeleton({ count }: { count: number }) {
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual (colored icon + Badge) + horizontal compact layout.
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
if (cards.length === 0) {
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
@@ -193,13 +295,31 @@ export function KpiStatGrid({
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(cards.length))}>
{cards.map((card) => (
<KpiStatCardItem key={card.id} card={card} />
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -10,6 +10,7 @@ import {
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
@@ -30,16 +31,6 @@ interface QuickActionGridProps {
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
+296 -148
View File
@@ -40,6 +40,10 @@ import {
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
import {
FrameDataGrid,
type FrameDataGridProps,
} from './frame-data-grid'
export interface ResourcePageTab {
id: string
@@ -47,21 +51,42 @@ export interface ResourcePageTab {
count?: number
}
export interface ResourcePageProps<T extends object> {
title: string
type SimpleGridPassthrough<T extends object> = Pick<
FrameDataGridProps<T>,
| 'onRowClick'
| 'pagination'
| 'footerContent'
| 'dense'
| 'initialSorting'
| 'virtualization'
| 'height'
| 'onRowSelectionChange'
| 'enableColumnVisibility'
| 'columnVisibility'
| 'onColumnVisibilityChange'
| 'columnVisibilityTrigger'
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'className'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
/** Optional when hideHeader / embedded under PageHeader. */
title?: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
filterFields: FilterFieldConfig[]
filters: Filter[]
onFiltersChange: (filters: Filter[]) => void
/** Empty / omit → simple Frame grid (no ReUI Filters). Preview: data-grid-filtering-2 */
filterFields?: FilterFieldConfig[]
filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue: (item: T, field: string) => unknown
getFilterFieldValue?: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T) => string
getRowId: (row: T, index?: number) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
@@ -77,6 +102,13 @@ export interface ResourcePageProps<T extends object> {
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
pinLastColumn?: boolean
/** FrameDataGrid aliases when used as simple list. */
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
/** Alias for primaryAction (FrameDataGrid `actions`). */
actions?: ReactNode
}
function ResourcePageSkeleton() {
@@ -98,18 +130,35 @@ function ResourcePageSkeleton() {
)
}
export function ResourcePage<T extends object>({
title,
function ResourceLoadError({
error,
onRetry,
}: {
error?: Error | null
onRetry?: () => void
}) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
const noopFiltersChange = (_filters: Filter[]) => {}
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
function ResourcePageSimple<T extends object>({
title = '',
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields,
filters,
onFiltersChange,
onClearFilters,
getFilterFieldValue,
columns,
data,
getRowId,
@@ -118,6 +167,101 @@ export function ResourcePage<T extends object>({
error = null,
onRetry,
primaryAction,
actions,
emptyState,
emptyTitle,
emptyDescription,
emptyAction,
pageSize = 10,
enableRowSelection = false,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
onRowClick,
pagination,
footerContent,
dense,
initialSorting,
virtualization,
height,
onRowSelectionChange,
enableColumnVisibility,
columnVisibility,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
const headerActions = primaryAction ?? actions
const resolvedEmpty = emptyState ?? (
emptyTitle || emptyDescription || emptyAction
? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
: undefined
)
return (
<div className="w-full">
{toolbarExtra ? (
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
) : null}
<FrameDataGrid
title={hideHeader ? undefined : title || undefined}
description={hideHeader ? undefined : description}
actions={hideHeader ? undefined : headerActions}
columns={columns}
data={data}
rowId={getRowId}
emptyTitle={resolvedEmpty?.title}
emptyDescription={resolvedEmpty?.description}
emptyAction={resolvedEmpty?.action}
pinLastColumn={pinLastColumn}
pageSize={pageSize}
enableRowSelection={enableRowSelection}
onRowClick={onRowClick}
pagination={pagination}
footerContent={footerContent}
dense={dense}
initialSorting={initialSorting}
virtualization={virtualization}
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
className={className}
/>
</div>
)
}
function ResourcePageFiltered<T extends object>({
title = '',
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields = [],
filters = [],
onFiltersChange = noopFiltersChange,
onClearFilters,
getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState,
pageSize = 10,
enableRowSelection = false,
@@ -125,8 +269,10 @@ export function ResourcePage<T extends object>({
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
@@ -142,16 +288,20 @@ export function ResourcePage<T extends object>({
}, [])
const filteredData = useMemo(() => {
let result = applyFiltersToData(data, filters, getFilterFieldValue)
let result = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = applyFiltersToData(data, filters, getFilterFieldValue)
const base = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
@@ -160,7 +310,7 @@ export function ResourcePage<T extends object>({
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
}, [tabs, tabFilter, data, filters, getFilterFieldValue, showFilters])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
@@ -176,7 +326,7 @@ export function ResourcePage<T extends object>({
const table = useReactTable({
data: filteredData,
columns,
getRowId,
getRowId: (row) => getRowId(row),
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
@@ -219,26 +369,8 @@ export function ResourcePage<T extends object>({
[tabs, tabCounts],
)
if (isLoading) {
return <ResourcePageSkeleton />
}
if (isError) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
if (data.length === 0 && emptyState) {
return (
@@ -254,121 +386,137 @@ export function ResourcePage<T extends object>({
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage={emptyMessage}
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
</FrameHeader>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
{showFilters ? (
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
) : (
<div />
)}
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{showFilters && onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
</div>
<Separator />
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
/**
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid under the hood).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
const showFilters = (props.filterFields?.length ?? 0) > 0
const hasTabs = (props.tabs?.length ?? 0) > 0
if (!showFilters && !hasTabs) {
return <ResourcePageSimple {...props} />
}
return <ResourcePageFiltered {...props} />
}
-66
View File
@@ -1,66 +0,0 @@
import type { ReactElement, ReactNode } from 'react'
import { KpiStatGrid, type KpiStatCard, type KpiStatVariant } from '@/components/reui-kit/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
iconClassName?: string
badge?: ReactNode
variant?: KpiStatVariant
active?: boolean
onClick?: () => void
}
const VARIANT_ICON_CLASS: Record<KpiStatVariant, string> = {
default: 'text-muted-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
/**
* @deprecated Prefer `KpiStatGrid` directly.
* Thin adapter → hybrid stats-12 Frame KPI. Preview: https://reui.io/preview/base/stats-12
*/
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
const cards: KpiStatCard[] = items.map((item, idx) => {
const label = typeof item.label === 'string' ? item.label : `kpi-${idx}`
const variant = item.variant ?? 'default'
let footer: ReactNode = item.badge
if (!footer && typeof item.hint === 'string') {
footer = (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
} else if (!footer && item.hint) {
footer = item.hint
} else if (!footer && variant !== 'default') {
footer = (
<Badge
variant={variant === 'warning' ? 'warning-light' : 'destructive-light'}
size="sm"
>
{variant === 'warning' ? 'Внимание' : 'Критично'}
</Badge>
)
}
return {
id: label,
label,
value: item.value as string | number,
icon: item.icon,
iconClassName: item.iconClassName ?? VARIANT_ICON_CLASS[variant],
variant,
selected: item.active,
onSelect: item.onClick,
footer,
}
})
return <KpiStatGrid cards={cards} className={className} />
}
+38 -14
View File
@@ -1,23 +1,44 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { Frame, FramePanel } from '@/components/reui/frame'
import { cn } from '@cfdm/ui/lib/utils'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
/** KPI skeleton — hybrid stats-12 layout. */
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
}))}
/>
<Frame className="@container w-full">
<div
className={cn(
'grid gap-2',
count <= 1
? 'grid-cols-1'
: count === 2
? 'grid-cols-1 @xl:grid-cols-2'
: count === 3
? 'grid-cols-1 @3xl:grid-cols-3'
: 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4',
)}
>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<Frame dense spacing="sm" className="w-full">
<FramePanel className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
@@ -32,7 +53,10 @@ export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: nu
</div>
))}
</div>
</CardContent>
</Card>
</FramePanel>
</Frame>
)
}
/** @deprecated Use KpiStatGridSkeleton */
export const SectionCardsSkeleton = KpiStatGridSkeleton
@@ -16,7 +16,7 @@ import {
FilterToggleChip,
type FilterChip,
} from '@/components/list-filters-bar'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
import type { VisibilityState } from '@tanstack/react-table'
import {
@@ -14,7 +14,7 @@ import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import { ConfirmDialog } from '@/components/confirm-dialog'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
import type { VisibilityState } from '@tanstack/react-table'
import {
@@ -0,0 +1,28 @@
import type { VisibilityState } from "@tanstack/react-table"
import type { DataGridColumn } from "@/components/data-grid-types"
export function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
return JSON.parse(raw) as VisibilityState
} catch {
return undefined
}
}
export interface DataGridColumnVisibilityOption {
id: string
label: string
}
export function dataGridColumnVisibilityOptions<T>(
cols: DataGridColumn<T>[],
): DataGridColumnVisibilityOption[] {
return cols
.filter((c) => c.enableHiding !== false)
.map((c) => ({
id: c.key,
label: c.headerTitle ?? (typeof c.header === "string" ? c.header : c.key),
}))
}
+12 -5
View File
@@ -21,13 +21,12 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid, KpiStatGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { RowActions } from '@/components/row-actions'
import { HealthModeBanner } from '@/components/health-mode-banner'
import { SectionCards } from '@/components/section-cards'
import {
ProviderAccountEditSheet,
providerAccountFormDefaults,
@@ -223,31 +222,39 @@ function AccountsPage() {
const defaultFilters = buildDefaultAccountFilters()
return [
{
id: 'all',
label: 'Всего аккаунтов',
value: accounts.length,
icon: <UserRoundIcon className="size-4" />,
iconClassName: 'text-muted-foreground',
active: !health && !hasActiveAccountFilters(filters),
onClick: () => setFilters(defaultFilters),
},
{
id: 'syncable',
label: 'Готовы к синку',
value: syncableCount,
icon: <RefreshCwIcon className="size-4" />,
iconClassName: 'text-info',
active: matchesAccountFilterPreset(filters, { syncableOnly: true }),
onClick: () => setFilters({ ...defaultFilters, syncableOnly: true }),
},
{
id: 'issues',
label: 'С проблемами',
value: countAccountsWithIssues(accounts, healthCtx),
icon: <ActivityIcon className="size-4" />,
iconClassName: 'text-warning',
variant: atRisk.length ? ('warning' as const) : ('default' as const),
active: matchesAccountFilterPreset(filters, { issuesOnly: true }),
onClick: () => setFilters({ ...defaultFilters, issuesOnly: true }),
},
{
id: 'low-balance',
label: 'Низкий баланс',
value: lowBalanceCount,
icon: <WalletIcon className="size-4" />,
iconClassName: 'text-destructive',
variant: lowBalanceCount ? ('destructive' as const) : ('default' as const),
active: matchesAccountFilterPreset(filters, { lowBalanceOnly: true }),
onClick: () => setFilters({ ...defaultFilters, lowBalanceOnly: true }),
@@ -428,7 +435,7 @@ function AccountsPage() {
>
{() => (
<div className="flex flex-col gap-4">
{snapshot ? <SectionCards items={summaryCards} /> : null}
{snapshot ? <KpiStatGrid items={summaryCards} /> : null}
{snapshot ? (
<AccountFiltersToolbar
filters={filters}
@@ -439,10 +446,10 @@ function AccountsPage() {
/>
) : null}
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={filteredAccounts}
rowId={(a) => a.id}
getRowId={(a) => a.id}
pinLastColumn
emptyTitle={health || hasActiveAccountFilters(filters) ? 'Нет аккаунтов с этими фильтрами' : 'Нет записей'}
emptyDescription={
+3 -3
View File
@@ -6,7 +6,7 @@ import { api } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
@@ -112,10 +112,10 @@ function AuditPage() {
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
>
{(rows) => (
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={rows as AuditRow[]}
rowId={(r) => r.id}
getRowId={(r) => r.id}
pageSize={25}
/>
)}
+7 -7
View File
@@ -20,12 +20,12 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { KpiStatGrid } from '@/components/reui-kit'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BalanceEntrySheet, balanceEntryFormDefaults } from '@/components/domain/balance-entry-sheet'
import type { BalanceLedgerFormValues } from '@/lib/schemas'
@@ -181,7 +181,7 @@ function BalancePage() {
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<KpiStatGridSkeleton count={3} />
<TableSkeleton />
</div>
}
@@ -210,7 +210,7 @@ function BalancePage() {
>
{() => (
<div className="flex flex-col gap-4">
<SectionCards
<KpiStatGrid
items={[
{
label: 'Всего приходов',
@@ -232,10 +232,10 @@ function BalancePage() {
},
]}
/>
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={rows}
rowId={(r) => r.id}
getRowId={(r) => r.id}
pinLastColumn
footerContent={
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
+9 -9
View File
@@ -23,10 +23,10 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { OpsDashboard, QuickActionGrid, type KpiStatCard, type QuickActionItem } from '@/components/reui-kit'
import { QueryState } from '@/components/query-state'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
@@ -131,7 +131,7 @@ function DashboardPage() {
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={6} />
<KpiStatGridSkeleton count={6} />
<TableSkeleton />
</div>
}
@@ -420,19 +420,19 @@ function DashboardPage() {
</TabsTrigger>
</TabsList>
<TabsContent value="issues" className="mt-0">
<DataGridCard
<ResourcePage
title="Здоровье инвентаря"
description="Ставка, оплата, синк, баланс аккаунтов"
columns={columnDefFromDataGrid(issueColumns)}
data={issues}
rowId={(i) => i.key}
getRowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Критичных проблем в инвентаре не обнаружено"
pagination={false}
/>
</TabsContent>
<TabsContent value="recent" className="mt-0">
<DataGridCard
<ResourcePage
title="Последние VPS"
description="Активные серверы"
actions={
@@ -442,18 +442,18 @@ function DashboardPage() {
}
columns={columnDefFromDataGrid(vpsColumns)}
data={activeVps.slice(0, 8)}
rowId={(v) => v.id}
getRowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/>
</TabsContent>
<TabsContent value="risk" className="mt-0">
<DataGridCard
<ResourcePage
title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager"
columns={columnDefFromDataGrid(riskColumns)}
data={atRisk}
rowId={(r) => r.id}
getRowId={(r) => r.id}
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
+7 -7
View File
@@ -15,12 +15,12 @@ import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { KpiStatGrid } from '@/components/reui-kit'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { RowActions } from '@/components/row-actions'
import { PaymentEditSheet, paymentFormDefaults } from '@/components/domain/payment-edit-sheet'
import type { PaymentFormValues } from '@/lib/schemas'
@@ -178,7 +178,7 @@ function PaymentsPage() {
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<KpiStatGridSkeleton count={3} />
<TableSkeleton />
</div>
}
@@ -225,7 +225,7 @@ function PaymentsPage() {
return (
<div className="flex flex-col gap-4">
<SectionCards
<KpiStatGrid
items={[
{
label: 'Всего платежей',
@@ -246,10 +246,10 @@ function PaymentsPage() {
},
]}
/>
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={sorted}
rowId={(p) => p.id}
getRowId={(p) => p.id}
pinLastColumn
virtualization={sorted.length > 200}
height={560}
@@ -17,9 +17,9 @@ import { api, ApiError } from '@/lib/api-client'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards } from '@/components/section-cards'
import { KpiStatGrid } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { StatusBadge } from '@/components/status-badge'
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
@@ -27,7 +27,7 @@ import type { ProjectFormValues } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import {
formatCurrency,
normalizeRatesPayload,
@@ -246,7 +246,7 @@ function ProjectDetailPage() {
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<KpiStatGridSkeleton count={3} />
<TableSkeleton />
</div>
}
@@ -264,7 +264,7 @@ function ProjectDetailPage() {
/>
) : (
<div className="flex flex-col gap-4">
<SectionCards
<KpiStatGrid
items={[
{
label: 'Активных VPS',
@@ -301,12 +301,12 @@ function ProjectDetailPage() {
</CardContent>
</Card>
) : null}
<DataGridCard
<ResourcePage
title="VPS проекта"
description={`${projectVps.length} серверов`}
columns={columnDefFromDataGrid(columns)}
data={projectVps}
rowId={(v) => v.id}
getRowId={(v) => v.id}
emptyTitle="VPS не назначены"
emptyDescription="Назначьте проект при редактировании VPS"
/>
+5 -5
View File
@@ -12,11 +12,11 @@ import { toast } from 'sonner'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { CrudListPage } from '@/components/crud-list-page'
import { RowActions } from '@/components/row-actions'
import { SectionCards } from '@/components/section-cards'
import { KpiStatGrid } from '@/components/reui-kit'
import { ProjectFiltersToolbar } from '@/components/project-filters-toolbar'
import {
applyProjectFilters,
@@ -243,7 +243,7 @@ function ProjectsPage() {
{() => (
<div className="flex flex-col gap-4">
{overview ? (
<SectionCards
<KpiStatGrid
items={[
{
label: 'Проектов',
@@ -291,10 +291,10 @@ function ProjectsPage() {
}
/>
) : (
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={rows}
rowId={(r) => r.id}
getRowId={(r) => r.id}
pinLastColumn
/>
)}
+3 -3
View File
@@ -8,7 +8,7 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellWithIcon } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page'
@@ -151,10 +151,10 @@ function ProvidersPage() {
}
>
{(snap) => (
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={snap.providers}
rowId={(p) => p.id}
getRowId={(p) => p.id}
pinLastColumn
/>
)}
+4 -4
View File
@@ -7,8 +7,8 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { KpiStatGrid } from '@/components/reui-kit'
import { KpiStatGridSkeleton } from '@/components/skeletons'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Badge } from '@cfdm/ui/components/badge'
@@ -131,7 +131,7 @@ function RenewalsPage() {
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<KpiStatGridSkeleton count={3} />
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
@@ -145,7 +145,7 @@ function RenewalsPage() {
>
{() => (
<div className="flex flex-col gap-4">
<SectionCards
<KpiStatGrid
items={[
{
label: 'Просрочено',
+2 -2
View File
@@ -7,7 +7,7 @@ import { z } from 'zod'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { Button } from '@cfdm/ui/components/button'
import { AnalyticsPage } from '@/components/analytics-page'
import { SectionCards } from '@/components/section-cards'
import { KpiStatGrid } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import {
ChartsGrid,
@@ -181,7 +181,7 @@ function ReportsPage() {
shownVps={filteredVps.length}
totalVps={snap.vps.length}
/>
<SectionCards
<KpiStatGrid
items={[
{
label: 'Расход/мес',
+8 -2
View File
@@ -5,7 +5,7 @@ import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { EmptyState } from '@/components/empty-state'
import { AnalyticsPage } from '@/components/analytics-page'
import { SectionCards } from '@/components/section-cards'
import { KpiStatGrid } from '@/components/reui-kit'
import { Button } from '@cfdm/ui/components/button'
import {
ChartContainer,
@@ -74,24 +74,30 @@ function ResourcesPage() {
return (
<>
<SectionCards
<KpiStatGrid
items={[
{
id: 'vcpu',
label: 'vCPU',
value: totals.vcpu,
icon: <CpuIcon className="size-4" />,
iconClassName: 'text-info',
hint: `${active.length} VPS`,
},
{
id: 'ram',
label: 'RAM',
value: totals.ram,
icon: <MemoryStickIcon className="size-4" />,
iconClassName: 'text-primary',
hint: 'GB',
},
{
id: 'disk',
label: 'Disk',
value: totals.disk,
icon: <HardDriveIcon className="size-4" />,
iconClassName: 'text-warning',
hint: 'GB',
},
]}
+3 -3
View File
@@ -4,7 +4,7 @@ import { HistoryIcon, UserRoundIcon, CheckCircle2Icon, XCircleIcon, LoaderIcon }
import { snapshotQueryOptions } from '@/queries/snapshot'
import { CrudListPage } from '@/components/crud-list-page'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { StatusBadge } from '@/components/status-badge'
import { dataGridCellStack } from '@/components/data-grid-cells'
@@ -98,10 +98,10 @@ function SyncJournalPage() {
}
>
{(snap) => (
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={snap.syncLog ?? []}
rowId={(r) => r.id}
getRowId={(r) => r.id}
dense
/>
)}
+3 -8
View File
@@ -7,12 +7,7 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Badge } from '@cfdm/ui/components/badge'
import {
DataGridCard,
columnDefFromDataGrid,
loadStoredColumnVisibility,
dataGridColumnVisibilityOptions,
} from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { VisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
@@ -391,10 +386,10 @@ function TariffsPage() {
</Alert>
) : null}
{toolbar}
<DataGridCard
<ResourcePage
columns={columnDefFromDataGrid(columns)}
data={filteredTariffs}
rowId={(t) => t.id}
getRowId={(t) => t.id}
emptyTitle="Тарифы не найдены"
dense={filters.tableCompact}
virtualization={filteredTariffs.length > 200}
+3 -3
View File
@@ -20,7 +20,7 @@ import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import type { DataGridColumn } from '@/components/data-grid-types'
import { getPaidUntilDate } from '@/lib/paid-until'
import {
@@ -261,11 +261,11 @@ function VpsDetailPage() {
/>
</CardContent>
</Card>
<DataGridCard
<ResourcePage
title="Связанные платежи"
columns={columnDefFromDataGrid(paymentColumns)}
data={relatedPayments}
rowId={(p) => p.id}
getRowId={(p) => p.id}
emptyTitle="Платежей нет"
pagination={false}
/>
+3 -3
View File
@@ -11,7 +11,7 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { DataGridCard, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/data-grid-card'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { VisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
@@ -642,12 +642,12 @@ function VpsPage() {
onColumnVisibilityChange={handleColumnVisibilityChange}
/>
{tableSections.map((section) => (
<DataGridCard
<ResourcePage
key={section.key}
title={section.label ?? undefined}
columns={columnDefFromDataGrid(columns)}
data={section.items}
rowId={(v) => v.id}
getRowId={(v) => v.id}
emptyTitle="VPS не найдены"
pinLastColumn
dense={filters.tableCompact}