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 {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user