feat(vps): визуальный редактор кастомных полей и колонки в таблице
Docker / build (push) Has been cancelled
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:
@@ -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()
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user