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 <[email protected]>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, useEffect, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
|
type VisibilityState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
|
import { Columns3Icon } from 'lucide-react'
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
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 { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { EmptyState } from './empty-state'
|
||||||
import type { DataTableColumn } from './data-grid-types'
|
import type { DataTableColumn } from './data-grid-types'
|
||||||
|
|
||||||
@@ -38,6 +42,16 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
|||||||
return ''
|
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> {
|
export interface DataGridCardProps<TData extends object> {
|
||||||
title?: ReactNode
|
title?: ReactNode
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
@@ -70,6 +84,12 @@ export interface DataGridCardProps<TData extends object> {
|
|||||||
enableRowSelection?: boolean
|
enableRowSelection?: boolean
|
||||||
/** Callback при изменении выбора. */
|
/** Callback при изменении выбора. */
|
||||||
onRowSelectionChange?: (selectedIds: string[]) => void
|
onRowSelectionChange?: (selectedIds: string[]) => void
|
||||||
|
/** Показать picker видимости колонок. */
|
||||||
|
enableColumnVisibility?: boolean
|
||||||
|
/** Ключ localStorage для сохранения видимости колонок. */
|
||||||
|
columnVisibilityStorageKey?: string
|
||||||
|
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||||
|
initialColumnVisibility?: VisibilityState
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +111,7 @@ function DataGridCardBody<TData extends object>({
|
|||||||
height,
|
height,
|
||||||
footerContent,
|
footerContent,
|
||||||
showPagination,
|
showPagination,
|
||||||
|
enableColumnVisibility,
|
||||||
}: {
|
}: {
|
||||||
table: ReturnType<typeof useReactTable<TData>>
|
table: ReturnType<typeof useReactTable<TData>>
|
||||||
data: TData[]
|
data: TData[]
|
||||||
@@ -101,6 +122,7 @@ function DataGridCardBody<TData extends object>({
|
|||||||
height: number
|
height: number
|
||||||
footerContent?: ReactNode
|
footerContent?: ReactNode
|
||||||
showPagination: boolean
|
showPagination: boolean
|
||||||
|
enableColumnVisibility: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DataGridContainer border={false}>
|
<DataGridContainer border={false}>
|
||||||
@@ -117,7 +139,7 @@ function DataGridCardBody<TData extends object>({
|
|||||||
headerBackground: true,
|
headerBackground: true,
|
||||||
headerBorder: true,
|
headerBorder: true,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
columnsVisibility: false,
|
columnsVisibility: enableColumnVisibility,
|
||||||
columnsResizable: false,
|
columnsResizable: false,
|
||||||
columnsPinnable: false,
|
columnsPinnable: false,
|
||||||
columnsMovable: false,
|
columnsMovable: false,
|
||||||
@@ -167,10 +189,24 @@ export function DataGridCard<TData extends object>({
|
|||||||
height = 480,
|
height = 480,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
onRowSelectionChange,
|
onRowSelectionChange,
|
||||||
|
enableColumnVisibility = false,
|
||||||
|
columnVisibilityStorageKey,
|
||||||
|
initialColumnVisibility,
|
||||||
className,
|
className,
|
||||||
}: DataGridCardProps<TData>) {
|
}: DataGridCardProps<TData>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
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> = {
|
const selectColumn: ColumnDef<TData, unknown> = {
|
||||||
id: 'select',
|
id: 'select',
|
||||||
@@ -191,6 +227,7 @@ export function DataGridCard<TData extends object>({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
meta: { cellClassName: 'w-10' },
|
meta: { cellClassName: 'w-10' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +240,13 @@ export function DataGridCard<TData extends object>({
|
|||||||
const table = useReactTable<TData>({
|
const table = useReactTable<TData>({
|
||||||
data,
|
data,
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
state: { sorting, ...(enableRowSelection ? { rowSelection } : {}) },
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
...(enableRowSelection ? { rowSelection } : {}),
|
||||||
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: enableRowSelection
|
onRowSelectionChange: enableRowSelection
|
||||||
? (updater) => {
|
? (updater) => {
|
||||||
setRowSelection((prev) => {
|
setRowSelection((prev) => {
|
||||||
@@ -229,9 +271,29 @@ export function DataGridCard<TData extends object>({
|
|||||||
: undefined,
|
: undefined,
|
||||||
enableColumnPinning: pinLastColumn,
|
enableColumnPinning: pinLastColumn,
|
||||||
enableRowSelection,
|
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 (data.length === 0) {
|
||||||
if (!hasHeader) {
|
if (!hasHeader) {
|
||||||
@@ -249,7 +311,7 @@ export function DataGridCard<TData extends object>({
|
|||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
{headerActions ? <div className="flex items-center gap-2">{headerActions}</div> : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
@@ -269,11 +331,19 @@ export function DataGridCard<TData extends object>({
|
|||||||
height={height}
|
height={height}
|
||||||
footerContent={footerContent}
|
footerContent={footerContent}
|
||||||
showPagination={showPagination}
|
showPagination={showPagination}
|
||||||
|
enableColumnVisibility={enableColumnVisibility}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!hasHeader) {
|
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 (
|
return (
|
||||||
@@ -283,7 +353,9 @@ export function DataGridCard<TData extends object>({
|
|||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
|
<CardContent className="p-0 pt-2">{gridBody}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -319,6 +391,7 @@ export function columnDefFromDataTable<T>(
|
|||||||
: () => c.header,
|
: () => c.header,
|
||||||
cell: ({ row }) => c.cell(row.original, row.index),
|
cell: ({ row }) => c.cell(row.original, row.index),
|
||||||
enableSorting: sortable,
|
enableSorting: sortable,
|
||||||
|
enableHiding: c.enableHiding ?? true,
|
||||||
meta: {
|
meta: {
|
||||||
headerTitle: title || undefined,
|
headerTitle: title || undefined,
|
||||||
cellClassName: c.className,
|
cellClassName: c.className,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DataTableColumn<T> {
|
|||||||
headerTitle?: string
|
headerTitle?: string
|
||||||
className?: string
|
className?: string
|
||||||
headerClassName?: string
|
headerClassName?: string
|
||||||
|
enableHiding?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Унифицированные классы колонок для DataGridCard. */
|
/** Унифицированные классы колонок для 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 { vpsStatusLabel, tariffTypeLabel } from '@/lib/format'
|
||||||
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
import { buildCityOptions, cityMatchesCountry, resolveCountryForCityFromRows } from '@cfdm/shared/geo'
|
||||||
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
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 { parseCustomData, type CustomFieldDef } from '@/lib/custom-fields'
|
||||||
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
import type { Provider, ProviderAccount, Vps } from '@/types/entities'
|
||||||
import type { ZodType } from 'zod'
|
import type { ZodType } from 'zod'
|
||||||
@@ -336,44 +337,7 @@ export function VpsEditSheet({
|
|||||||
<FormField label="Заметки" htmlFor="vps-notes">
|
<FormField label="Заметки" htmlFor="vps-notes">
|
||||||
<Textarea id="vps-notes" {...register('notes')} />
|
<Textarea id="vps-notes" {...register('notes')} />
|
||||||
</FormField>
|
</FormField>
|
||||||
{customFieldDefs.length > 0 ? (
|
<CustomFieldValues defs={customFieldDefs} watch={watch} setValue={setValue} />
|
||||||
<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 ? (
|
{editingId ? (
|
||||||
<FormField
|
<FormField
|
||||||
label="Не перезаписывать при синке"
|
label="Не перезаписывать при синке"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function DataGridColumnVisibility<TData>({
|
|||||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-medium">
|
<DropdownMenuLabel className="font-medium">
|
||||||
Toggle Columns
|
Колонки
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{table
|
{table
|
||||||
.getAllColumns()
|
.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 { z } from 'zod'
|
||||||
import { billingModeSchema as sharedBillingModeSchema } from '@cfdm/shared/contracts/provider-account'
|
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 vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||||
@@ -100,7 +101,12 @@ export const settingsSchema = z.object({
|
|||||||
notifyVpsDownEnabled: z.boolean().optional().default(true),
|
notifyVpsDownEnabled: z.boolean().optional().default(true),
|
||||||
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
|
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional().default(''),
|
||||||
webhookEnabled: z.boolean().optional().default(false),
|
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({
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
import { parseCustomFieldDefs } from '@cfdm/shared/contracts/custom-fields'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
||||||
|
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
||||||
import type { Settings } from '@/types/entities'
|
import type { Settings } from '@/types/entities'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
@@ -48,11 +49,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
|
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
|
||||||
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
|
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
|
||||||
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
|
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
|
||||||
customFieldsJson: JSON.stringify(
|
customFields: parseCustomFieldDefs(s.customFields),
|
||||||
(s as Settings & { customFields?: unknown[] }).customFields ?? [],
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
|
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,15 +93,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
const upsertMut = useMutation({
|
const upsertMut = useMutation({
|
||||||
mutationFn: (patch: SettingsFormValues) => {
|
mutationFn: (patch: SettingsFormValues) => {
|
||||||
const { customFieldsJson, ...rest } = patch
|
const payload = { ...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 }
|
|
||||||
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
||||||
return api.create<Settings>('settings', {
|
return api.create<Settings>('settings', {
|
||||||
id: 'settings-main',
|
id: 'settings-main',
|
||||||
@@ -400,16 +389,16 @@ function SettingsPage() {
|
|||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Кастомные поля VPS</CardTitle>
|
<CardTitle>Кастомные поля VPS</CardTitle>
|
||||||
<CardDescription>JSON-массив: {"{ \"key\", \"label\", \"type\": \"text|number|bool\" }"}</CardDescription>
|
<CardDescription>
|
||||||
|
Поля отображаются в таблице VPS и в форме редактирования сервера
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<FormField label="Схема полей" htmlFor="set-custom-fields" error={form.formState.errors.customFieldsJson?.message}>
|
<CustomFieldsEditor
|
||||||
<Textarea
|
control={form.control}
|
||||||
id="set-custom-fields"
|
setValue={form.setValue}
|
||||||
className="min-h-32 font-mono text-xs"
|
errors={form.formState.errors.customFields}
|
||||||
{...form.register('customFieldsJson')}
|
/>
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
} from '@/lib/format'
|
} from '@/lib/format'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
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'
|
import type { Payment, Vps } from '@/types/entities'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
||||||
@@ -75,6 +80,19 @@ function VpsDetailPage() {
|
|||||||
|
|
||||||
const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
|
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>[] = [
|
const paymentColumns: DataTableColumn<Payment>[] = [
|
||||||
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||||
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
||||||
@@ -167,6 +185,22 @@ function VpsDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<TabsContent value="finance" className="flex flex-col gap-4">
|
<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 { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
||||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||||
import { parseCustomFieldDefs } from '@/lib/custom-fields'
|
import { parseCustomFieldDefs, buildCustomFieldColumns, buildCustomFieldColumnVisibility } from '@/lib/custom-fields'
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
@@ -264,7 +264,18 @@ function VpsPage() {
|
|||||||
}))
|
}))
|
||||||
}, [filteredVps, filters.groupByProject])
|
}, [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',
|
key: 'ip',
|
||||||
header: 'IP / DNS',
|
header: 'IP / DNS',
|
||||||
@@ -403,6 +414,7 @@ function VpsPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
enableHiding: false,
|
||||||
className: 'w-24 text-right',
|
className: 'w-24 text-right',
|
||||||
cell: (v) => (
|
cell: (v) => (
|
||||||
<RowActions
|
<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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -514,6 +536,9 @@ function VpsPage() {
|
|||||||
onRowSelectionChange={setSelectedIds}
|
onRowSelectionChange={setSelectedIds}
|
||||||
virtualization={section.items.length > 200}
|
virtualization={section.items.length > 200}
|
||||||
height={560}
|
height={560}
|
||||||
|
enableColumnVisibility
|
||||||
|
columnVisibilityStorageKey="vps-column-visibility"
|
||||||
|
initialColumnVisibility={customColumnVisibility}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CustomFieldDef } from '@cfdm/shared/contracts/custom-fields'
|
||||||
|
|
||||||
export type VpsStatus = 'active' | 'paused' | 'archived'
|
export type VpsStatus = 'active' | 'paused' | 'archived'
|
||||||
export type TariffType = 'daily' | 'monthly'
|
export type TariffType = 'daily' | 'monthly'
|
||||||
export type BillingMode = 'daily' | 'monthly'
|
export type BillingMode = 'daily' | 'monthly'
|
||||||
@@ -77,6 +79,7 @@ export interface Vps {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
paidUntil?: string
|
paidUntil?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
customData?: string | Record<string, string | number | boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
@@ -115,6 +118,7 @@ export interface Settings {
|
|||||||
notifyNewTariffsEnabled?: boolean
|
notifyNewTariffsEnabled?: boolean
|
||||||
telegramChatId?: string
|
telegramChatId?: string
|
||||||
telegramBotToken?: string
|
telegramBotToken?: string
|
||||||
|
customFields?: CustomFieldDef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveTariff {
|
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 { z } from 'zod'
|
||||||
|
import { customFieldsSchema } from './custom-fields.js'
|
||||||
|
|
||||||
export const settingsSchema = z.object({
|
export const settingsSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -19,7 +20,7 @@ export const settingsSchema = z.object({
|
|||||||
notifyVpsDownEnabled: z.boolean().optional(),
|
notifyVpsDownEnabled: z.boolean().optional(),
|
||||||
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||||
webhookEnabled: z.boolean().optional(),
|
webhookEnabled: z.boolean().optional(),
|
||||||
customFields: z.any().optional(),
|
customFields: customFieldsSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Settings = z.infer<typeof settingsSchema>
|
export type Settings = z.infer<typeof settingsSchema>
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export * from './contracts/vps.js'
|
|||||||
export * from './contracts/payment.js'
|
export * from './contracts/payment.js'
|
||||||
export * from './contracts/balance-ledger.js'
|
export * from './contracts/balance-ledger.js'
|
||||||
export * from './contracts/settings.js'
|
export * from './contracts/settings.js'
|
||||||
|
export * from './contracts/custom-fields.js'
|
||||||
export * from './contracts/project.js'
|
export * from './contracts/project.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user