feat: расширить инвентарь, ops и интеграции по roadmap
Docker / build (push) Has been cancelled

Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette,
календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 15:28:35 +07:00
co-authored by Cursor
parent 0ea92746e4
commit 11b67a1cef
43 changed files with 2085 additions and 79 deletions
+49 -3
View File
@@ -7,9 +7,11 @@ import {
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { cn } from '@cfdm/ui/lib/utils'
import {
DataGrid,
@@ -64,6 +66,10 @@ export interface DataGridCardProps<TData extends object> {
virtualization?: boolean
/** Высота viewport для виртуализации (px). По умолчанию 480. */
height?: number
/** Включить выбор строк (чекбоксы). */
enableRowSelection?: boolean
/** Callback при изменении выбора. */
onRowSelectionChange?: (selectedIds: string[]) => void
className?: string
}
@@ -155,19 +161,58 @@ export function DataGridCard<TData extends object>({
initialSorting,
virtualization = false,
height = 480,
enableRowSelection = false,
onRowSelectionChange,
className,
}: DataGridCardProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const lastColId = pinLastColumn ? columns[columns.length - 1]?.id ?? '' : ''
const selectColumn: ColumnDef<TData, unknown> = {
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Выбрать все"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Выбрать строку"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
meta: { cellClassName: 'w-10' },
}
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
const showPagination = pagination ?? true
const table = useReactTable<TData>({
data,
columns,
state: { sorting },
columns: tableColumns,
state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) },
onSortingChange: setSorting,
onRowSelectionChange: enableRowSelection
? (updater) => {
setRowSelection((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater
if (onRowSelectionChange && rowId) {
const ids = Object.keys(next).filter((k) => next[k])
onRowSelectionChange(ids)
}
return next
})
}
: undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
@@ -179,6 +224,7 @@ export function DataGridCard<TData extends object>({
? (row, index) => rowId(row, index)
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
})
const hasHeader = Boolean(title || description || actions)
@@ -1,12 +1,18 @@
import { useMutation } from '@tanstack/react-query'
import { PlugIcon, RefreshCwIcon } from 'lucide-react'
import { toast } from 'sonner'
import { FormSheetRhf } from '@/components/form-sheet-rhf'
import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import type { ZodType } from 'zod'
import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/schemas'
import type { BillingMode, Provider } from '@/types/entities'
import { billingModeLabel } from '@/lib/format'
import { api, ApiError } from '@/lib/api-client'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
@@ -25,6 +31,7 @@ interface ProviderAccountEditSheetProps {
providers: Provider[]
onSubmit: (values: ProviderAccountFormValues) => void
submitting?: boolean
onBalanceRefreshed?: () => void
}
export function providerAccountFormDefaults(
@@ -48,9 +55,26 @@ export function ProviderAccountEditSheet({
providers,
onSubmit,
submitting,
onBalanceRefreshed,
}: ProviderAccountEditSheetProps) {
const isEdit = Boolean(defaultValues.id)
const testMut = useMutation({
mutationFn: async (values: { apiBaseUrl: string; apiCredentials: string }) =>
api.testConnection(values.apiBaseUrl, values.apiCredentials),
onSuccess: () => toast.success('Подключение успешно'),
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка подключения'),
})
const balanceMut = useMutation({
mutationFn: (accountId: string) => api.fetchAccountBalance(accountId),
onSuccess: (data) => {
toast.success(`Баланс: ${data.balance} ${data.currency}`)
onBalanceRefreshed?.()
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка обновления баланса'),
})
return (
<FormSheetRhf
open={open}
@@ -64,13 +88,19 @@ export function ProviderAccountEditSheet({
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
const providerId = watch('providerId')
const provider = providers.find((p) => p.id === providerId)
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
const creds = watch('apiCredentials')?.trim() ?? ''
const canTest = Boolean(apiBaseUrl && creds)
return (
<>
<FormField label="Хостер" htmlFor="acc-provider" error={errors.providerId?.message}>
<SelectField
triggerId="acc-provider"
placeholder="Выберите хостера"
value={watch('providerId')}
value={providerId}
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
/>
@@ -88,6 +118,31 @@ export function ProviderAccountEditSheet({
>
<Input id="acc-creds" type="password" {...register('apiCredentials')} />
</FormField>
<div className="flex flex-wrap gap-2">
<LoadingButton
type="button"
variant="outline"
size="sm"
disabled={!canTest}
loading={testMut.isPending}
onClick={() => testMut.mutate({ apiBaseUrl, apiCredentials: creds })}
>
<PlugIcon data-icon="inline-start" />
Проверить подключение
</LoadingButton>
{isEdit && defaultValues.id ? (
<LoadingButton
type="button"
variant="outline"
size="sm"
loading={balanceMut.isPending}
onClick={() => balanceMut.mutate(defaultValues.id!)}
>
<RefreshCwIcon data-icon="inline-start" />
Обновить баланс
</LoadingButton>
) : null}
</div>
<FormField label="Режим биллинга" htmlFor="acc-mode">
<SelectField
triggerId="acc-mode"
@@ -5,7 +5,7 @@ import { Textarea } from '@cfdm/ui/components/textarea'
import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { paymentSchema, type PaymentFormValues } from '@/lib/schemas'
import type { PaymentType, Provider, ProviderAccount } from '@/types/entities'
import type { PaymentType, Provider, ProviderAccount, Vps } from '@/types/entities'
import { paymentTypeLabel } from '@/lib/format'
import { accountSelectLabel, providerByIdMap } from '@/lib/billmanager'
@@ -17,6 +17,7 @@ const EMPTY: PaymentFormValues = {
amount: 0,
currency: 'RUB',
providerAccountId: '',
vpsId: '',
note: '',
}
@@ -26,6 +27,7 @@ interface PaymentEditSheetProps {
defaultValues: PaymentFormValues
providerAccounts: ProviderAccount[]
providers: Provider[]
vpsRows: Vps[]
onSubmit: (values: PaymentFormValues) => void
submitting?: boolean
}
@@ -37,7 +39,7 @@ export function paymentFormDefaults(
if (!edit) {
return { ...EMPTY, providerAccountId: fallbackAccountId }
}
return { ...EMPTY, ...edit }
return { ...EMPTY, ...edit, vpsId: edit.vpsId ?? '' }
}
export function PaymentEditSheet({
@@ -46,6 +48,7 @@ export function PaymentEditSheet({
defaultValues,
providerAccounts,
providers,
vpsRows,
onSubmit,
submitting,
}: PaymentEditSheetProps) {
@@ -63,6 +66,14 @@ export function PaymentEditSheet({
>
{(form) => {
const { register, formState: { errors }, watch, setValue } = form
const accountId = watch('providerAccountId')
const vpsOptions = vpsRows
.filter((v) => !accountId || v.providerAccountId === accountId)
.map((v) => ({
value: v.id,
label: [v.ip, v.dns, v.project].filter(Boolean).join(' · ') || v.id,
}))
return (
<>
<FormField label="Тип" htmlFor="pay-type" error={errors.type?.message}>
@@ -82,14 +93,26 @@ export function PaymentEditSheet({
<SelectField
triggerId="pay-acc"
placeholder="Выберите аккаунт"
value={watch('providerAccountId')}
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
value={accountId}
onValueChange={(v) => {
setValue('providerAccountId', v ?? '', { shouldValidate: true })
setValue('vpsId', '')
}}
options={providerAccounts.map((a) => ({
value: a.id,
label: accountSelectLabel(a, providerById),
}))}
/>
</FormField>
<FormField label="VPS (необязательно)" htmlFor="pay-vps">
<SelectField
triggerId="pay-vps"
placeholder="Не привязан"
value={watch('vpsId') || ''}
onValueChange={(v) => setValue('vpsId', v ?? '')}
options={[{ value: '', label: '—' }, ...vpsOptions]}
/>
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Дата" htmlFor="pay-date" error={errors.date?.message}>
<Input id="pay-date" type="date" {...register('date')} />
@@ -3,33 +3,52 @@ import { FormField } from '@/components/form-field'
import { Input } from '@cfdm/ui/components/input'
import { projectSchema, type ProjectFormValues } from '@/lib/schemas'
const EMPTY: ProjectFormValues = { name: '' }
const EMPTY: ProjectFormValues = { name: '', color: '' }
interface ProjectEditSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
defaultValues?: ProjectFormValues
onSubmit: (values: ProjectFormValues) => void
submitting?: boolean
}
export function ProjectEditSheet({ open, onOpenChange, onSubmit, submitting }: ProjectEditSheetProps) {
export function projectFormDefaults(edit?: Partial<ProjectFormValues> | null): ProjectFormValues {
if (!edit) return { ...EMPTY }
return { ...EMPTY, ...edit, color: edit.color ?? '' }
}
export function ProjectEditSheet({
open,
onOpenChange,
defaultValues = EMPTY,
onSubmit,
submitting,
}: ProjectEditSheetProps) {
const isEdit = Boolean(defaultValues.id)
return (
<FormSheetRhf
open={open}
onOpenChange={onOpenChange}
title="Новый проект"
title={isEdit ? 'Редактировать проект' : 'Новый проект'}
description="Имя будет доступно в автодополнении на форме VPS"
schema={projectSchema}
defaultValues={EMPTY}
schema={projectSchema as import('zod').ZodType<ProjectFormValues>}
defaultValues={defaultValues}
onSubmit={onSubmit}
submitting={submitting}
>
{(form) => {
const { register, formState: { errors } } = form
return (
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<>
<FormField label="Название" htmlFor="project-name" error={errors.name?.message} invalid={!!errors.name}>
<Input id="project-name" aria-invalid={!!errors.name} {...register('name')} />
</FormField>
<FormField label="Цвет (hex)" htmlFor="project-color" description="Например #3b82f6 — для badge в списке VPS">
<Input id="project-color" placeholder="#3b82f6" {...register('color')} />
</FormField>
</>
)
}}
</FormSheetRhf>
@@ -0,0 +1,80 @@
import { ArchiveIcon, FolderKanbanIcon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { SelectField } from '@/components/select-field'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { useState } from 'react'
import { vpsStatusLabel } from '@/lib/format'
interface VpsBulkToolbarProps {
selectedCount: number
projectOptions: string[]
onSetStatus: (status: 'active' | 'paused' | 'archived') => void
onSetProject: (project: string) => void
onDelete: () => void
busy?: boolean
}
export function VpsBulkToolbar({
selectedCount,
projectOptions,
onSetStatus,
onSetProject,
onDelete,
busy,
}: VpsBulkToolbarProps) {
const [projectValue, setProjectValue] = useState('')
if (selectedCount === 0) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-sm font-medium tabular-nums">Выбрано: {selectedCount}</span>
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('active')}>
<PlayIcon data-icon="inline-start" />
{vpsStatusLabel('active')}
</Button>
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('paused')}>
<PauseIcon data-icon="inline-start" />
{vpsStatusLabel('paused')}
</Button>
<Button variant="outline" size="sm" disabled={busy} onClick={() => onSetStatus('archived')}>
<ArchiveIcon data-icon="inline-start" />
{vpsStatusLabel('archived')}
</Button>
<div className="flex items-center gap-1">
<SelectField
placeholder="Проект…"
value={projectValue}
onValueChange={(v) => setProjectValue(v ?? '')}
options={projectOptions.map((p) => ({ value: p, label: p }))}
triggerClassName="w-40"
/>
<Button
variant="outline"
size="sm"
disabled={busy || !projectValue}
onClick={() => {
onSetProject(projectValue)
setProjectValue('')
}}
>
<FolderKanbanIcon data-icon="inline-start" />
Назначить
</Button>
</div>
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm" disabled={busy}>
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
}
title={`Удалить ${selectedCount} VPS?`}
description="Записи будут удалены безвозвратно."
confirmLabel="Удалить"
destructive
onConfirm={onDelete}
/>
</div>
)
}
@@ -5,6 +5,8 @@ import { Input } from '@cfdm/ui/components/input'
import { SelectField } from '@/components/select-field'
import { AutoCompleteInput } from '@/components/auto-complete-input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
import {
NumberField,
NumberFieldGroup,
@@ -16,6 +18,8 @@ import { FormDatePicker } from '@/components/form-date-picker'
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
import { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
import type { ZodType } from 'zod'
@@ -38,6 +42,8 @@ export const VPS_FORM_EMPTY: VpsFormValues = {
paidUntil: '',
project: '',
notes: '',
userOverrides: [] as string[],
customData: {} as Record<string, string | number | boolean>,
}
export function vpsFormFromRow(v: Vps): VpsFormValues {
@@ -61,6 +67,8 @@ export function vpsFormFromRow(v: Vps): VpsFormValues {
paidUntil: v.paidUntil ?? '',
project: v.project ?? '',
notes: v.notes ?? '',
userOverrides: parseUserOverrides((v as Vps & { userOverrides?: unknown }).userOverrides),
customData: parseCustomData((v as Vps & { customData?: unknown }).customData),
}
}
@@ -73,6 +81,7 @@ interface VpsEditSheetProps {
providerAccounts: ProviderAccount[]
vpsRows: Vps[]
formCountryOptions: Array<{ value: string; label: string }>
customFieldDefs?: CustomFieldDef[]
onSubmit: (values: VpsFormValues) => void
submitting?: boolean
}
@@ -86,6 +95,7 @@ export function VpsEditSheet({
providerAccounts,
vpsRows,
formCountryOptions,
customFieldDefs = [],
onSubmit,
submitting,
}: VpsEditSheetProps) {
@@ -326,6 +336,74 @@ export function VpsEditSheet({
<FormField label="Заметки" htmlFor="vps-notes">
<Textarea id="vps-notes" {...register('notes')} />
</FormField>
{customFieldDefs.length > 0 ? (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Дополнительные поля</p>
{customFieldDefs.map((field) => {
const customData = watch('customData') ?? {}
if (field.type === 'bool') {
return (
<div key={field.key} className="flex items-center gap-2">
<Checkbox
id={`custom-${field.key}`}
checked={Boolean(customData[field.key])}
onCheckedChange={(v) =>
setValue('customData', { ...customData, [field.key]: Boolean(v) })
}
/>
<Label htmlFor={`custom-${field.key}`} className="font-normal">
{field.label}
</Label>
</div>
)
}
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<Input
id={`custom-${field.key}`}
type={field.type === 'number' ? 'number' : 'text'}
value={String(customData[field.key] ?? '')}
onChange={(e) => {
const val =
field.type === 'number' ? Number(e.target.value) : e.target.value
setValue('customData', { ...customData, [field.key]: val })
}}
/>
</FormField>
)
})}
</div>
) : null}
{editingId ? (
<FormField
label="Не перезаписывать при синке"
description="Отмеченные поля сохранят ручные значения при синхронизации с BILLmanager"
>
<div className="flex flex-col gap-2">
{VPS_SYNC_OVERRIDE_FIELDS.map(({ key, label }) => {
const overrides = watch('userOverrides') ?? []
const checked = overrides.includes(key)
return (
<div key={key} className="flex items-center gap-2">
<Checkbox
id={`vps-override-${key}`}
checked={checked}
onCheckedChange={(value) => {
const next = value
? [...overrides, key]
: overrides.filter((f) => f !== key)
setValue('userOverrides', next)
}}
/>
<Label htmlFor={`vps-override-${key}`} className="font-normal">
{label}
</Label>
</div>
)
})}
</div>
</FormField>
) : null}
</>
)
}}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
ServerIcon,
WalletIcon,
Building2Icon,
FolderKanbanIcon,
LayoutDashboardIcon,
SearchIcon,
} from 'lucide-react'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@cfdm/ui/components/command'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { providerByIdMap } from '@/lib/billmanager'
interface GlobalSearchProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
const navigate = useNavigate()
const { data: snapshot } = useQuery({ ...snapshotQueryOptions(), enabled: open })
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const go = (to: string, search?: Record<string, string>) => {
onOpenChange(false)
void navigate({ to, search })
}
const vpsItems = useMemo(() => snapshot?.vps ?? [], [snapshot])
const accountItems = useMemo(() => snapshot?.providerAccounts ?? [], [snapshot])
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
return (
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация">
<CommandInput placeholder="IP, DNS, проект, аккаунт…" />
<CommandList>
<CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Навигация">
<CommandItem onSelect={() => go('/dashboard')}>
<LayoutDashboardIcon />
Дашборд
</CommandItem>
<CommandItem onSelect={() => go('/vps')}>
<ServerIcon />
Все VPS
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
{vpsItems.slice(0, 50).map((v) => (
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}>
<ServerIcon />
<span>{v.ip || v.dns || v.id}</span>
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Аккаунты">
{accountItems.map((a) => (
<CommandItem
key={a.id}
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
onSelect={() => go('/accounts')}
>
<WalletIcon />
{a.name}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Проекты">
{projectItems.map((p) => {
const row = p as { id: string; name: string }
return (
<CommandItem key={row.id} value={row.name} onSelect={() => go('/vps', { project: row.name })}>
<FolderKanbanIcon />
{row.name}
</CommandItem>
)
})}
</CommandGroup>
<CommandGroup heading="Хостеры">
{(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon />
{p.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
export function useGlobalSearchHotkey(onOpen: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
onOpen()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onOpen])
}
export function GlobalSearchTrigger({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hidden items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted md:flex"
>
<SearchIcon className="size-4" />
<span>Поиск</span>
<kbd className="pointer-events-none rounded border bg-background px-1.5 font-mono text-xs">Ctrl+K</kbd>
</button>
)
}
@@ -0,0 +1,33 @@
import { AlertCircleIcon, XIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Button } from '@cfdm/ui/components/button'
const HEALTH_LABELS: Record<string, string> = {
'no-rate': 'Нет ставки или валюты',
'paid-overdue': 'Просрочена оплата (оценка)',
'stale-sync': 'Нет успешного синка > 48 ч',
'balance-mismatch': 'Баланс API и ledger расходятся',
}
interface HealthModeBannerProps {
health: string
exitTo: string
}
export function HealthModeBanner({ health, exitTo }: HealthModeBannerProps) {
const title = HEALTH_LABELS[health] ?? 'Режим диагностики'
return (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-2">
<span>Показаны только записи с этой проблемой.</span>
<Button variant="outline" size="sm" render={<Link to={exitTo} search={{}} />}>
<XIcon data-icon="inline-start" />
Выйти из режима
</Button>
</AlertDescription>
</Alert>
)
}
+10 -1
View File
@@ -42,9 +42,10 @@ import { Badge } from '@cfdm/ui/components/badge'
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { useState, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
@@ -87,12 +88,14 @@ const NAV_GROUPS: NavGroup[] = [
items: [
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
{ to: '/renewals', label: 'Продления', icon: RefreshCwIcon },
],
},
{
label: 'Система',
items: [
{ to: '/sync-journal', label: 'Журнал синка', icon: HistoryIcon },
{ to: '/audit', label: 'Журнал изменений', icon: HistoryIcon },
{ to: '/settings', label: 'Настройки', icon: Settings },
],
},
@@ -114,11 +117,15 @@ const PARENT_ROUTE: Record<string, string> = {
'/balance': '/dashboard',
'/reports': '/dashboard',
'/resources': '/dashboard',
'/renewals': '/dashboard',
'/sync-journal': '/settings',
'/audit': '/settings',
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const [searchOpen, setSearchOpen] = useState(false)
useGlobalSearchHotkey(() => setSearchOpen(true))
const activeItem = ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
@@ -227,6 +234,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<GlobalSearchTrigger onClick={() => setSearchOpen(true)} />
{stats?.issuesCount ? (
<Badge variant="destructive" className="hidden sm:inline-flex">
{stats.issuesCount} проблем
@@ -237,6 +245,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
</SidebarProvider>
)
}