Унифицировать оболочку ops-экранов, IconTile, skip-link и контракт UI. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -17,13 +17,13 @@ import {
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
@@ -60,6 +60,36 @@ function ChartEmpty({ message }: { message: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Chart-15 Frame shell. Preview: https://reui.io/preview/base/chart-15 */
|
||||
function ChartShell({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||
<FrameHeader className={action ? 'flex-row items-start justify-between gap-3' : undefined}>
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>{title}</FrameTitle>
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{action}
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
{children}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
const EXPENSE_CONFIG: ChartConfig = {
|
||||
expense: { label: 'Расход', color: 'var(--chart-1)' },
|
||||
}
|
||||
@@ -104,12 +134,11 @@ export function MonthlyExpenseChart({
|
||||
.slice(0, 10)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
<ChartShell
|
||||
className={className}
|
||||
title={title}
|
||||
description={description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}
|
||||
>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных для графика" />
|
||||
) : (
|
||||
@@ -123,8 +152,7 @@ export function MonthlyExpenseChart({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,12 +196,11 @@ export function PaymentsPieChart({
|
||||
}, [payments, baseCurrency, ratesData])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Платежи по типам</CardTitle>
|
||||
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
<ChartShell
|
||||
className={className}
|
||||
title="Платежи по типам"
|
||||
description={`Структура в ${baseCurrency}`}
|
||||
>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных о платежах" />
|
||||
) : (
|
||||
@@ -196,8 +223,7 @@ export function PaymentsPieChart({
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,11 +270,11 @@ function DashboardMonthlyBarChart({
|
||||
const hasData = data.some((row) => row.amount > 0)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardAction>
|
||||
<ChartShell
|
||||
className={className}
|
||||
title={title}
|
||||
description={description}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SelectField
|
||||
size="sm"
|
||||
@@ -268,9 +294,8 @@ function DashboardMonthlyBarChart({
|
||||
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||
/>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
}
|
||||
>
|
||||
{!hasData ? (
|
||||
<ChartEmpty message="Нет данных за выбранный период" />
|
||||
) : (
|
||||
@@ -289,8 +314,7 @@ function DashboardMonthlyBarChart({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -366,11 +390,11 @@ export function DashboardExpensesChart({
|
||||
: 'Списания и платежи за VPS по месяцам'
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Расходы</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<CardAction>
|
||||
<ChartShell
|
||||
className={className}
|
||||
title="Расходы"
|
||||
description={description}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SelectField
|
||||
size="sm"
|
||||
@@ -390,9 +414,8 @@ export function DashboardExpensesChart({
|
||||
options={years.map((y) => ({ value: String(y), label: String(y) }))}
|
||||
/>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
}
|
||||
>
|
||||
{!hasData ? (
|
||||
<ChartEmpty message="Нет данных за выбранный период" />
|
||||
) : (
|
||||
@@ -411,8 +434,7 @@ export function DashboardExpensesChart({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -443,12 +465,11 @@ export function MonthlyTrendChart({
|
||||
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Динамика платежей</CardTitle>
|
||||
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
<ChartShell
|
||||
className={className}
|
||||
title="Динамика платежей"
|
||||
description={`Последние 12 месяцев, ${baseCurrency}`}
|
||||
>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных за выбранный период" />
|
||||
) : (
|
||||
@@ -462,8 +483,7 @@ export function MonthlyTrendChart({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -513,12 +533,11 @@ export function ProjectExpenseChart({
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Расходы по проектам (мес)</CardTitle>
|
||||
<CardDescription>Активные VPS, в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||
<ChartShell
|
||||
className={className}
|
||||
title="Расходы по проектам (мес)"
|
||||
description={`Активные VPS, в ${baseCurrency}`}
|
||||
>
|
||||
{data.length === 0 ? (
|
||||
<ChartEmpty message="Нет данных для графика" />
|
||||
) : (
|
||||
@@ -544,7 +563,6 @@ export function ProjectExpenseChart({
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ReUI Empty + IconStack — adapted from empty-state-12.
|
||||
* Preview: https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/blocks
|
||||
* ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12.
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-stack
|
||||
*/
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
|
||||
@@ -5,12 +5,12 @@ import { z } from 'zod'
|
||||
import { appSwitcherConfigSchema } from '@cfdm/shared/contracts/app-switcher'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SettingsCard } from '@/components/reui-kit/settings-card'
|
||||
import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config'
|
||||
|
||||
const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({
|
||||
@@ -39,14 +39,12 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
|
||||
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Связанные приложения</CardTitle>
|
||||
<CardDescription>URL для переключателя в sidebar и deep links</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingsCard
|
||||
title="Связанные приложения"
|
||||
description="URL для переключателя в sidebar и deep links"
|
||||
>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
className="flex flex-col gap-4 px-5 py-4"
|
||||
onSubmit={(e) => void form.handleSubmit(onSave)(e)}
|
||||
>
|
||||
<FieldGroup>
|
||||
@@ -110,7 +108,6 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
|
||||
Сохранить приложения
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
permissionForPath,
|
||||
} from '@/lib/auth'
|
||||
import { SpaceProvider, useSpaceId } from '@/lib/space'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
@@ -175,6 +176,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<AppSwitcher />
|
||||
@@ -220,7 +224,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<SidebarInset id="main-content">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
@@ -251,9 +255,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<SpaceScopedMain>{children}</SpaceScopedMain>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -32,23 +32,29 @@ export function QueryState<T>({
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div role="alert">
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{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',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
@@ -35,16 +35,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.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',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture:
|
||||
*
|
||||
* The root owns four variables so every part of the tile stays in proportion
|
||||
* and stays overridable from a single `className`:
|
||||
*
|
||||
* --icon-tile-size tile width/height
|
||||
* --icon-tile-icon-size glyph size applied to child svgs
|
||||
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||
* --icon-tile-inset gap between the outer ring and the inner card
|
||||
*
|
||||
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||
* context, so the negative z-index pseudo paints above the root background but
|
||||
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||
*
|
||||
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||
* default to `text-primary`; override it to recolor without touching internals.
|
||||
*/
|
||||
const iconTileVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||
outline: "border border-border bg-background dark:bg-input/32",
|
||||
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||
elevated:
|
||||
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||
/**
|
||||
* Tinted double container: an opacity-filled outer ring with no border
|
||||
* around a bordered inner card, all derived from `currentColor`. The
|
||||
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||
*/
|
||||
soft: [
|
||||
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-current/20 after:bg-current/5",
|
||||
],
|
||||
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||
solid: "bg-primary text-primary-foreground",
|
||||
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||
frame: [
|
||||
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
default:
|
||||
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||
},
|
||||
/**
|
||||
* `default`: active style radius. `full`: circular.
|
||||
* The plain value is the fallback outside a style scope; the `style-*`
|
||||
* tokens win by specificity inside one, and survive the registry
|
||||
* transform as the single resolved value per generated style.
|
||||
*
|
||||
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||
* is a circle once it reaches half the box, so the soft styles used to
|
||||
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||
* size instead, so a tile still reads as the same shape when it scales.
|
||||
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||
*/
|
||||
radius: {
|
||||
default:
|
||||
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||
}
|
||||
|
||||
function IconTile({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
radius = "default",
|
||||
render,
|
||||
...props
|
||||
}: IconTileProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "icon-tile",
|
||||
"data-variant": variant,
|
||||
"data-size": size,
|
||||
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { IconTile, iconTileVariants, type IconTileProps }
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Ops / list / dashboard / detail / settings: **Frame** only.
|
||||
* KPI: horizontal compact hybrid via `reui-kit/KpiStatGrid`
|
||||
* (icon left + label/Badge + value ± variant).
|
||||
* (`IconTile` elevated `size-10.5` + label/Badge + value ± variant).
|
||||
* Lists: **data-grid-filtering-2** via `reui-kit/ResourcePage`.
|
||||
*
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
@@ -12,5 +12,9 @@
|
||||
*/
|
||||
export const UI_SURFACE = 'frame' as const
|
||||
|
||||
/** Skip link — keyboard: first Tab. */
|
||||
export const SKIP_TO_CONTENT_CLASS =
|
||||
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||
|
||||
/** Grid for horizontal compact hybrid KPI rows (3–6 tiles). */
|
||||
export const kpiStatGridClassName = '@container w-full'
|
||||
|
||||
@@ -10,7 +10,12 @@ import { QueryState } from '@/components/query-state'
|
||||
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 {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
@@ -168,14 +173,14 @@ function RenewalsPage() {
|
||||
]}
|
||||
/>
|
||||
{grouped.map(([weekLabel, weekItems]) => (
|
||||
<Card key={weekLabel}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Frame key={weekLabel} dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarIcon className="size-4" />
|
||||
{weekLabel}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2">
|
||||
{weekItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
@@ -197,8 +202,8 @@ function RenewalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import { Tooltip as RechartsTooltip } from 'recharts'
|
||||
|
||||
@@ -103,12 +109,12 @@ function ResourcesPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Ресурсы по хостерам</CardTitle>
|
||||
<CardDescription>Только активные VPS</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-80 flex-1 flex-col items-center justify-center">
|
||||
<Frame dense spacing="sm" className="flex flex-col">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Ресурсы по хостерам</FrameTitle>
|
||||
<FrameDescription>Только активные VPS</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-80 flex-1 flex-col items-center justify-center">
|
||||
{chartData.length === 0 ? (
|
||||
<EmptyState title="Нет данных для графика" className="min-h-80 w-full flex-1 py-0" />
|
||||
) : (
|
||||
@@ -124,8 +130,8 @@ function ResourcesPage() {
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -12,21 +12,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SettingsCard } from '@/components/reui-kit/settings-card'
|
||||
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { useSpaceId } from '@/lib/space'
|
||||
import {
|
||||
@@ -181,6 +174,140 @@ function SpacesPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const memberColumns: DataGridColumn<MemberRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'user',
|
||||
header: 'Пользователь',
|
||||
cell: (m) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm">{userLabelById.get(m.userId) ?? m.userId}</span>
|
||||
{!userLabelById.has(m.userId) ? (
|
||||
<span className="text-muted-foreground font-mono text-xs">{m.userId}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Роль', cell: (m) => m.role },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-36',
|
||||
cell: (m) =>
|
||||
canAdmin && m.role !== 'owner' ? (
|
||||
<ConfirmDialog
|
||||
title="Отозвать доступ?"
|
||||
description="Пользователь потеряет доступ к этому пространству."
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => removeMutation.mutate(m.userId)}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
Отозвать
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[canAdmin, removeMutation, userLabelById],
|
||||
)
|
||||
|
||||
const trashColumns: DataGridColumn<(typeof trash)[number]>[] = useMemo(
|
||||
() => [
|
||||
{ key: 'name', header: 'Название', cell: (s) => s.name },
|
||||
{
|
||||
key: 'deletedAt',
|
||||
header: 'Удалено',
|
||||
cell: (s) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{s.deletedAt ? new Date(s.deletedAt).toLocaleString('ru-RU') : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-64',
|
||||
cell: (s) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => restoreMutation.mutate(s.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
Восстановить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
title="Удалить навсегда?"
|
||||
description="Безвозвратно: все данные пространства будут уничтожены."
|
||||
confirmLabel="Удалить навсегда"
|
||||
destructive
|
||||
onConfirm={() => purgeMutation.mutate(s.id)}
|
||||
trigger={
|
||||
<Button size="sm" variant="destructive">
|
||||
Удалить навсегда
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[purgeMutation, restoreMutation],
|
||||
)
|
||||
|
||||
const addMemberForm = canAdmin ? (
|
||||
<div className="flex w-full flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<Label>Пользователь</Label>
|
||||
<AutoCompleteInput
|
||||
value={
|
||||
selectedUserId
|
||||
? (userLabelById.get(selectedUserId) ?? userQuery)
|
||||
: userQuery
|
||||
}
|
||||
onChange={(v) => {
|
||||
const match = userOptions.find(
|
||||
(o) => o.value === v || o.label === v,
|
||||
)
|
||||
if (match) {
|
||||
setSelectedUserId(match.value)
|
||||
setUserQuery(match.label)
|
||||
} else {
|
||||
setSelectedUserId('')
|
||||
setUserQuery(v)
|
||||
}
|
||||
}}
|
||||
options={userOptions}
|
||||
placeholder="Имя или email…"
|
||||
allowFreeText={false}
|
||||
emptyText="Пользователи не найдены"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 md:w-40">
|
||||
<Label>Роль</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="member">member</SelectItem>
|
||||
<SelectItem value="viewer">viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId.trim() || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -243,170 +370,34 @@ function SpacesPage() {
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
|
||||
<QueryState
|
||||
data={membersQuery.data as MemberRow[] | undefined}
|
||||
<ResourcePage
|
||||
title="Участники"
|
||||
description="Доступ к текущему пространству"
|
||||
columns={columnDefFromDataGrid(memberColumns)}
|
||||
data={membersQuery.data ?? []}
|
||||
getRowId={(m) => `${m.spaceId}-${m.userId}`}
|
||||
pagination={false}
|
||||
pinLastColumn
|
||||
isLoading={membersQuery.isLoading}
|
||||
isError={membersQuery.isError}
|
||||
error={membersQuery.error}
|
||||
error={membersQuery.error as Error | null}
|
||||
onRetry={() => void membersQuery.refetch()}
|
||||
empty={Boolean(membersQuery.data && membersQuery.data.length === 0)}
|
||||
toolbarExtra={addMemberForm}
|
||||
emptyTitle="Нет участников"
|
||||
emptyDescription="Добавьте пользователя по имени или email"
|
||||
>
|
||||
{(members) => (
|
||||
<SettingsCard title="Участники" description="Доступ к текущему пространству">
|
||||
{canAdmin ? (
|
||||
<div className="flex flex-col gap-3 border-b border-border/40 px-5 py-4 md:flex-row md:items-end">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label>Пользователь</Label>
|
||||
<AutoCompleteInput
|
||||
value={
|
||||
selectedUserId
|
||||
? (userLabelById.get(selectedUserId) ?? userQuery)
|
||||
: userQuery
|
||||
}
|
||||
onChange={(v) => {
|
||||
const match = userOptions.find(
|
||||
(o) => o.value === v || o.label === v,
|
||||
)
|
||||
if (match) {
|
||||
setSelectedUserId(match.value)
|
||||
setUserQuery(match.label)
|
||||
} else {
|
||||
setSelectedUserId('')
|
||||
setUserQuery(v)
|
||||
}
|
||||
}}
|
||||
options={userOptions}
|
||||
placeholder="Имя или email…"
|
||||
allowFreeText={false}
|
||||
emptyText="Пользователи не найдены"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 md:w-40">
|
||||
<Label>Роль</Label>
|
||||
<Select value={role} onValueChange={(v) => setRole(v ?? 'member')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="member">member</SelectItem>
|
||||
<SelectItem value="viewer">viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!selectedUserId.trim() || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Роль</TableHead>
|
||||
<TableHead className="w-36" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((m) => (
|
||||
<TableRow key={`${m.spaceId}-${m.userId}`}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm">
|
||||
{userLabelById.get(m.userId) ?? m.userId}
|
||||
</span>
|
||||
{!userLabelById.has(m.userId) ? (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{m.userId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{m.role}</TableCell>
|
||||
<TableCell>
|
||||
{canAdmin && m.role !== 'owner' ? (
|
||||
<ConfirmDialog
|
||||
title="Отозвать доступ?"
|
||||
description="Пользователь потеряет доступ к этому пространству."
|
||||
confirmLabel="Отозвать"
|
||||
destructive
|
||||
onConfirm={() => removeMutation.mutate(m.userId)}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
Отозвать
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<SettingsCard
|
||||
<ResourcePage
|
||||
title="Корзина"
|
||||
description="Удалённые пространства можно восстановить или удалить навсегда"
|
||||
>
|
||||
{trash.length === 0 ? (
|
||||
<p className="text-muted-foreground px-5 py-4 text-sm">Корзина пуста</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Название</TableHead>
|
||||
<TableHead>Удалено</TableHead>
|
||||
<TableHead className="w-64" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trash.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{s.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{s.deletedAt
|
||||
? new Date(s.deletedAt).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => restoreMutation.mutate(s.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
Восстановить
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
title="Удалить навсегда?"
|
||||
description="Безвозвратно: все данные пространства будут уничтожены."
|
||||
confirmLabel="Удалить навсегда"
|
||||
destructive
|
||||
onConfirm={() => purgeMutation.mutate(s.id)}
|
||||
trigger={
|
||||
<Button size="sm" variant="destructive">
|
||||
Удалить навсегда
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</SettingsCard>
|
||||
columns={columnDefFromDataGrid(trashColumns)}
|
||||
data={trash}
|
||||
getRowId={(s) => s.id}
|
||||
pagination={false}
|
||||
pinLastColumn
|
||||
emptyTitle="Корзина пуста"
|
||||
emptyDescription="Удалённых пространств нет"
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -18,8 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
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 {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
@@ -58,6 +63,30 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DetailFrame({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={className}>
|
||||
<FrameHeader>
|
||||
<FrameTitle className="flex items-center gap-2 text-base">
|
||||
{icon}
|
||||
{title}
|
||||
</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">{children}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function VpsDetailPage() {
|
||||
const { vpsId } = Route.useParams()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
@@ -175,54 +204,29 @@ function VpsDetailPage() {
|
||||
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GlobeIcon className="size-4" />
|
||||
Сеть
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<DetailFrame title="Сеть" icon={<GlobeIcon className="size-4" />}>
|
||||
<InfoRow label="IP" value={row.ip || '—'} />
|
||||
<InfoRow label="DNS" value={row.dns || '—'} />
|
||||
<InfoRow label="IPv6" value={(row as Vps & { ipv6?: string }).ipv6 || '—'} />
|
||||
<InfoRow label="SSH порт" value={(row as Vps & { sshPort?: number }).sshPort ?? 22} />
|
||||
<InfoRow label="Локация" value={[row.country, row.city].filter(Boolean).join(', ') || '—'} />
|
||||
<InfoRow label="Дата-центр" value={row.datacenter || '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CpuIcon className="size-4" />
|
||||
Ресурсы
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
</DetailFrame>
|
||||
<DetailFrame title="Ресурсы" icon={<CpuIcon className="size-4" />}>
|
||||
<InfoRow label="vCPU" value={row.vcpu} />
|
||||
<InfoRow label="RAM" value={`${row.ramGb} GB`} />
|
||||
<InfoRow label="Disk" value={`${row.diskGb} GB`} />
|
||||
<InfoRow label="ОС" value={(row as Vps & { os?: string }).os || '—'} />
|
||||
<InfoRow label="Хостер" value={provider?.name || '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
{vpsDomains.length > 0 ? (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Домены (CFDM)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailFrame title="Домены (CFDM)" className="md:col-span-2">
|
||||
<VpsDomainsCell domains={vpsDomains} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
) : null}
|
||||
</div>
|
||||
{customFieldRows.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Дополнительные поля</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<DetailFrame title="Дополнительные поля">
|
||||
{customFieldRows.map(({ def, value }) => (
|
||||
<InfoRow
|
||||
key={def.key}
|
||||
@@ -230,20 +234,12 @@ function VpsDetailPage() {
|
||||
value={formatCustomFieldValue(def, value)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="finance" className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CreditCardIcon className="size-4" />
|
||||
Тариф
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<DetailFrame title="Тариф" icon={<CreditCardIcon className="size-4" />}>
|
||||
<InfoRow label="Тип" value={tariffTypeLabel(row.tariffType)} />
|
||||
<InfoRow
|
||||
label="Ставка"
|
||||
@@ -259,8 +255,7 @@ function VpsDetailPage() {
|
||||
label="Оплачено до"
|
||||
value={paidUntil ? paidUntil.toLocaleDateString('ru-RU') : '—'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
<ResourcePage
|
||||
title="Связанные платежи"
|
||||
columns={columnDefFromDataGrid(paymentColumns)}
|
||||
@@ -272,28 +267,13 @@ function VpsDetailPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<StickyNoteIcon className="size-4" />
|
||||
Заметки
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailFrame title="Заметки" icon={<StickyNoteIcon className="size-4" />}>
|
||||
<p className="whitespace-pre-wrap text-sm">{row.notes?.trim() || 'Нет заметок'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sync">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Защита от перезаписи при синке
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DetailFrame title="Защита от перезаписи при синке" icon={<RefreshCwIcon className="size-4" />}>
|
||||
{overrides.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Все поля обновляются из BILLmanager</p>
|
||||
) : (
|
||||
@@ -304,8 +284,7 @@ function VpsDetailPage() {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DetailFrame>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user