fix(web): UX/UI аудит — пагинация, a11y, settings и TruncatedText
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Исправить обрезку Select в пагинации DataGrid, health-mode zero-results, подтверждение импорта бэкапа и унифицировать tooltip на обрезанном тексте. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -72,6 +72,7 @@ export function AccountFiltersToolbar({
|
|||||||
<SelectField
|
<SelectField
|
||||||
triggerClassName="w-full sm:w-48"
|
triggerClassName="w-full sm:w-48"
|
||||||
placeholder="Все хостеры"
|
placeholder="Все хостеры"
|
||||||
|
aria-label="Фильтр по хостеру"
|
||||||
value={filters.providerIds[0] ?? null}
|
value={filters.providerIds[0] ?? null}
|
||||||
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
|
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
|
||||||
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
options={providers.map((p) => ({ value: p.id, label: p.name }))}
|
||||||
@@ -79,6 +80,7 @@ export function AccountFiltersToolbar({
|
|||||||
<SelectField
|
<SelectField
|
||||||
triggerClassName="w-full sm:w-40"
|
triggerClassName="w-full sm:w-40"
|
||||||
placeholder="Любой биллинг"
|
placeholder="Любой биллинг"
|
||||||
|
aria-label="Фильтр по режиму биллинга"
|
||||||
value={filters.billingMode || null}
|
value={filters.billingMode || null}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
onChange({
|
onChange({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
AutocompleteItem,
|
AutocompleteItem,
|
||||||
AutocompleteList,
|
AutocompleteList,
|
||||||
} from '@/components/reui/autocomplete'
|
} from '@/components/reui/autocomplete'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
export interface AutoCompleteOption {
|
export interface AutoCompleteOption {
|
||||||
value: string
|
value: string
|
||||||
@@ -114,7 +115,9 @@ export function AutoCompleteInput({
|
|||||||
{item.leading ? (
|
{item.leading ? (
|
||||||
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
|
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="relative z-1 min-w-0 flex-1 truncate">{item.label}</span>
|
<span className="relative z-1 min-w-0 flex-1">
|
||||||
|
<TruncatedText>{item.label}</TruncatedText>
|
||||||
|
</span>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
|
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const PAGINATION_LABELS = {
|
|||||||
info: '{from}–{to} из {count}',
|
info: '{from}–{to} из {count}',
|
||||||
previousPageLabel: 'Предыдущая страница',
|
previousPageLabel: 'Предыдущая страница',
|
||||||
nextPageLabel: 'Следующая страница',
|
nextPageLabel: 'Следующая страница',
|
||||||
|
pageLabel: 'Страница {page}',
|
||||||
|
previousPagesLabel: 'Предыдущие страницы',
|
||||||
|
nextPagesLabel: 'Следующие страницы',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||||
@@ -140,7 +143,7 @@ function DataGridSectionHeader({
|
|||||||
|
|
||||||
function DataGridPaginationBar() {
|
function DataGridPaginationBar() {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2.5">
|
<div className="border-t border-border px-4 py-2.5">
|
||||||
<DataGridPagination {...PAGINATION_LABELS} />
|
<DataGridPagination {...PAGINATION_LABELS} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -170,46 +173,41 @@ function DataGridCardBody<TData extends object>({
|
|||||||
enableColumnVisibility: boolean
|
enableColumnVisibility: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DataGridContainer border={false}>
|
<DataGrid
|
||||||
<DataGrid
|
table={table}
|
||||||
table={table}
|
recordCount={data.length}
|
||||||
recordCount={data.length}
|
onRowClick={onRowClick}
|
||||||
onRowClick={onRowClick}
|
emptyMessage={emptyTitle}
|
||||||
emptyMessage={emptyTitle}
|
tableLayout={{
|
||||||
tableLayout={{
|
dense,
|
||||||
dense,
|
stripped: true,
|
||||||
stripped: true,
|
rowBorder: true,
|
||||||
rowBorder: true,
|
headerSticky: true,
|
||||||
headerSticky: true,
|
headerBackground: true,
|
||||||
headerBackground: true,
|
headerBorder: true,
|
||||||
headerBorder: true,
|
width: 'auto',
|
||||||
width: 'auto',
|
columnsVisibility: enableColumnVisibility,
|
||||||
columnsVisibility: enableColumnVisibility,
|
columnsResizable: false,
|
||||||
columnsResizable: false,
|
columnsPinnable: false,
|
||||||
columnsPinnable: false,
|
columnsMovable: false,
|
||||||
columnsMovable: false,
|
rowsDraggable: false,
|
||||||
rowsDraggable: false,
|
rowsPinnable: false,
|
||||||
rowsPinnable: false,
|
}}
|
||||||
}}
|
tableClassNames={{
|
||||||
tableClassNames={{
|
header: 'text-xs font-medium text-muted-foreground',
|
||||||
header: 'text-xs font-medium text-muted-foreground',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<DataGridContainer border={false}>
|
||||||
{virtualization ? (
|
{virtualization ? (
|
||||||
<>
|
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
</DataGridScrollArea>
|
||||||
</DataGridScrollArea>
|
|
||||||
{showPagination ? <DataGridPaginationBar /> : null}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DataGridTable footerContent={footerContent} />
|
||||||
<DataGridTable footerContent={footerContent} />
|
|
||||||
{showPagination ? <DataGridPaginationBar /> : null}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DataGrid>
|
</DataGridContainer>
|
||||||
</DataGridContainer>
|
{showPagination ? <DataGridPaginationBar /> : null}
|
||||||
|
</DataGrid>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
export function dataGridCellStack(
|
export function dataGridCellStack(
|
||||||
primary: ReactNode,
|
primary: ReactNode,
|
||||||
@@ -9,9 +10,17 @@ export function dataGridCellStack(
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
|
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
|
||||||
<span className="truncate font-medium">{primary}</span>
|
{typeof primary === 'string' || typeof primary === 'number' ? (
|
||||||
|
<TruncatedText className="font-medium">{primary}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-medium">{primary}</span>
|
||||||
|
)}
|
||||||
{secondary ? (
|
{secondary ? (
|
||||||
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
|
typeof secondary === 'string' || typeof secondary === 'number' ? (
|
||||||
|
<TruncatedText className="max-w-[14rem] text-xs text-muted-foreground">{secondary}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function MonthlyExpenseChart({
|
|||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных для графика" />
|
<ChartEmpty message="Нет данных для графика" />
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
|
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full" aria-label="График расходов по VPS">
|
||||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
@@ -155,7 +155,7 @@ export function PaymentsPieChart({
|
|||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных о платежах" />
|
<ChartEmpty message="Нет данных о платежах" />
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full">
|
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full" aria-label="График платежей по типам">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
content={
|
content={
|
||||||
@@ -215,7 +215,7 @@ export function MonthlyTrendChart({
|
|||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных за выбранный период" />
|
<ChartEmpty message="Нет данных за выбранный период" />
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={trendConfig} className="h-72 w-full">
|
<ChartContainer config={trendConfig} className="h-72 w-full" aria-label="График тренда расходов">
|
||||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
@@ -281,7 +281,7 @@ export function ProjectExpenseChart({
|
|||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных для графика" />
|
<ChartEmpty message="Нет данных для графика" />
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={chartConfig} className="h-72 w-full">
|
<ChartContainer config={chartConfig} className="h-72 w-full" aria-label="График расходов по проектам">
|
||||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function VpsBulkToolbar({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<SelectField
|
<SelectField
|
||||||
placeholder="Проект…"
|
placeholder="Проект…"
|
||||||
|
aria-label="Проект для массового назначения"
|
||||||
value={projectValue}
|
value={projectValue}
|
||||||
onValueChange={(v) => setProjectValue(v ?? '')}
|
onValueChange={(v) => setProjectValue(v ?? '')}
|
||||||
options={projectOptions.map((p) => ({ value: p, label: p }))}
|
options={projectOptions.map((p) => ({ value: p, label: p }))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
|
||||||
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
|
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
@@ -10,11 +10,33 @@ interface FormFieldProps {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withFieldA11y(child: ReactNode, isInvalid: boolean, htmlFor?: string): ReactNode {
|
||||||
|
if (!isValidElement(child)) return child
|
||||||
|
|
||||||
|
const childProps = child.props as Record<string, unknown>
|
||||||
|
const props: Record<string, unknown> = {}
|
||||||
|
if (isInvalid) {
|
||||||
|
props['aria-invalid'] = true
|
||||||
|
props.invalid = true
|
||||||
|
}
|
||||||
|
if (htmlFor && childProps.id == null && childProps.triggerId == null) {
|
||||||
|
if ('triggerId' in childProps) {
|
||||||
|
props.triggerId = htmlFor
|
||||||
|
} else {
|
||||||
|
props.id = htmlFor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(props).length > 0 ? cloneElement(child as ReactElement, props) : child
|
||||||
|
}
|
||||||
|
|
||||||
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
|
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
|
||||||
|
const isInvalid = invalid || Boolean(error)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field data-invalid={invalid || Boolean(error)}>
|
<Field data-invalid={isInvalid}>
|
||||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||||
{children}
|
{withFieldA11y(children, isInvalid, htmlFor)}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
{error ? <FieldError>{error}</FieldError> : null}
|
{error ? <FieldError>{error}</FieldError> : null}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@cfdm/ui/components/command'
|
} from '@cfdm/ui/components/command'
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { providerByIdMap } from '@/lib/billmanager'
|
import { providerByIdMap } from '@/lib/billmanager'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
interface GlobalSearchProps {
|
interface GlobalSearchProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -73,7 +74,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
|
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
|
||||||
>
|
>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
<span className="truncate">{v.ip || v.dns || v.id}</span>
|
<TruncatedText>{v.ip || v.dns || v.id}</TruncatedText>
|
||||||
{v.project ? (
|
{v.project ? (
|
||||||
<span className="text-muted-foreground text-xs">{v.project}</span>
|
<span className="text-muted-foreground text-xs">{v.project}</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -89,7 +90,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
onSelect={() => go('/accounts')}
|
onSelect={() => go('/accounts')}
|
||||||
>
|
>
|
||||||
<WalletIcon />
|
<WalletIcon />
|
||||||
<span className="truncate">{a.name}</span>
|
<TruncatedText>{a.name}</TruncatedText>
|
||||||
{providerById.get(a.providerId)?.name ? (
|
{providerById.get(a.providerId)?.name ? (
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
{providerById.get(a.providerId)?.name}
|
{providerById.get(a.providerId)?.name}
|
||||||
@@ -109,7 +110,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
onSelect={() => go('/vps', { project: row.name })}
|
onSelect={() => go('/vps', { project: row.name })}
|
||||||
>
|
>
|
||||||
<FolderKanbanIcon />
|
<FolderKanbanIcon />
|
||||||
<span className="truncate">{row.name}</span>
|
<TruncatedText>{row.name}</TruncatedText>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -119,7 +120,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
|||||||
{(snapshot?.providers ?? []).map((p) => (
|
{(snapshot?.providers ?? []).map((p) => (
|
||||||
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
|
||||||
<Building2Icon />
|
<Building2Icon />
|
||||||
<span className="truncate">{p.name}</span>
|
<TruncatedText>{p.name}</TruncatedText>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { ModeToggle } from '@/components/mode-toggle'
|
|||||||
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
||||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
import { formatRelativeSyncTime } from '@/lib/sync-format'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
@@ -200,9 +201,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
|
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
|
||||||
<Settings />
|
<Settings />
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<TruncatedText
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tooltip={`Синк: ${formatRelativeSyncTime(stats?.lastGlobalSyncAt)}`}
|
||||||
|
>
|
||||||
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
|
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
|
||||||
</span>
|
</TruncatedText>
|
||||||
{stats?.staleSyncAccountCount ? (
|
{stats?.staleSyncAccountCount ? (
|
||||||
<Badge variant="outline" className="ml-auto text-xs">
|
<Badge variant="outline" className="ml-auto text-xs">
|
||||||
<RefreshCwIcon className="size-3" />
|
<RefreshCwIcon className="size-3" />
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface ProjectColorDotProps {
|
||||||
|
color?: string | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectColorDot({ color, className }: ProjectColorDotProps) {
|
||||||
|
if (!color) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('inline-block size-2.5 shrink-0 rounded-full', className)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ export function ReportsFiltersToolbar({
|
|||||||
<SelectField
|
<SelectField
|
||||||
triggerClassName="w-full sm:w-44"
|
triggerClassName="w-full sm:w-44"
|
||||||
placeholder="Период"
|
placeholder="Период"
|
||||||
|
aria-label="Период отчёта"
|
||||||
value={filters.period}
|
value={filters.period}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' })
|
onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' })
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ interface DataGridPaginationProps {
|
|||||||
rowsPerPageLabel?: string
|
rowsPerPageLabel?: string
|
||||||
previousPageLabel?: string
|
previousPageLabel?: string
|
||||||
nextPageLabel?: string
|
nextPageLabel?: string
|
||||||
|
pageLabel?: string
|
||||||
|
previousPagesLabel?: string
|
||||||
|
nextPagesLabel?: string
|
||||||
ellipsisText?: string
|
ellipsisText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +50,9 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
rowsPerPageLabel: "Rows per page",
|
rowsPerPageLabel: "Rows per page",
|
||||||
previousPageLabel: "Go to previous page",
|
previousPageLabel: "Go to previous page",
|
||||||
nextPageLabel: "Go to next page",
|
nextPageLabel: "Go to next page",
|
||||||
|
pageLabel: "Page {page}",
|
||||||
|
previousPagesLabel: "Previous pages",
|
||||||
|
nextPagesLabel: "Next pages",
|
||||||
ellipsisText: "...",
|
ellipsisText: "...",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +94,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
key={i}
|
key={i}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
|
||||||
|
aria-current={pageIndex === i ? "page" : undefined}
|
||||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||||
"bg-accent text-accent-foreground": pageIndex === i,
|
"bg-accent text-accent-foreground": pageIndex === i,
|
||||||
})}
|
})}
|
||||||
@@ -112,6 +120,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className={btnBaseClasses}
|
className={btnBaseClasses}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
aria-label={mergedProps.previousPagesLabel}
|
||||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||||
>
|
>
|
||||||
{mergedProps.ellipsisText}
|
{mergedProps.ellipsisText}
|
||||||
@@ -129,6 +138,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
className={btnBaseClasses}
|
className={btnBaseClasses}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
aria-label={mergedProps.nextPagesLabel}
|
||||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||||
>
|
>
|
||||||
{mergedProps.ellipsisText}
|
{mergedProps.ellipsisText}
|
||||||
@@ -146,7 +156,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
mergedProps?.className
|
mergedProps?.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
mergedProps?.sizesSkeleton
|
mergedProps?.sizesSkeleton
|
||||||
) : (
|
) : (
|
||||||
@@ -164,7 +174,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
<SelectTrigger className="w-14" size="sm">
|
<SelectTrigger className="w-14" size="sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top" className="min-w-18">
|
<SelectContent className="min-w-18">
|
||||||
{mergedProps?.sizes?.map((size: number) => (
|
{mergedProps?.sizes?.map((size: number) => (
|
||||||
<SelectItem key={size} value={`${size}`}>
|
<SelectItem key={size} value={`${size}`}>
|
||||||
{size}
|
{size}
|
||||||
@@ -184,7 +194,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
|||||||
{paginationInfo}
|
{paginationInfo}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 && (
|
{pageCount > 1 && (
|
||||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
<div className="order-1 flex items-center gap-1 sm:order-2">
|
||||||
<Button
|
<Button
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
export interface SectionCardItem {
|
export interface SectionCardItem {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
@@ -15,7 +16,7 @@ export interface SectionCardItem {
|
|||||||
|
|
||||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
default: '',
|
default: '',
|
||||||
warning: 'border-amber-500/50',
|
warning: 'border-warning/50',
|
||||||
destructive: 'border-destructive/50',
|
destructive: 'border-destructive/50',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,13 +43,21 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
{typeof item.label === 'string' ? (
|
||||||
|
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||||
|
)}
|
||||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||||
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
|
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
|
||||||
{item.hint ? (
|
{item.hint ? (
|
||||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
typeof item.hint === 'string' ? (
|
||||||
|
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'valu
|
|||||||
size?: 'sm' | 'default'
|
size?: 'sm' | 'default'
|
||||||
value?: string | null
|
value?: string | null
|
||||||
onValueChange?: (value: string | null) => void
|
onValueChange?: (value: string | null) => void
|
||||||
|
invalid?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectField({
|
export function SelectField({
|
||||||
@@ -33,6 +35,8 @@ export function SelectField({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
invalid,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
...props
|
...props
|
||||||
}: SelectFieldProps) {
|
}: SelectFieldProps) {
|
||||||
const items = React.useMemo(
|
const items = React.useMemo(
|
||||||
@@ -42,7 +46,13 @@ export function SelectField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
|
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
|
||||||
<SelectTrigger id={triggerId} size={size} className={cn('w-full', triggerClassName)}>
|
<SelectTrigger
|
||||||
|
id={triggerId}
|
||||||
|
size={size}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
className={cn('w-full', triggerClassName)}
|
||||||
|
>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
interface TruncatedTextProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
as?: 'span' | 'p' | 'div'
|
||||||
|
/** Явный текст подсказки, если children — не строка. */
|
||||||
|
tooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
|
||||||
|
const tip =
|
||||||
|
tooltip ??
|
||||||
|
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
|
||||||
|
|
||||||
|
if (!tip) {
|
||||||
|
return <Tag className={cn('truncate', className)}>{children}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
|
||||||
|
<TooltipContent>{tip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { PlusIcon } from 'lucide-react'
|
import { PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
|
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 '@/components/data-grid-card'
|
||||||
import type { VisibilityState } from '@tanstack/react-table'
|
import type { VisibilityState } from '@tanstack/react-table'
|
||||||
|
|
||||||
@@ -652,14 +653,22 @@ export function VpsFiltersToolbar({
|
|||||||
>
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<ConfirmDialog
|
||||||
type="button"
|
title="Удалить пресет?"
|
||||||
onClick={() => deletePreset(p.name)}
|
description={`Пресет «${p.name}» будет удалён без возможности восстановления.`}
|
||||||
aria-label="Удалить пресет"
|
confirmLabel="Удалить"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
destructive
|
||||||
>
|
onConfirm={() => deletePreset(p.name)}
|
||||||
<Trash2Icon className="size-3.5" />
|
trigger={
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить пресет ${p.name}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +91,7 @@ function buildSavePayload(r: ProviderAccountFormValues) {
|
|||||||
|
|
||||||
function AccountsPage() {
|
function AccountsPage() {
|
||||||
const { health } = Route.useSearch()
|
const { health } = Route.useSearch()
|
||||||
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -451,8 +452,16 @@ function AccountsPage() {
|
|||||||
}
|
}
|
||||||
emptyAction={
|
emptyAction={
|
||||||
health || hasActiveAccountFilters(filters) ? (
|
health || hasActiveAccountFilters(filters) ? (
|
||||||
<Button variant="outline" onClick={() => setFilters(buildDefaultAccountFilters())}>
|
<Button
|
||||||
Сбросить фильтры
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFilters(buildDefaultAccountFilters())
|
||||||
|
if (health) {
|
||||||
|
void navigate({ to: '/accounts', search: {} })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{health ? 'Выйти из режима и сбросить фильтры' : 'Сбросить фильтры'}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card
|
|||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
|
||||||
|
const AUDIT_ENTITY_LABELS: Record<string, string> = {
|
||||||
|
vps: 'VPS',
|
||||||
|
payment: 'Платёж',
|
||||||
|
providerAccount: 'Аккаунт',
|
||||||
|
provider: 'Хостер',
|
||||||
|
settings: 'Настройки',
|
||||||
|
balanceLedger: 'Баланс',
|
||||||
|
serverProject: 'Проект',
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditEntityLabel(entity: string): string {
|
||||||
|
return AUDIT_ENTITY_LABELS[entity] ?? entity
|
||||||
|
}
|
||||||
|
|
||||||
interface AuditRow {
|
interface AuditRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -51,7 +66,7 @@ function AuditPage() {
|
|||||||
{
|
{
|
||||||
key: 'entity',
|
key: 'entity',
|
||||||
header: 'Сущность',
|
header: 'Сущность',
|
||||||
cell: (r) => <Badge variant="outline">{r.entity}</Badge>,
|
cell: (r) => <Badge variant="outline">{auditEntityLabel(r.entity)}</Badge>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'action',
|
key: 'action',
|
||||||
@@ -91,6 +106,7 @@ function AuditPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
|
skeleton={<TableSkeleton />}
|
||||||
empty={!data?.length}
|
empty={!data?.length}
|
||||||
emptyTitle="Записей нет"
|
emptyTitle="Записей нет"
|
||||||
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
|
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ function BalancePage() {
|
|||||||
rowId={(r) => r.id}
|
rowId={(r) => r.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
footerContent={
|
footerContent={
|
||||||
<div className="flex justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
||||||
<span>
|
<span>
|
||||||
Приходы: <b className="text-foreground">{formatCurrency(totalCredit, baseCurrency)}</b>
|
Приходы: <b className="text-foreground">{formatCurrency(totalCredit, baseCurrency)}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type InventoryIssue = { key: string; title: string; count: number; to: string; h
|
|||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const { data: stats } = useQuery(dashboardStatsQueryOptions())
|
const { data: stats, isLoading: statsLoading } = useQuery(dashboardStatsQueryOptions())
|
||||||
const settings = snapshot?.settings?.[0]
|
const settings = snapshot?.settings?.[0]
|
||||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||||
@@ -204,6 +204,9 @@ function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 md:gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
{statsLoading ? (
|
||||||
|
<SectionCardsSkeleton count={6} />
|
||||||
|
) : (
|
||||||
<SectionCards
|
<SectionCards
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
@@ -269,6 +272,7 @@ function DashboardPage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
|||||||
@@ -26,16 +26,8 @@ import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/proje
|
|||||||
import type { ProjectFormValues } from '@/lib/schemas'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import {
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
AlertDialog,
|
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@cfdm/ui/components/alert-dialog'
|
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
normalizeRatesPayload,
|
normalizeRatesPayload,
|
||||||
@@ -72,7 +64,6 @@ function ProjectDetailPage() {
|
|||||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
||||||
|
|
||||||
const project = snapshot ? findProject(snapshot, projectId) : undefined
|
const project = snapshot ? findProject(snapshot, projectId) : undefined
|
||||||
const projectVps = useMemo(
|
const projectVps = useMemo(
|
||||||
@@ -218,19 +209,31 @@ function ProjectDetailPage() {
|
|||||||
<PencilIcon data-icon="inline-start" />
|
<PencilIcon data-icon="inline-start" />
|
||||||
Изменить
|
Изменить
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{projectVps.length > 0 ? (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => {
|
variant="outline"
|
||||||
if (projectVps.length > 0) {
|
onClick={() =>
|
||||||
toast.error(`Нельзя удалить: к проекту привязано ${projectVps.length} VPS`)
|
toast.error(`Нельзя удалить: к проекту привязано ${projectVps.length} VPS`)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
setDeleteOpen(true)
|
>
|
||||||
}}
|
<Trash2Icon data-icon="inline-start" />
|
||||||
>
|
Удалить
|
||||||
<Trash2Icon data-icon="inline-start" />
|
</Button>
|
||||||
Удалить
|
) : (
|
||||||
</Button>
|
<ConfirmDialog
|
||||||
|
title="Удалить проект?"
|
||||||
|
description={`«${project.name}» будет удалён без возможности восстановления.`}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => delMut.mutate()}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" disabled={delMut.isPending}>
|
||||||
|
<Trash2Icon data-icon="inline-start" />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -241,6 +244,12 @@ function ProjectDetailPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
|
skeleton={
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionCardsSkeleton count={3} />
|
||||||
|
<TableSkeleton />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{() =>
|
{() =>
|
||||||
!project ? (
|
!project ? (
|
||||||
@@ -313,26 +322,6 @@ function ProjectDetailPage() {
|
|||||||
onSubmit={(values) => saveMut.mutate(values)}
|
onSubmit={(values) => saveMut.mutate(values)}
|
||||||
submitting={saveMut.isPending}
|
submitting={saveMut.isPending}
|
||||||
/>
|
/>
|
||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Удалить проект?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
«{project.name}» будет удалён без возможности восстановления.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => delMut.mutate()}
|
|
||||||
disabled={delMut.isPending}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { ProjectColorDot } from '@/components/project-color-dot'
|
||||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||||
import type { ProjectFormValues } from '@/lib/schemas'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||||
@@ -133,9 +134,7 @@ function ProjectsPage() {
|
|||||||
icon: FolderKanbanIcon,
|
icon: FolderKanbanIcon,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{row.color ? (
|
<ProjectColorDot color={row.color} />
|
||||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0 font-medium"
|
className="h-auto p-0 font-medium"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from '@/components/page-header'
|
|||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -110,6 +111,7 @@ function RenewalsPage() {
|
|||||||
actions={
|
actions={
|
||||||
<SelectField
|
<SelectField
|
||||||
value={horizon}
|
value={horizon}
|
||||||
|
aria-label="Горизонт продлений"
|
||||||
onValueChange={(v) => setHorizon((v ?? '30') as Horizon)}
|
onValueChange={(v) => setHorizon((v ?? '30') as Horizon)}
|
||||||
options={[
|
options={[
|
||||||
{ value: '7', label: '7 дней' },
|
{ value: '7', label: '7 дней' },
|
||||||
@@ -127,7 +129,16 @@ function RenewalsPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
skeleton={<SectionCardsSkeleton count={3} />}
|
skeleton={
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionCardsSkeleton 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" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
empty={items.length === 0}
|
empty={items.length === 0}
|
||||||
emptyTitle="Нет продлений в выбранном периоде"
|
emptyTitle="Нет продлений в выбранном периоде"
|
||||||
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
|
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
|
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { AnalyticsPage } from '@/components/analytics-page'
|
import { AnalyticsPage } from '@/components/analytics-page'
|
||||||
import { SectionCards } from '@/components/section-cards'
|
import { SectionCards } from '@/components/section-cards'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -102,7 +103,10 @@ function ResourcesPage() {
|
|||||||
<CardDescription>Только активные VPS</CardDescription>
|
<CardDescription>Только активные VPS</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
|
{chartData.length === 0 ? (
|
||||||
|
<EmptyState title="Нет данных для графика" />
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full" aria-label="Ресурсы по хостерам">
|
||||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
@@ -113,6 +117,7 @@ function ResourcesPage() {
|
|||||||
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
|
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useForm, Controller } from 'react-hook-form'
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
import { DownloadIcon, UploadIcon } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
|
|
||||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
import { api, ApiError } from '@/lib/api-client'
|
import { api, ApiError } from '@/lib/api-client'
|
||||||
@@ -12,6 +12,17 @@ import { PageShell } from '@/components/page-shell'
|
|||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@cfdm/ui/components/table'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
@@ -32,6 +43,20 @@ export const Route = createFileRoute('/_auth/settings')({
|
|||||||
|
|
||||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
||||||
|
|
||||||
|
const NOTIFICATION_STATUS_MAP: Record<string, string> = {
|
||||||
|
sent: 'ok',
|
||||||
|
failed: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFICATION_STATUS_LABELS: Record<string, string> = {
|
||||||
|
sent: 'Отправлено',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationStatusLabel(status: string): string {
|
||||||
|
return NOTIFICATION_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
function settingsToFormValues(s: Settings): SettingsFormValues {
|
function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -162,6 +187,50 @@ function SettingsPage() {
|
|||||||
queryFn: () => api.fetchNotificationLog(30),
|
queryFn: () => api.fetchNotificationLog(30),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const importJsonMut = useMutation({
|
||||||
|
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
toast.success('Импорт JSON выполнен')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const importDbMut = useMutation({
|
||||||
|
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||||
|
toast.success('Импорт SQLite выполнен')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pickJsonFile = useCallback(() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'application/json,.json'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const text = await file.text()
|
||||||
|
importJsonMut.mutate(text)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}, [importJsonMut])
|
||||||
|
|
||||||
|
const pickDbFile = useCallback(() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.db,application/octet-stream'
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
importDbMut.mutate(buffer)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}, [importDbMut])
|
||||||
|
|
||||||
const notificationRows = useMemo(
|
const notificationRows = useMemo(
|
||||||
() => notificationLog as NotificationLogRow[],
|
() => notificationLog as NotificationLogRow[],
|
||||||
[notificationLog],
|
[notificationLog],
|
||||||
@@ -209,54 +278,40 @@ function SettingsPage() {
|
|||||||
<DownloadIcon data-icon="inline-start" />
|
<DownloadIcon data-icon="inline-start" />
|
||||||
SQLite
|
SQLite
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<ConfirmDialog
|
||||||
variant="outline"
|
title="Импортировать JSON?"
|
||||||
onClick={() => {
|
description="Текущие данные будут перезаписаны содержимым файла резервной копии."
|
||||||
const input = document.createElement('input')
|
confirmLabel="Выбрать файл"
|
||||||
input.type = 'file'
|
destructive
|
||||||
input.accept = 'application/json,.json'
|
onConfirm={pickJsonFile}
|
||||||
input.onchange = async () => {
|
trigger={
|
||||||
const file = input.files?.[0]
|
<LoadingButton
|
||||||
if (!file) return
|
type="button"
|
||||||
try {
|
variant="outline"
|
||||||
const text = await file.text()
|
loading={importJsonMut.isPending}
|
||||||
await api.importBackupJson(JSON.parse(text))
|
>
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
<UploadIcon data-icon="inline-start" />
|
||||||
toast.success('Импорт JSON выполнен')
|
Импорт JSON
|
||||||
} catch (e) {
|
</LoadingButton>
|
||||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
}
|
||||||
}
|
/>
|
||||||
}
|
<ConfirmDialog
|
||||||
input.click()
|
title="Импортировать SQLite?"
|
||||||
}}
|
description="Текущая база данных будет полностью заменена загруженным файлом .db."
|
||||||
>
|
confirmLabel="Выбрать файл"
|
||||||
<UploadIcon data-icon="inline-start" />
|
destructive
|
||||||
Импорт JSON
|
onConfirm={pickDbFile}
|
||||||
</Button>
|
trigger={
|
||||||
<Button
|
<LoadingButton
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={() => {
|
variant="outline"
|
||||||
const input = document.createElement('input')
|
loading={importDbMut.isPending}
|
||||||
input.type = 'file'
|
>
|
||||||
input.accept = '.db,application/octet-stream'
|
<UploadIcon data-icon="inline-start" />
|
||||||
input.onchange = async () => {
|
Импорт SQLite
|
||||||
const file = input.files?.[0]
|
</LoadingButton>
|
||||||
if (!file) return
|
}
|
||||||
try {
|
/>
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
await api.importBackupDatabase(buffer)
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
|
||||||
toast.success('Импорт SQLite выполнен')
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input.click()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UploadIcon data-icon="inline-start" />
|
|
||||||
Импорт SQLite
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -273,7 +328,7 @@ function SettingsPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
skeleton={<SectionCardsSkeleton count={1} />}
|
skeleton={<SectionCardsSkeleton count={3} />}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<form
|
<form
|
||||||
@@ -478,42 +533,45 @@ function SettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{notificationRows.length === 0 ? (
|
{notificationRows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Записей пока нет</p>
|
<EmptyState title="Записей пока нет" />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<Table>
|
||||||
<table className="w-full text-sm">
|
<TableHeader>
|
||||||
<thead>
|
<TableRow>
|
||||||
<tr className="border-b bg-muted/50 text-left">
|
<TableHead>Время</TableHead>
|
||||||
<th className="px-3 py-2 font-medium">Время</th>
|
<TableHead>Событие</TableHead>
|
||||||
<th className="px-3 py-2 font-medium">Событие</th>
|
<TableHead>Канал</TableHead>
|
||||||
<th className="px-3 py-2 font-medium">Канал</th>
|
<TableHead>Статус</TableHead>
|
||||||
<th className="px-3 py-2 font-medium">Статус</th>
|
<TableHead>Ошибка</TableHead>
|
||||||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
</TableRow>
|
||||||
</tr>
|
</TableHeader>
|
||||||
</thead>
|
<TableBody>
|
||||||
<tbody>
|
{notificationRows.map((row) => {
|
||||||
{notificationRows.map((row) => {
|
const errorText =
|
||||||
const errorText =
|
row.status === 'failed' && row.payload?.error != null
|
||||||
row.status === 'failed' && row.payload?.error != null
|
? String(row.payload.error)
|
||||||
? String(row.payload.error)
|
: ''
|
||||||
: ''
|
return (
|
||||||
return (
|
<TableRow key={row.id}>
|
||||||
<tr key={row.id} className="border-b last:border-0">
|
<TableCell className="whitespace-nowrap text-muted-foreground">
|
||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
|
||||||
{new Date(row.createdAt).toLocaleString('ru-RU')}
|
{new Date(row.createdAt).toLocaleString('ru-RU')}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2">{row.event}</td>
|
<TableCell>{row.event}</TableCell>
|
||||||
<td className="px-3 py-2">{row.channel}</td>
|
<TableCell>{row.channel}</TableCell>
|
||||||
<td className="px-3 py-2">{row.status}</td>
|
<TableCell>
|
||||||
<td className="max-w-xs px-3 py-2 text-xs text-destructive break-words">
|
<StatusBadge
|
||||||
|
status={NOTIFICATION_STATUS_MAP[row.status] ?? row.status}
|
||||||
|
label={notificationStatusLabel(row.status)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs break-words text-xs text-destructive">
|
||||||
{errorText || '—'}
|
{errorText || '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -340,9 +340,13 @@ function TariffsPage() {
|
|||||||
<AlertDescription className="flex flex-col gap-1">
|
<AlertDescription className="flex flex-col gap-1">
|
||||||
{tariffDiffs.slice(0, 5).map((d) => (
|
{tariffDiffs.slice(0, 5).map((d) => (
|
||||||
<span key={d.vpsId}>
|
<span key={d.vpsId}>
|
||||||
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} />}
|
||||||
|
>
|
||||||
{d.vpsLabel}
|
{d.vpsLabel}
|
||||||
</Link>
|
</Button>
|
||||||
{' '}({d.tariffName}): {d.issues.join('; ')}
|
{' '}({d.tariffName}): {d.issues.join('; ')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -372,9 +376,13 @@ function TariffsPage() {
|
|||||||
<AlertDescription className="flex flex-col gap-1">
|
<AlertDescription className="flex flex-col gap-1">
|
||||||
{tariffDiffs.slice(0, 5).map((d) => (
|
{tariffDiffs.slice(0, 5).map((d) => (
|
||||||
<span key={d.vpsId}>
|
<span key={d.vpsId}>
|
||||||
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0"
|
||||||
|
render={<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} />}
|
||||||
|
>
|
||||||
{d.vpsLabel}
|
{d.vpsLabel}
|
||||||
</Link>
|
</Button>
|
||||||
{' '}({d.tariffName}): {d.issues.join('; ')}
|
{' '}({d.tariffName}): {d.issues.join('; ')}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
|||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Badge } from '@cfdm/ui/components/badge'
|
import { Badge } from '@cfdm/ui/components/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
@@ -116,9 +118,11 @@ function VpsDetailPage() {
|
|||||||
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
|
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
|
||||||
description={account ? accountSelectLabel(account, providerById) : undefined}
|
description={account ? accountSelectLabel(account, providerById) : undefined}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
|
vps ? (
|
||||||
Редактировать
|
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
|
||||||
</Button>
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -133,6 +137,15 @@ function VpsDetailPage() {
|
|||||||
isError={isError}
|
isError={isError}
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
|
skeleton={
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
empty={!isLoading && !vps}
|
empty={!isLoading && !vps}
|
||||||
emptyTitle="VPS не найден"
|
emptyTitle="VPS не найден"
|
||||||
emptyDescription="Запись могла быть удалена"
|
emptyDescription="Запись могла быть удалена"
|
||||||
@@ -151,9 +164,7 @@ function VpsDetailPage() {
|
|||||||
|
|
||||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
|
<StatusBadge status={row.status} label={vpsStatusLabel(row.status)} />
|
||||||
{vpsStatusLabel(row.status)}
|
|
||||||
</Badge>
|
|
||||||
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
|
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
|
||||||
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
|
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from '@/components/vps-filters'
|
} from '@/components/vps-filters'
|
||||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||||
|
import { ProjectColorDot } from '@/components/project-color-dot'
|
||||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
@@ -189,7 +190,7 @@ function VpsPage() {
|
|||||||
value: name,
|
value: name,
|
||||||
label: name,
|
label: name,
|
||||||
leading: color ? (
|
leading: color ? (
|
||||||
<span className="size-2.5 shrink-0 rounded-full ring-1 ring-foreground/10" style={{ backgroundColor: color }} />
|
<ProjectColorDot color={color} className="ring-1 ring-foreground/10" />
|
||||||
) : undefined,
|
) : undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -531,11 +532,20 @@ function VpsPage() {
|
|||||||
>
|
>
|
||||||
{(snap) => {
|
{(snap) => {
|
||||||
const filtersActive = hasActiveVpsFilters(filters)
|
const filtersActive = hasActiveVpsFilters(filters)
|
||||||
const zeroResults = snap.vps.length > 0 && filteredVps.length === 0 && filtersActive
|
const zeroResults =
|
||||||
|
snap.vps.length > 0 && filteredVps.length === 0 && (filtersActive || Boolean(health))
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters(buildDefaultVpsFilters())
|
||||||
|
if (health) {
|
||||||
|
void navigate({ to: '/vps', search: {} })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (zeroResults) {
|
if (zeroResults) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
|
||||||
<VpsFiltersToolbar
|
<VpsFiltersToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
@@ -555,8 +565,8 @@ function VpsPage() {
|
|||||||
title="Ничего не найдено"
|
title="Ничего не найдено"
|
||||||
description="По текущим фильтрам VPS не найдены"
|
description="По текущим фильтрам VPS не найдены"
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" onClick={() => setFilters(buildDefaultVpsFilters())}>
|
<Button variant="outline" onClick={resetFilters}>
|
||||||
Сбросить фильтры
|
{health ? 'Выйти из режима и сбросить фильтры' : 'Сбросить фильтры'}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user