From 5a3241a7d1a60cc463b513e8bfba3f050734e6c2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 28 Jun 2026 19:28:32 +0700 Subject: [PATCH] =?UTF-8?q?feat(vps):=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=B2=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменён JSON-редактор в настройках на UI с drag-reorder, добавлены динамические колонки VPS с picker видимости и типизированные контракты в @cfdm/shared. Co-authored-by: Cursor --- apps/web/src/components/data-grid-card.tsx | 87 +++++- apps/web/src/components/data-grid-types.ts | 1 + .../components/domain/custom-field-values.tsx | 111 ++++++++ .../domain/custom-fields-editor.tsx | 253 ++++++++++++++++++ .../src/components/domain/vps-edit-sheet.tsx | 40 +-- .../data-grid/data-grid-column-visibility.tsx | 2 +- apps/web/src/lib/custom-fields.ts | 34 --- apps/web/src/lib/custom-fields.tsx | 72 +++++ apps/web/src/lib/schemas.ts | 8 +- apps/web/src/routes/_auth/settings.tsx | 35 +-- apps/web/src/routes/_auth/vps.$vpsId.tsx | 34 +++ apps/web/src/routes/_auth/vps.tsx | 31 ++- apps/web/src/types/entities.ts | 4 + .../shared/src/contracts/custom-fields.ts | 83 ++++++ packages/shared/src/contracts/settings.ts | 3 +- packages/shared/src/index.ts | 1 + 16 files changed, 691 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/components/domain/custom-field-values.tsx create mode 100644 apps/web/src/components/domain/custom-fields-editor.tsx delete mode 100644 apps/web/src/lib/custom-fields.ts create mode 100644 apps/web/src/lib/custom-fields.tsx create mode 100644 packages/shared/src/contracts/custom-fields.ts diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 43464c1..b198a45 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -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 { title?: ReactNode description?: ReactNode @@ -70,6 +84,12 @@ export interface DataGridCardProps { enableRowSelection?: boolean /** Callback при изменении выбора. */ onRowSelectionChange?: (selectedIds: string[]) => void + /** Показать picker видимости колонок. */ + enableColumnVisibility?: boolean + /** Ключ localStorage для сохранения видимости колонок. */ + columnVisibilityStorageKey?: string + /** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */ + initialColumnVisibility?: VisibilityState className?: string } @@ -91,6 +111,7 @@ function DataGridCardBody({ height, footerContent, showPagination, + enableColumnVisibility, }: { table: ReturnType> data: TData[] @@ -101,6 +122,7 @@ function DataGridCardBody({ height: number footerContent?: ReactNode showPagination: boolean + enableColumnVisibility: boolean }) { return ( @@ -117,7 +139,7 @@ function DataGridCardBody({ headerBackground: true, headerBorder: true, width: 'auto', - columnsVisibility: false, + columnsVisibility: enableColumnVisibility, columnsResizable: false, columnsPinnable: false, columnsMovable: false, @@ -167,10 +189,24 @@ export function DataGridCard({ height = 480, enableRowSelection = false, onRowSelectionChange, + enableColumnVisibility = false, + columnVisibilityStorageKey, + initialColumnVisibility, className, }: DataGridCardProps) { const [sorting, setSorting] = useState(initialSorting ?? []) const [rowSelection, setRowSelection] = useState({}) + const [columnVisibility, setColumnVisibility] = useState(() => { + const stored = columnVisibilityStorageKey + ? loadStoredColumnVisibility(columnVisibilityStorageKey) + : undefined + return { ...initialColumnVisibility, ...stored } + }) + + useEffect(() => { + if (!columnVisibilityStorageKey) return + localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility)) + }, [columnVisibility, columnVisibilityStorageKey]) const selectColumn: ColumnDef = { id: 'select', @@ -191,6 +227,7 @@ export function DataGridCard({ /> ), enableSorting: false, + enableHiding: false, meta: { cellClassName: 'w-10' }, } @@ -203,8 +240,13 @@ export function DataGridCard({ const table = useReactTable({ 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({ : undefined, enableColumnPinning: pinLastColumn, enableRowSelection, + enableHiding: enableColumnVisibility, }) - const hasHeader = Boolean(title || description || actions) + const columnVisibilityAction = enableColumnVisibility ? ( + + + Колонки + + } + /> + ) : null + + const headerActions = ( +
+ {columnVisibilityAction} + {actions} +
+ ) + + const hasHeader = Boolean(title || description || actions || enableColumnVisibility) if (data.length === 0) { if (!hasHeader) { @@ -249,7 +311,7 @@ export function DataGridCard({ {title ? {title} : null} {description ?

{description}

: null} - {actions ?
{actions}
: null} + {headerActions ?
{headerActions}
: null} @@ -269,11 +331,19 @@ export function DataGridCard({ height={height} footerContent={footerContent} showPagination={showPagination} + enableColumnVisibility={enableColumnVisibility} /> ) if (!hasHeader) { - return
{gridBody}
+ return ( +
+ {enableColumnVisibility ? ( +
{columnVisibilityAction}
+ ) : null} + {gridBody} +
+ ) } return ( @@ -283,7 +353,9 @@ export function DataGridCard({ {title ? {title} : null} {description ?

{description}

: null} - {actions ?
{actions}
: null} + {(actions || enableColumnVisibility) ? ( +
{headerActions}
+ ) : null} {gridBody} @@ -319,6 +391,7 @@ export function columnDefFromDataTable( : () => c.header, cell: ({ row }) => c.cell(row.original, row.index), enableSorting: sortable, + enableHiding: c.enableHiding ?? true, meta: { headerTitle: title || undefined, cellClassName: c.className, diff --git a/apps/web/src/components/data-grid-types.ts b/apps/web/src/components/data-grid-types.ts index 15bda03..8a29011 100644 --- a/apps/web/src/components/data-grid-types.ts +++ b/apps/web/src/components/data-grid-types.ts @@ -11,6 +11,7 @@ export interface DataTableColumn { headerTitle?: string className?: string headerClassName?: string + enableHiding?: boolean } /** Унифицированные классы колонок для DataGridCard. */ diff --git a/apps/web/src/components/domain/custom-field-values.tsx b/apps/web/src/components/domain/custom-field-values.tsx new file mode 100644 index 0000000..1fab871 --- /dev/null +++ b/apps/web/src/components/domain/custom-field-values.tsx @@ -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 + setValue: UseFormSetValue +} + +function setCustomFieldValue( + setValue: UseFormSetValue, + watch: UseFormWatch, + 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 ( + <> + +
+

Дополнительные поля

+ {defs.map((field) => { + if (field.type === 'bool') { + return ( +
+ setCustomFieldValue(setValue, watch, field.key, Boolean(v))} + /> + +
+ ) + } + if (field.type === 'number') { + const raw = customData[field.key] + const numVal = typeof raw === 'number' && Number.isFinite(raw) ? raw : null + return ( + + + setCustomFieldValue( + setValue, + watch, + field.key, + v == null || !Number.isFinite(v) ? undefined : v, + ) + } + > + + + + + + + + ) + } + return ( + + + setCustomFieldValue( + setValue, + watch, + field.key, + e.target.value || undefined, + ) + } + /> + + ) + })} +
+ + ) +} diff --git a/apps/web/src/components/domain/custom-fields-editor.tsx b/apps/web/src/components/domain/custom-fields-editor.tsx new file mode 100644 index 0000000..fa4e830 --- /dev/null +++ b/apps/web/src/components/domain/custom-fields-editor.tsx @@ -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 + setValue: UseFormSetValue + errors?: FieldErrors['customFields'] +} + +function SortableFieldRow({ + id, + index, + control, + setValue, + errors, + onRemove, + manualKeysRef, +}: { + id: string + index: number + control: Control + setValue: UseFormSetValue + errors?: FieldErrors['customFields'] + onRemove: () => void + manualKeysRef: MutableRefObject> +}) { + 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 ( +
+ +
+ ( + + { + field.onBlur() + if (!manualKeysRef.current.has(index)) { + setValue(`customFields.${index}.key`, slugifyCustomFieldKey(e.target.value), { + shouldDirty: true, + }) + } + }} + /> + + )} + /> + ( + + { + manualKeysRef.current.add(index) + field.onChange(e) + }} + onBlur={field.onBlur} + /> + + )} + /> + ( + + field.onChange(v ?? 'text')} + options={[...FIELD_TYPES]} + /> + + )} + /> +
+ + + + } + title="Удалить поле?" + description="Значения этого поля в VPS сохранятся в данных, но перестанут отображаться." + confirmLabel="Удалить" + destructive + onConfirm={onRemove} + /> +
+ ) +} + +export function CustomFieldsEditor({ control, setValue, errors }: CustomFieldsEditorProps) { + const { fields, append, remove, move } = useFieldArray({ + control, + name: 'customFields', + }) + const manualKeysRef = useRef>(new Set()) + const [rowIds, setRowIds] = useState([]) + + 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 ( + + + Добавить поле + + } + /> + ) + } + + const ids = fields.map((f, i) => rowIds[i] ?? f.id) + + return ( +
+ + + {fields.map((field, index) => ( + remove(index)} + manualKeysRef={manualKeysRef} + /> + ))} + + + +
+ ) +} diff --git a/apps/web/src/components/domain/vps-edit-sheet.tsx b/apps/web/src/components/domain/vps-edit-sheet.tsx index ee1cf49..f43ed59 100644 --- a/apps/web/src/components/domain/vps-edit-sheet.tsx +++ b/apps/web/src/components/domain/vps-edit-sheet.tsx @@ -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({