feat(vps): визуальный редактор кастомных полей и колонки в таблице
Docker / build (push) Has been cancelled

Заменён JSON-редактор в настройках на UI с drag-reorder, добавлены динамические колонки VPS с picker видимости и типизированные контракты в @cfdm/shared.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-28 19:28:32 +07:00
co-authored by Cursor
parent 2e1ca9f2a7
commit 5a3241a7d1
16 changed files with 691 additions and 108 deletions
+80 -7
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react'
import { useState, useEffect, type ReactNode } from 'react'
import {
useReactTable,
getCoreRowModel,
@@ -8,10 +8,13 @@ import {
type ColumnDef,
type SortingState,
type RowSelectionState,
type VisibilityState,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
import {
DataGrid,
@@ -22,6 +25,7 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { EmptyState } from './empty-state'
import type { DataTableColumn } from './data-grid-types'
@@ -38,6 +42,16 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
return ''
}
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
return JSON.parse(raw) as VisibilityState
} catch {
return undefined
}
}
export interface DataGridCardProps<TData extends object> {
title?: ReactNode
description?: ReactNode
@@ -70,6 +84,12 @@ export interface DataGridCardProps<TData extends object> {
enableRowSelection?: boolean
/** Callback при изменении выбора. */
onRowSelectionChange?: (selectedIds: string[]) => void
/** Показать picker видимости колонок. */
enableColumnVisibility?: boolean
/** Ключ localStorage для сохранения видимости колонок. */
columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
className?: string
}
@@ -91,6 +111,7 @@ function DataGridCardBody<TData extends object>({
height,
footerContent,
showPagination,
enableColumnVisibility,
}: {
table: ReturnType<typeof useReactTable<TData>>
data: TData[]
@@ -101,6 +122,7 @@ function DataGridCardBody<TData extends object>({
height: number
footerContent?: ReactNode
showPagination: boolean
enableColumnVisibility: boolean
}) {
return (
<DataGridContainer border={false}>
@@ -117,7 +139,7 @@ function DataGridCardBody<TData extends object>({
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: false,
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
@@ -167,10 +189,24 @@ export function DataGridCard<TData extends object>({
height = 480,
enableRowSelection = false,
onRowSelectionChange,
enableColumnVisibility = false,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: DataGridCardProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined
return { ...initialColumnVisibility, ...stored }
})
useEffect(() => {
if (!columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey])
const selectColumn: ColumnDef<TData, unknown> = {
id: 'select',
@@ -191,6 +227,7 @@ export function DataGridCard<TData extends object>({
/>
),
enableSorting: false,
enableHiding: false,
meta: { cellClassName: 'w-10' },
}
@@ -203,8 +240,13 @@ export function DataGridCard<TData extends object>({
const table = useReactTable<TData>({
data,
columns: tableColumns,
state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) },
state: {
sorting,
columnVisibility,
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
setRowSelection((prev) => {
@@ -229,9 +271,29 @@ export function DataGridCard<TData extends object>({
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
enableHiding: enableColumnVisibility,
})
const hasHeader = Boolean(title || description || actions)
const columnVisibilityAction = enableColumnVisibility ? (
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="outline" size="sm">
<Columns3Icon data-icon="inline-start" />
Колонки
</Button>
}
/>
) : null
const headerActions = (
<div className="flex items-center gap-2">
{columnVisibilityAction}
{actions}
</div>
)
const hasHeader = Boolean(title || description || actions || enableColumnVisibility)
if (data.length === 0) {
if (!hasHeader) {
@@ -249,7 +311,7 @@ export function DataGridCard<TData extends object>({
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{headerActions ? <div className="flex items-center gap-2">{headerActions}</div> : null}
</CardHeader>
<CardContent>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
@@ -269,11 +331,19 @@ export function DataGridCard<TData extends object>({
height={height}
footerContent={footerContent}
showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility}
/>
)
if (!hasHeader) {
return <div className={className}>{gridBody}</div>
return (
<div className={className}>
{enableColumnVisibility ? (
<div className="mb-2 flex justify-end">{columnVisibilityAction}</div>
) : null}
{gridBody}
</div>
)
}
return (
@@ -283,7 +353,9 @@ export function DataGridCard<TData extends object>({
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
{(actions || enableColumnVisibility) ? (
<div className="flex items-center gap-2">{headerActions}</div>
) : null}
</CardHeader>
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
</Card>
@@ -319,6 +391,7 @@ export function columnDefFromDataTable<T>(
: () => c.header,
cell: ({ row }) => c.cell(row.original, row.index),
enableSorting: sortable,
enableHiding: c.enableHiding ?? true,
meta: {
headerTitle: title || undefined,
cellClassName: c.className,
@@ -11,6 +11,7 @@ export interface DataTableColumn<T> {
headerTitle?: string
className?: string
headerClassName?: string
enableHiding?: boolean
}
/** Унифицированные классы колонок для DataGridCard. */
@@ -0,0 +1,111 @@
import { type UseFormSetValue, type UseFormWatch } from 'react-hook-form'
import { Input } from '@cfdm/ui/components/input'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Label } from '@cfdm/ui/components/label'
import { Separator } from '@cfdm/ui/components/separator'
import { FormField } from '@/components/form-field'
import {
NumberField,
NumberFieldGroup,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import type { CustomFieldDef } from '@/lib/custom-fields'
import type { VpsFormValues } from '@/lib/schemas'
interface CustomFieldValuesProps {
defs: CustomFieldDef[]
watch: UseFormWatch<VpsFormValues>
setValue: UseFormSetValue<VpsFormValues>
}
function setCustomFieldValue(
setValue: UseFormSetValue<VpsFormValues>,
watch: UseFormWatch<VpsFormValues>,
key: string,
value: string | number | boolean | undefined,
) {
const current = watch('customData') ?? {}
const next = { ...current }
if (value === undefined || value === '') {
delete next[key]
} else {
next[key] = value
}
setValue('customData', next, { shouldDirty: true })
}
export function CustomFieldValues({ defs, watch, setValue }: CustomFieldValuesProps) {
if (defs.length === 0) return null
const customData = watch('customData') ?? {}
return (
<>
<Separator />
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Дополнительные поля</p>
{defs.map((field) => {
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) => setCustomFieldValue(setValue, watch, field.key, Boolean(v))}
/>
<Label htmlFor={`custom-${field.key}`} className="font-normal">
{field.label}
</Label>
</div>
)
}
if (field.type === 'number') {
const raw = customData[field.key]
const numVal = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<NumberField
id={`custom-${field.key}`}
value={numVal}
onValueChange={(v) =>
setCustomFieldValue(
setValue,
watch,
field.key,
v == null || !Number.isFinite(v) ? undefined : v,
)
}
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput placeholder="0" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</FormField>
)
}
return (
<FormField key={field.key} label={field.label} htmlFor={`custom-${field.key}`}>
<Input
id={`custom-${field.key}`}
value={String(customData[field.key] ?? '')}
onChange={(e) =>
setCustomFieldValue(
setValue,
watch,
field.key,
e.target.value || undefined,
)
}
/>
</FormField>
)
})}
</div>
</>
)
}
@@ -0,0 +1,253 @@
import { useRef, useState, type MutableRefObject } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVerticalIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import {
useFieldArray,
Controller,
type Control,
type FieldErrors,
type UseFormSetValue,
} from 'react-hook-form'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { FormField } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { slugifyCustomFieldKey } from '@/lib/custom-fields'
import type { SettingsFormValues } from '@/lib/schemas'
const FIELD_TYPES = [
{ value: 'text', label: 'Текст' },
{ value: 'number', label: 'Число' },
{ value: 'bool', label: 'Да/Нет' },
] as const
interface CustomFieldsEditorProps {
control: Control<SettingsFormValues>
setValue: UseFormSetValue<SettingsFormValues>
errors?: FieldErrors<SettingsFormValues>['customFields']
}
function SortableFieldRow({
id,
index,
control,
setValue,
errors,
onRemove,
manualKeysRef,
}: {
id: string
index: number
control: Control<SettingsFormValues>
setValue: UseFormSetValue<SettingsFormValues>
errors?: FieldErrors<SettingsFormValues>['customFields']
onRemove: () => void
manualKeysRef: MutableRefObject<Set<number>>
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
})
const rowErrors = errors?.[index]
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className="flex flex-col gap-3 rounded-lg border border-border p-3 sm:flex-row sm:items-start"
>
<button
type="button"
className="mt-2 flex size-8 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground active:cursor-grabbing"
aria-label="Перетащить"
{...attributes}
{...listeners}
>
<GripVerticalIcon className="size-4" />
</button>
<div className="grid flex-1 gap-3 sm:grid-cols-3">
<Controller
control={control}
name={`customFields.${index}.label`}
render={({ field }) => (
<FormField
label="Название"
htmlFor={`custom-field-label-${index}`}
error={rowErrors?.label?.message}
>
<Input
id={`custom-field-label-${index}`}
placeholder="Панель управления"
value={field.value}
onChange={field.onChange}
onBlur={(e) => {
field.onBlur()
if (!manualKeysRef.current.has(index)) {
setValue(`customFields.${index}.key`, slugifyCustomFieldKey(e.target.value), {
shouldDirty: true,
})
}
}}
/>
</FormField>
)}
/>
<Controller
control={control}
name={`customFields.${index}.key`}
render={({ field }) => (
<FormField
label="Ключ"
htmlFor={`custom-field-key-${index}`}
error={rowErrors?.key?.message}
description="panel_url"
>
<Input
id={`custom-field-key-${index}`}
className="font-mono text-xs"
placeholder="panel_url"
value={field.value}
onChange={(e) => {
manualKeysRef.current.add(index)
field.onChange(e)
}}
onBlur={field.onBlur}
/>
</FormField>
)}
/>
<Controller
control={control}
name={`customFields.${index}.type`}
render={({ field }) => (
<FormField
label="Тип"
htmlFor={`custom-field-type-${index}`}
error={rowErrors?.type?.message}
>
<SelectField
triggerId={`custom-field-type-${index}`}
value={field.value ?? 'text'}
onValueChange={(v) => field.onChange(v ?? 'text')}
options={[...FIELD_TYPES]}
/>
</FormField>
)}
/>
</div>
<ConfirmDialog
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 shrink-0 text-muted-foreground hover:text-destructive"
aria-label="Удалить поле"
>
<Trash2Icon className="size-4" />
</Button>
}
title="Удалить поле?"
description="Значения этого поля в VPS сохранятся в данных, но перестанут отображаться."
confirmLabel="Удалить"
destructive
onConfirm={onRemove}
/>
</div>
)
}
export function CustomFieldsEditor({ control, setValue, errors }: CustomFieldsEditorProps) {
const { fields, append, remove, move } = useFieldArray({
control,
name: 'customFields',
})
const manualKeysRef = useRef<Set<number>>(new Set())
const [rowIds, setRowIds] = useState<string[]>([])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const ids = fields.map((f, i) => rowIds[i] ?? f.id)
const oldIndex = ids.indexOf(String(active.id))
const newIndex = ids.indexOf(String(over.id))
if (oldIndex < 0 || newIndex < 0) return
move(oldIndex, newIndex)
setRowIds((prev) => arrayMove(prev.length ? prev : ids, oldIndex, newIndex))
}
const handleAdd = () => {
const n = fields.length + 1
append({ key: `field_${n}`, label: '', type: 'text' })
}
if (fields.length === 0) {
return (
<EmptyState
title="Кастомные поля не заданы"
description="Добавьте поля для отображения в таблице VPS и в форме редактирования сервера"
action={
<Button type="button" variant="outline" onClick={handleAdd}>
<PlusIcon data-icon="inline-start" />
Добавить поле
</Button>
}
/>
)
}
const ids = fields.map((f, i) => rowIds[i] ?? f.id)
return (
<div className="flex flex-col gap-3">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{fields.map((field, index) => (
<SortableFieldRow
key={field.id}
id={ids[index] ?? field.id}
index={index}
control={control}
setValue={setValue}
errors={errors}
onRemove={() => remove(index)}
manualKeysRef={manualKeysRef}
/>
))}
</SortableContext>
</DndContext>
<Button type="button" variant="outline" className="w-fit" onClick={handleAdd}>
<PlusIcon data-icon="inline-start" />
Добавить поле
</Button>
</div>
)
}
@@ -19,6 +19,7 @@ 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 { CustomFieldValues } from '@/components/domain/custom-field-values'
import { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
import type { ZodType } from 'zod'
@@ -336,44 +337,7 @@ 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}
<CustomFieldValues defs={customFieldDefs} watch={watch} setValue={setValue} />
{editingId ? (
<FormField
label="Не перезаписывать при синке"
@@ -24,7 +24,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
Колонки
</DropdownMenuLabel>
{table
.getAllColumns()
-34
View File
@@ -1,34 +0,0 @@
export interface CustomFieldDef {
key: string
label: string
type?: 'text' | 'number' | 'bool'
}
export function parseCustomFieldDefs(raw: unknown): CustomFieldDef[] {
if (!Array.isArray(raw)) return []
return raw
.filter((item): item is Record<string, unknown> => item != null && typeof item === 'object')
.map((item) => ({
key: String(item.key ?? '').trim(),
label: String(item.label ?? item.key ?? '').trim(),
type: (item.type as CustomFieldDef['type']) ?? 'text',
}))
.filter((f) => f.key.length > 0)
}
export function parseCustomData(raw: unknown): Record<string, string | number | boolean> {
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string | number | boolean>
}
} catch {
return {}
}
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, string | number | boolean>
}
return {}
}
+72
View File
@@ -0,0 +1,72 @@
export {
type CustomFieldDef,
type CustomFieldType,
parseCustomFieldDefs,
parseCustomData,
slugifyCustomFieldKey,
formatCustomFieldValue,
} from '@cfdm/shared/contracts/custom-fields'
import type { ReactNode } from 'react'
import { Badge } from '@cfdm/ui/components/badge'
import {
type CustomFieldDef,
formatCustomFieldValue,
parseCustomData,
} from '@cfdm/shared/contracts/custom-fields'
import type { DataTableColumn } from '@/components/data-grid-types'
export function buildCustomFieldColumns<T extends { customData?: unknown }>(
defs: CustomFieldDef[],
): DataTableColumn<T>[] {
return defs.map((def) => ({
key: `custom_${def.key}`,
header: def.label,
headerTitle: def.label,
enableHiding: true,
sortValue: (row) => {
const data = parseCustomData(row.customData)
const val = data[def.key]
if (def.type === 'bool') return val ? 1 : 0
if (def.type === 'number') return Number(val) || 0
return String(val ?? '')
},
cell: (row): ReactNode => {
const data = parseCustomData(row.customData)
const val = data[def.key]
if (val === undefined || val === null || val === '') {
return <span className="text-muted-foreground"></span>
}
if (def.type === 'bool') {
return (
<Badge variant={val ? 'default' : 'outline'}>
{formatCustomFieldValue(def, val)}
</Badge>
)
}
if (def.type === 'number') {
return (
<span className="tabular-nums text-muted-foreground">
{formatCustomFieldValue(def, val)}
</span>
)
}
const text = formatCustomFieldValue(def, val)
return (
<span className="block max-w-[200px] truncate text-muted-foreground" title={text}>
{text}
</span>
)
},
}))
}
export function buildCustomFieldColumnVisibility(
defs: CustomFieldDef[],
): Record<string, boolean> {
const visibility: Record<string, boolean> = {}
for (const def of defs) {
visibility[`custom_${def.key}`] = false
}
return visibility
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
import { customFieldsSchema } from '@cfdm/shared/contracts/custom-fields'
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
@@ -100,7 +101,12 @@ export const settingsSchema = z.object({
notifyVpsDownEnabled: z.boolean().optional().default(true),
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
webhookEnabled: z.boolean().optional().default(false),
customFieldsJson: z.string().optional().default('[]'),
customFields: customFieldsSchema
.default([])
.refine(
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
'Ключи кастомных полей должны быть уникальными',
),
})
export const projectSchema = z.object({
+12 -23
View File
@@ -14,12 +14,13 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Textarea } from '@cfdm/ui/components/textarea'
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { FormField } from '@/components/form-field'
import { Button } from '@cfdm/ui/components/button'
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
import type { Settings } from '@/types/entities'
export const Route = createFileRoute('/_auth/settings')({
@@ -48,11 +49,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
customFieldsJson: JSON.stringify(
(s as Settings & { customFields?: unknown[] }).customFields ?? [],
null,
2,
),
customFields: parseCustomFieldDefs(s.customFields),
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
}
}
@@ -96,15 +93,7 @@ function SettingsPage() {
const upsertMut = useMutation({
mutationFn: (patch: SettingsFormValues) => {
const { customFieldsJson, ...rest } = patch
let customFields: unknown[] = []
try {
const parsed = JSON.parse(customFieldsJson || '[]') as unknown
if (Array.isArray(parsed)) customFields = parsed
} catch {
throw new ApiError('Невалидный JSON в кастомных полях')
}
const payload = { ...rest, customFields }
const payload = { ...patch }
if (current?.id) return api.update<Settings>('settings', current.id, payload)
return api.create<Settings>('settings', {
id: 'settings-main',
@@ -400,16 +389,16 @@ function SettingsPage() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Кастомные поля VPS</CardTitle>
<CardDescription>JSON-массив: {"{ \"key\", \"label\", \"type\": \"text|number|bool\" }"}</CardDescription>
<CardDescription>
Поля отображаются в таблице VPS и в форме редактирования сервера
</CardDescription>
</CardHeader>
<CardContent>
<FormField label="Схема полей" htmlFor="set-custom-fields" error={form.formState.errors.customFieldsJson?.message}>
<Textarea
id="set-custom-fields"
className="min-h-32 font-mono text-xs"
{...form.register('customFieldsJson')}
/>
</FormField>
<CustomFieldsEditor
control={form.control}
setValue={form.setValue}
errors={form.formState.errors.customFields}
/>
</CardContent>
</Card>
</div>
+34
View File
@@ -31,6 +31,11 @@ import {
} from '@/lib/format'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
import {
parseCustomFieldDefs,
parseCustomData,
formatCustomFieldValue,
} from '@/lib/custom-fields'
import type { Payment, Vps } from '@/types/entities'
export const Route = createFileRoute('/_auth/vps/$vpsId')({
@@ -75,6 +80,19 @@ function VpsDetailPage() {
const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
const customFieldDefs = useMemo(
() => parseCustomFieldDefs((snapshot?.settings[0] as { customFields?: unknown })?.customFields),
[snapshot],
)
const customFieldRows = useMemo(() => {
if (!vps || customFieldDefs.length === 0) return []
const data = parseCustomData((vps as Vps & { customData?: unknown }).customData)
return customFieldDefs
.map((def) => ({ def, value: data[def.key] }))
.filter(({ value }) => value !== undefined && value !== null && value !== '')
}, [vps, customFieldDefs])
const paymentColumns: DataTableColumn<Payment>[] = [
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
@@ -167,6 +185,22 @@ function VpsDetailPage() {
</CardContent>
</Card>
</div>
{customFieldRows.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Дополнительные поля</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{customFieldRows.map(({ def, value }) => (
<InfoRow
key={def.key}
label={def.label}
value={formatCustomFieldValue(def, value)}
/>
))}
</CardContent>
</Card>
) : null}
</TabsContent>
<TabsContent value="finance" className="flex flex-col gap-4">
+28 -3
View File
@@ -34,7 +34,7 @@ import type { Vps } from '@/types/entities'
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
import { getPaidUntilDate } from '@/lib/paid-until'
import { parseCustomFieldDefs } from '@/lib/custom-fields'
import { parseCustomFieldDefs, buildCustomFieldColumns, buildCustomFieldColumnVisibility } from '@/lib/custom-fields'
import { z } from 'zod'
@@ -264,7 +264,18 @@ function VpsPage() {
}))
}, [filteredVps, filters.groupByProject])
const columns: DataTableColumn<Vps>[] = [
const customFieldDefs = useMemo(
() => parseCustomFieldDefs((settings as { customFields?: unknown })?.customFields),
[settings],
)
const customColumnVisibility = useMemo(
() => buildCustomFieldColumnVisibility(customFieldDefs),
[customFieldDefs],
)
const columns: DataTableColumn<Vps>[] = useMemo(() => {
const base: DataTableColumn<Vps>[] = [
{
key: 'ip',
header: 'IP / DNS',
@@ -403,6 +414,7 @@ function VpsPage() {
key: 'actions',
header: '',
sortable: false,
enableHiding: false,
className: 'w-24 text-right',
cell: (v) => (
<RowActions
@@ -413,7 +425,17 @@ function VpsPage() {
/>
),
},
]
]
const customCols = buildCustomFieldColumns<Vps>(customFieldDefs)
const actionsCol = base.pop()!
return [...base, ...customCols, actionsCol]
}, [
snapshot,
providerById,
ratesData,
customFieldDefs,
deleteMutation,
])
return (
<PageShell>
@@ -514,6 +536,9 @@ function VpsPage() {
onRowSelectionChange={setSelectedIds}
virtualization={section.items.length > 200}
height={560}
enableColumnVisibility
columnVisibilityStorageKey="vps-column-visibility"
initialColumnVisibility={customColumnVisibility}
/>
))}
</div>
+4
View File
@@ -1,3 +1,5 @@
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
export type VpsStatus = 'active' | 'paused' | 'archived'
export type TariffType = 'daily' | 'monthly'
export type BillingMode = 'daily' | 'monthly'
@@ -77,6 +79,7 @@ export interface Vps {
createdAt: string
paidUntil?: string
notes?: string
customData?: string | Record<string, string | number | boolean>
}
export interface Payment {
@@ -115,6 +118,7 @@ export interface Settings {
notifyNewTariffsEnabled?: boolean
telegramChatId?: string
telegramBotToken?: string
customFields?: CustomFieldDef[]
}
export interface ActiveTariff {
@@ -0,0 +1,83 @@
import { z } from 'zod'
export const customFieldTypeSchema = z.enum(['text', 'number', 'bool'])
export const customFieldDefSchema = z.object({
key: z
.string()
.min(1, 'Ключ обязателен')
.regex(/^[a-z][a-z0-9_]*$/, 'Ключ: латиница, цифры, _, начинается с буквы'),
label: z.string().min(1, 'Название обязательно').max(80),
type: customFieldTypeSchema.default('text'),
})
export const customFieldsSchema = z.array(customFieldDefSchema)
export type CustomFieldType = z.infer<typeof customFieldTypeSchema>
export type CustomFieldDef = z.infer<typeof customFieldDefSchema>
export function parseCustomFieldDefs(raw: unknown): CustomFieldDef[] {
if (!Array.isArray(raw)) return []
return raw
.filter((item): item is Record<string, unknown> => item != null && typeof item === 'object')
.map((item) => ({
key: String(item.key ?? '').trim(),
label: String(item.label ?? item.key ?? '').trim(),
type: (item.type as CustomFieldType) ?? 'text',
}))
.filter((f) => f.key.length > 0)
}
export function parseCustomData(raw: unknown): Record<string, string | number | boolean> {
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string | number | boolean>
}
} catch {
return {}
}
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, string | number | boolean>
}
return {}
}
export function slugifyCustomFieldKey(label: string): string {
const translitMap: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z',
и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r',
с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
}
const lower = label.trim().toLowerCase()
let slug = ''
for (const ch of lower) {
if (translitMap[ch] !== undefined) {
slug += translitMap[ch]
} else if (/[a-z0-9]/.test(ch)) {
slug += ch
} else if (/\s|[-_]/.test(ch)) {
slug += '_'
}
}
slug = slug.replace(/_+/g, '_').replace(/^_|_$/g, '')
if (!slug) return 'field'
if (!/^[a-z]/.test(slug)) slug = `field_${slug}`
return slug.slice(0, 40)
}
export function formatCustomFieldValue(
def: CustomFieldDef,
value: string | number | boolean | undefined,
): string {
if (value === undefined || value === null || value === '') return '—'
if (def.type === 'bool') return value ? 'Да' : 'Нет'
if (def.type === 'number') {
const n = Number(value)
return Number.isFinite(n) ? String(n) : '—'
}
return String(value)
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { customFieldsSchema } from './custom-fields.js'
export const settingsSchema = z.object({
id: z.string().optional(),
@@ -19,7 +20,7 @@ export const settingsSchema = z.object({
notifyVpsDownEnabled: z.boolean().optional(),
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
webhookEnabled: z.boolean().optional(),
customFields: z.any().optional(),
customFields: customFieldsSchema.optional(),
})
export type Settings = z.infer<typeof settingsSchema>
+1
View File
@@ -5,4 +5,5 @@ export * from './contracts/vps.js'
export * from './contracts/payment.js'
export * from './contracts/balance-ledger.js'
export * from './contracts/settings.js'
export * from './contracts/custom-fields.js'
export * from './contracts/project.js'