feat(web): унифицировать ReUI PRO — ResourcePage, KpiStatGrid, chrome
Docker / build (push) Failing after 20s
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:
@@ -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 ? (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
-7
@@ -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}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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: 'Просрочено',
|
||||
|
||||
@@ -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: 'Расход/мес',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user