Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user