diff --git a/client/src/components/TableFieldSelect.tsx b/client/src/components/TableFieldSelect.tsx
index d499620..7ae710b 100644
--- a/client/src/components/TableFieldSelect.tsx
+++ b/client/src/components/TableFieldSelect.tsx
@@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Table } from 'lucide-react';
+import { DirectoryTreeSelect } from '@/components/directories/DirectoryTreeSelect';
+import type { DataTableRow } from '@shared/schema';
interface TableFieldSelectProps {
fieldId: number;
@@ -27,7 +29,7 @@ export default function TableFieldSelect({
const { data: tableData, isLoading, error } = useQuery<{
table: {
- rows: Array<{ id: number; values: string[] }>;
+ rows: DataTableRow[];
columns: Array<{ name: string }>;
};
}>({
@@ -94,6 +96,8 @@ export default function TableFieldSelect({
setIsOpen(false);
};
+ const hasHierarchy = rows.some((r) => r.parentId !== null && r.parentId !== undefined);
+
return (
@@ -125,29 +129,41 @@ export default function TableFieldSelect({
}
}}
>
-
- - { e.preventDefault(); handleSelect(''); }}
- >
- —
-
- {filtered.map((row) => (
- - { e.preventDefault(); handleSelect(row.id.toString()); }}
- >
-
-
- {row.values[displayColumnIndex!] || row.values[0]}
-
-
- ))}
- {filtered.length === 0 && (
- - Нет совпадений
+
+ {hasHierarchy ? (
+
+ ) : (
+
+ - { e.preventDefault(); handleSelect(''); }}
+ >
+ —
+
+ {filtered.map((row) => (
+ - { e.preventDefault(); handleSelect(row.id.toString()); }}
+ >
+
+
+ {row.values[displayColumnIndex!] || row.values[0]}
+
+
+ ))}
+ {filtered.length === 0 && (
+ - Нет совпадений
+ )}
+
)}
-
+
);
diff --git a/client/src/components/directories/DirectoryRowTree.tsx b/client/src/components/directories/DirectoryRowTree.tsx
new file mode 100644
index 0000000..8597e0a
--- /dev/null
+++ b/client/src/components/directories/DirectoryRowTree.tsx
@@ -0,0 +1,371 @@
+import { useMemo } from 'react';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ sortableKeyboardCoordinates,
+} from '@dnd-kit/sortable';
+import { KeyboardSensor, PointerSensor, useDroppable } from '@dnd-kit/core';
+import { CSS } from '@dnd-kit/utilities';
+import { ChevronRight, ChevronDown, GripVertical, Pencil, Trash2, Plus, FileText } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import type { DataTableRow } from '@shared/schema';
+
+export interface DirectoryRowTreeItem extends DataTableRow {
+ level: number;
+ children: DirectoryRowTreeItem[];
+}
+
+interface DirectoryRowTreeProps {
+ rows: DataTableRow[];
+ labelColumnIndex?: number;
+ selectedRowId?: number | null;
+ expanded: Set
;
+ onSelect: (row: DataTableRow) => void;
+ onToggleExpand: (id: number) => void;
+ onEdit: (row: DataTableRow) => void;
+ onDelete: (row: DataTableRow) => void;
+ onAddChild: (parentId: number | null) => void;
+ overRowId?: number | null;
+}
+
+export const ROW_SENSOR_CONFIG = {
+ pointer: { activationConstraint: { distance: 5 } },
+ keyboard: { coordinateGetter: sortableKeyboardCoordinates },
+};
+
+export function rowSortableId(id: number) {
+ return `row-${id}`;
+}
+
+export function rowDroppableId(id: number) {
+ return `row-drop-${id}`;
+}
+
+export function rowGapId(parentId: number | null, position: number) {
+ return `row-gap-${parentId ?? 'root'}-${position}`;
+}
+
+export function parseRowSortableId(id: string | number): number | null {
+ if (typeof id === 'number') return id;
+ if (typeof id === 'string' && id.startsWith('row-')) {
+ const num = parseInt(id.replace('row-', ''), 10);
+ return isNaN(num) ? null : num;
+ }
+ return null;
+}
+
+function buildTree(rows: DataTableRow[]): DirectoryRowTreeItem[] {
+ const map = new Map();
+ const roots: DirectoryRowTreeItem[] = [];
+
+ const sorted = [...rows].sort((a, b) => {
+ if ((a.parentId ?? -1) !== (b.parentId ?? -1)) return 0;
+ return a.position - b.position;
+ });
+
+ for (const row of sorted) {
+ map.set(row.id, {
+ ...row,
+ level: 0,
+ children: [],
+ });
+ }
+
+ for (const row of sorted) {
+ const node = map.get(row.id)!;
+ if (row.parentId === null || row.parentId === undefined) {
+ roots.push(node);
+ } else {
+ const parent = map.get(row.parentId);
+ if (parent) {
+ node.level = parent.level + 1;
+ parent.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+ }
+
+ return roots.sort((a, b) => a.position - b.position);
+}
+
+function getVisibleItems(tree: DirectoryRowTreeItem[], expanded: Set): DirectoryRowTreeItem[] {
+ const result: DirectoryRowTreeItem[] = [];
+ const walk = (items: DirectoryRowTreeItem[]) => {
+ for (const item of items) {
+ result.push(item);
+ if (expanded.has(item.id)) {
+ walk(item.children);
+ }
+ }
+ };
+ walk(tree);
+ return result;
+}
+
+function filterTree(
+ tree: DirectoryRowTreeItem[],
+ query: string
+): { tree: DirectoryRowTreeItem[]; expanded: Set } {
+ if (!query.trim()) return { tree, expanded: new Set() };
+ const q = query.toLowerCase();
+ const expanded = new Set();
+ function walk(nodes: DirectoryRowTreeItem[]): DirectoryRowTreeItem[] {
+ const visible: DirectoryRowTreeItem[] = [];
+ for (const node of nodes) {
+ const label = String(node.values[0] ?? '').toLowerCase();
+ const children = walk(node.children);
+ const childMatch = children.length > 0;
+ if (label.includes(q) || childMatch) {
+ visible.push({ ...node, children });
+ if (childMatch) expanded.add(node.id);
+ }
+ }
+ return visible;
+ }
+ return { tree: walk(tree), expanded };
+}
+
+function getRowLabel(row: DataTableRow, labelColumnIndex = 0): string {
+ const values = Array.isArray(row.values) ? row.values : [];
+ return String(values[labelColumnIndex] ?? '') || '(без названия)';
+}
+
+function DropGap({
+ parentId,
+ position,
+ level,
+}: {
+ parentId: number | null;
+ position: number;
+ level: number;
+}) {
+ const { setNodeRef, isOver } = useDroppable({
+ id: rowGapId(parentId, position),
+ data: { type: 'row-gap', parentId, position },
+ });
+
+ return (
+
+ );
+}
+
+function SortableRowNode({
+ item,
+ labelColumnIndex,
+ selectedRowId,
+ expanded,
+ onToggleExpand,
+ onSelect,
+ onEdit,
+ onDelete,
+ onAddChild,
+ overRowId,
+}: DirectoryRowTreeProps & { item: DirectoryRowTreeItem }) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: rowSortableId(item.id), data: { type: 'row', rowId: item.id } });
+
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
+ id: rowDroppableId(item.id),
+ data: { type: 'row', rowId: item.id },
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.4 : 1,
+ };
+
+ const hasChildren = item.children.length > 0;
+ const isSelected = selectedRowId === item.id;
+ const isOverRow = isOver || overRowId === item.id;
+
+ return (
+ {
+ setNodeRef(node);
+ setDropRef(node);
+ }}
+ style={style}
+ >
+
onSelect(item)}
+ onDoubleClick={() => hasChildren && onToggleExpand(item.id)}
+ >
+
+
+
+
+
{getRowLabel(item, labelColumnIndex)}
+
+
e.stopPropagation()}
+ >
+
+
+
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
+ );
+}
+
+export function DirectoryRowTree({
+ rows,
+ labelColumnIndex = 0,
+ selectedRowId,
+ expanded,
+ onSelect,
+ onToggleExpand,
+ onEdit,
+ onDelete,
+ onAddChild,
+ overRowId,
+ searchQuery,
+}: DirectoryRowTreeProps & { searchQuery?: string }) {
+ const tree = useMemo(() => buildTree(rows), [rows]);
+ const filtered = useMemo(() => filterTree(tree, searchQuery || ''), [tree, searchQuery]);
+ const effectiveExpanded = useMemo(
+ () => (searchQuery?.trim() ? filtered.expanded : expanded),
+ [searchQuery, filtered.expanded, expanded]
+ );
+ const displayTree = useMemo(
+ () => (searchQuery?.trim() ? filtered.tree : tree),
+ [searchQuery, filtered.tree, tree]
+ );
+ const visibleItems = useMemo(
+ () => getVisibleItems(displayTree, effectiveExpanded),
+ [displayTree, effectiveExpanded]
+ );
+
+ const rowsWithGaps = useMemo(() => {
+ const result: (
+ | { type: 'gap'; parentId: number | null; position: number; level: number }
+ | { type: 'row'; item: DirectoryRowTreeItem }
+ )[] = [];
+
+ function walk(items: DirectoryRowTreeItem[]) {
+ for (let i = 0; i <= items.length; i++) {
+ const parentId = items[0]?.parentId ?? null;
+ const level = items[0]?.level ?? 0;
+ result.push({ type: 'gap', parentId, position: i, level });
+ if (i < items.length) {
+ const item = items[i];
+ result.push({ type: 'row', item });
+ if (effectiveExpanded.has(item.id)) {
+ walk(item.children);
+ }
+ }
+ }
+ }
+
+ walk(displayTree);
+ return result;
+ }, [displayTree, effectiveExpanded]);
+
+ return (
+ rowSortableId(item.id))} strategy={verticalListSortingStrategy}>
+
+ {rowsWithGaps.map((row, idx) =>
+ row.type === 'gap' ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/client/src/components/directories/DirectoryTreeMultiSelect.tsx b/client/src/components/directories/DirectoryTreeMultiSelect.tsx
new file mode 100644
index 0000000..5b42496
--- /dev/null
+++ b/client/src/components/directories/DirectoryTreeMultiSelect.tsx
@@ -0,0 +1,182 @@
+import { useMemo, useState } from 'react';
+import { ChevronRight, ChevronDown, FileText } from 'lucide-react';
+import { Checkbox } from '@/components/ui/checkbox';
+import { cn } from '@/lib/utils';
+import type { DataTableRow } from '@shared/schema';
+
+interface TreeItem extends DataTableRow {
+ level: number;
+ children: TreeItem[];
+}
+
+interface DirectoryTreeMultiSelectProps {
+ rows: DataTableRow[];
+ displayColumnIndex: number;
+ selected: Set;
+ onToggle: (rowId: string) => void;
+ searchTerm?: string;
+}
+
+function buildTree(rows: DataTableRow[]): TreeItem[] {
+ const map = new Map();
+ const roots: TreeItem[] = [];
+
+ const sorted = [...rows].sort((a, b) => {
+ if ((a.parentId ?? -1) !== (b.parentId ?? -1)) return 0;
+ return a.position - b.position;
+ });
+
+ for (const row of sorted) {
+ map.set(row.id, { ...row, level: 0, children: [] });
+ }
+
+ for (const row of sorted) {
+ const node = map.get(row.id)!;
+ if (row.parentId === null || row.parentId === undefined) {
+ roots.push(node);
+ } else {
+ const parent = map.get(row.parentId);
+ if (parent) {
+ node.level = parent.level + 1;
+ parent.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+ }
+
+ return roots.sort((a, b) => a.position - b.position);
+}
+
+function filterTree(tree: TreeItem[], query: string, displayColumnIndex: number): { tree: TreeItem[]; expanded: Set } {
+ if (!query.trim()) return { tree, expanded: new Set() };
+ const q = query.toLowerCase();
+ const expanded = new Set();
+ function walk(nodes: TreeItem[]): TreeItem[] {
+ const visible: TreeItem[] = [];
+ for (const node of nodes) {
+ const label = String(node.values[displayColumnIndex] ?? node.values[0] ?? '').toLowerCase();
+ const children = walk(node.children);
+ const childMatch = children.length > 0;
+ if (label.includes(q) || childMatch) {
+ visible.push({ ...node, children });
+ if (childMatch) expanded.add(node.id);
+ }
+ }
+ return visible;
+ }
+ return { tree: walk(tree), expanded };
+}
+
+function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
+ const values = Array.isArray(row.values) ? row.values : [];
+ return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
+}
+
+function TreeNode({
+ node,
+ displayColumnIndex,
+ selected,
+ onToggle,
+ expanded,
+ onToggleExpand,
+}: {
+ node: TreeItem;
+ displayColumnIndex: number;
+ selected: Set;
+ onToggle: (rowId: string) => void;
+ expanded: Set;
+ onToggleExpand: (id: number) => void;
+}) {
+ const hasChildren = node.children.length > 0;
+ const rowId = node.id.toString();
+
+ return (
+
+
{ e.preventDefault(); onToggle(rowId); }}
+ >
+ {hasChildren ? (
+ {
+ e.stopPropagation();
+ onToggleExpand(node.id);
+ }}
+ >
+ {expanded.has(node.id) ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {getRowLabel(node, displayColumnIndex)}
+
+ {hasChildren && expanded.has(node.id) && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function DirectoryTreeMultiSelect({ rows, displayColumnIndex, selected, onToggle, searchTerm = '' }: DirectoryTreeMultiSelectProps) {
+ const tree = useMemo(() => buildTree(rows), [rows]);
+ const filtered = useMemo(() => filterTree(tree, searchTerm, displayColumnIndex), [tree, searchTerm, displayColumnIndex]);
+ const [manualExpanded, setManualExpanded] = useState>(new Set());
+
+ const effectiveExpanded = useMemo(
+ () => (searchTerm.trim() ? filtered.expanded : manualExpanded),
+ [searchTerm, filtered.expanded, manualExpanded]
+ );
+ const displayTree = useMemo(
+ () => (searchTerm.trim() ? filtered.tree : tree),
+ [searchTerm, filtered.tree, tree]
+ );
+
+ const toggleExpand = (id: number) => {
+ setManualExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ return (
+
+ {displayTree.map((node) => (
+
+ ))}
+ {displayTree.length === 0 && (
+
Нет совпадений
+ )}
+
+ );
+}
diff --git a/client/src/components/directories/DirectoryTreeSelect.tsx b/client/src/components/directories/DirectoryTreeSelect.tsx
new file mode 100644
index 0000000..a86f383
--- /dev/null
+++ b/client/src/components/directories/DirectoryTreeSelect.tsx
@@ -0,0 +1,193 @@
+import { useMemo, useState } from 'react';
+import { ChevronRight, ChevronDown, FileText } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { DataTableRow } from '@shared/schema';
+
+interface TreeItem extends DataTableRow {
+ level: number;
+ children: TreeItem[];
+}
+
+interface DirectoryTreeSelectProps {
+ rows: DataTableRow[];
+ displayColumnIndex: number;
+ value?: string | number | null;
+ onChange: (rowId: string) => void;
+ searchTerm?: string;
+}
+
+function buildTree(rows: DataTableRow[]): TreeItem[] {
+ const map = new Map();
+ const roots: TreeItem[] = [];
+
+ const sorted = [...rows].sort((a, b) => {
+ if ((a.parentId ?? -1) !== (b.parentId ?? -1)) return 0;
+ return a.position - b.position;
+ });
+
+ for (const row of sorted) {
+ map.set(row.id, { ...row, level: 0, children: [] });
+ }
+
+ for (const row of sorted) {
+ const node = map.get(row.id)!;
+ if (row.parentId === null || row.parentId === undefined) {
+ roots.push(node);
+ } else {
+ const parent = map.get(row.parentId);
+ if (parent) {
+ node.level = parent.level + 1;
+ parent.children.push(node);
+ } else {
+ roots.push(node);
+ }
+ }
+ }
+
+ return roots.sort((a, b) => a.position - b.position);
+}
+
+function filterTree(tree: TreeItem[], query: string, displayColumnIndex: number): { tree: TreeItem[]; expanded: Set } {
+ if (!query.trim()) return { tree, expanded: new Set() };
+ const q = query.toLowerCase();
+ const expanded = new Set();
+ function walk(nodes: TreeItem[]): TreeItem[] {
+ const visible: TreeItem[] = [];
+ for (const node of nodes) {
+ const label = String(node.values[displayColumnIndex] ?? node.values[0] ?? '').toLowerCase();
+ const children = walk(node.children);
+ const childMatch = children.length > 0;
+ if (label.includes(q) || childMatch) {
+ visible.push({ ...node, children });
+ if (childMatch) expanded.add(node.id);
+ }
+ }
+ return visible;
+ }
+ return { tree: walk(tree), expanded };
+}
+
+function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
+ const values = Array.isArray(row.values) ? row.values : [];
+ return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
+}
+
+function TreeNode({
+ node,
+ displayColumnIndex,
+ value,
+ onChange,
+ expanded,
+ onToggle,
+}: {
+ node: TreeItem;
+ displayColumnIndex: number;
+ value?: string | number | null;
+ onChange: (rowId: string) => void;
+ expanded: Set;
+ onToggle: (id: number) => void;
+}) {
+ const hasChildren = node.children.length > 0;
+ const isSelected = value?.toString() === node.id.toString();
+
+ return (
+
+
+ {hasChildren && expanded.has(node.id) && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function DirectoryTreeSelect({ rows, displayColumnIndex, value, onChange, searchTerm = '' }: DirectoryTreeSelectProps) {
+ const tree = useMemo(() => buildTree(rows), [rows]);
+ const filtered = useMemo(() => filterTree(tree, searchTerm, displayColumnIndex), [tree, searchTerm, displayColumnIndex]);
+ const [manualExpanded, setManualExpanded] = useState>(new Set());
+
+ const effectiveExpanded = useMemo(
+ () => (searchTerm.trim() ? filtered.expanded : manualExpanded),
+ [searchTerm, filtered.expanded, manualExpanded]
+ );
+ const displayTree = useMemo(
+ () => (searchTerm.trim() ? filtered.tree : tree),
+ [searchTerm, filtered.tree, tree]
+ );
+
+ const toggle = (id: number) => {
+ setManualExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ return (
+
+
+ {displayTree.map((node) => (
+
+ ))}
+ {displayTree.length === 0 && (
+
Нет совпадений
+ )}
+
+ );
+}
diff --git a/client/src/pages/TableEditor.tsx b/client/src/pages/TableEditor.tsx
index ca73e06..9c853c3 100644
--- a/client/src/pages/TableEditor.tsx
+++ b/client/src/pages/TableEditor.tsx
@@ -29,7 +29,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { normalizeSelectOptions } from '@/lib/fieldOptions';
import {
ArrowLeft, Plus, Trash2, Save, Settings, Users, Download, Upload,
- Check, X, Pencil, FileSpreadsheet, Globe
+ Check, X, Pencil, FileSpreadsheet, Globe, ListTree, GripVertical, Search, FileText
} from 'lucide-react';
import { FIELD_TYPE_DEFINITIONS } from '@shared/field-types';
import { apiRequest, queryClient } from '@/lib/queryClient';
@@ -39,6 +39,28 @@ import { useColumnFilters } from '@/hooks/useColumnFilters';
import { ColumnFilter } from '@/components/ui/ColumnFilter';
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
import { UserRoleRenderer, UserRoleEditor } from '@/components/fields';
+import {
+ DndContext,
+ DragEndEvent,
+ DragOverEvent,
+ DragStartEvent,
+ DragOverlay,
+ PointerSensor,
+ KeyboardSensor,
+ useSensor,
+ useSensors,
+ defaultDropAnimationSideEffects,
+ DropAnimation,
+ pointerWithin,
+} from '@dnd-kit/core';
+import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
+import {
+ DirectoryRowTree,
+ parseRowSortableId,
+ rowSortableId,
+ rowDroppableId,
+ rowGapId,
+} from '@/components/directories/DirectoryRowTree';
interface TableColumn {
name: string;
@@ -73,6 +95,17 @@ export default function TableEditor() {
const [importLoading, setImportLoading] = useState(false);
const fileInputRef = useRef(null);
+ const [viewMode, setViewMode] = useState<'table' | 'tree'>('table');
+ const [expanded, setExpanded] = useState>(new Set());
+ const [selectedRowId, setSelectedRowId] = useState(null);
+ const [activeId, setActiveId] = useState(null);
+ const [overRowId, setOverRowId] = useState(null);
+ const [rowEditorOpen, setRowEditorOpen] = useState(false);
+ const [rowEditorInitialValues, setRowEditorInitialValues] = useState([]);
+ const [rowEditorParentId, setRowEditorParentId] = useState(null);
+ const [editorValues, setEditorValues] = useState([]);
+ const [treeSearchQuery, setTreeSearchQuery] = useState('');
+
const { filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters } = useColumnFilters();
const { data, isLoading } = useQuery<{ table: DataTableFull; role: string }>({
@@ -142,6 +175,12 @@ export default function TableEditor() {
}
}, [data]);
+ useEffect(() => {
+ if (rowEditorOpen) {
+ setEditorValues(rowEditorInitialValues);
+ }
+ }, [rowEditorOpen, rowEditorInitialValues]);
+
const updateTableMutation = useMutation({
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[] }) => {
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
@@ -157,13 +196,16 @@ export default function TableEditor() {
});
const createRowMutation = useMutation({
- mutationFn: async (values: string[]) => {
- return apiRequest('POST', `/api/directories/${tableId}/rows`, { values });
+ mutationFn: async ({ values, parentId }: { values: string[]; parentId?: number | null }) => {
+ return apiRequest('POST', `/api/directories/${tableId}/rows`, { values, parentId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
refetchRows();
setNewRowValues(new Array(tableColumns.length).fill(''));
+ setRowEditorOpen(false);
+ setRowEditorInitialValues([]);
+ setRowEditorParentId(null);
toast({ title: 'Строка добавлена' });
},
onError: () => {
@@ -179,6 +221,7 @@ export default function TableEditor() {
queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
refetchRows();
setEditingRowId(null);
+ setRowEditorOpen(false);
toast({ title: 'Строка обновлена' });
},
onError: () => {
@@ -186,6 +229,19 @@ export default function TableEditor() {
},
});
+ const moveRowMutation = useMutation({
+ mutationFn: async ({ rowId, parentId, position }: { rowId: number; parentId: number | null; position: number }) => {
+ return apiRequest('PATCH', `/api/directories/${tableId}/rows/${rowId}`, { parentId, position });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['/api/directories', tableId] });
+ refetchRows();
+ },
+ onError: (error: any) => {
+ toast({ title: 'Ошибка перемещения строки', description: error.message, variant: 'destructive' });
+ },
+ });
+
const deleteRowMutation = useMutation({
mutationFn: async (rowId: number) => {
return apiRequest('DELETE', `/api/directories/${tableId}/rows/${rowId}`);
@@ -284,7 +340,7 @@ export default function TableEditor() {
const handleAddRow = () => {
if (newRowValues.some(v => v.trim())) {
- createRowMutation.mutate(newRowValues);
+ createRowMutation.mutate({ values: newRowValues });
}
};
@@ -292,6 +348,116 @@ export default function TableEditor() {
setNewRowValues(new Array(tableColumns.length).fill(''));
};
+ const toggleExpand = (id: number) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number) => {
+ setRowEditorParentId(parentId);
+ setRowEditorInitialValues(initialValues ?? new Array(tableColumns.length).fill(''));
+ if (rowId) {
+ setEditingRowId(rowId);
+ } else {
+ setEditingRowId(null);
+ }
+ setRowEditorOpen(true);
+ };
+
+ const closeRowEditor = () => {
+ setRowEditorOpen(false);
+ setRowEditorInitialValues([]);
+ setRowEditorParentId(null);
+ setEditingRowId(null);
+ };
+
+ const handleSaveRowEditor = (values: string[]) => {
+ if (editingRowId !== null) {
+ updateRowMutation.mutate({ rowId: editingRowId, values });
+ } else {
+ createRowMutation.mutate({ values, parentId: rowEditorParentId });
+ }
+ };
+
+ const handleTreeSelect = (row: DataTableRow) => {
+ setSelectedRowId(row.id);
+ openRowEditor(row.parentId ?? null, Array.isArray(row.values) ? [...row.values] : [], row.id);
+ };
+
+ const handleTreeAddChild = (parentId: number | null) => {
+ setSelectedRowId(null);
+ openRowEditor(parentId, new Array(tableColumns.length).fill(''));
+ };
+
+ const handleTreeDelete = (row: DataTableRow) => {
+ if (window.confirm(`Удалить строку "${row.values[0] || row.id}"?`)) {
+ deleteRowMutation.mutate(row.id);
+ }
+ };
+
+ const treeSensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ );
+
+ const handleDragStart = (event: DragStartEvent) => {
+ setActiveId(event.active.id);
+ };
+
+ const handleDragOver = (event: DragOverEvent) => {
+ const { over } = event;
+ if (!over) {
+ setOverRowId(null);
+ return;
+ }
+ const overData = over.data.current as any;
+ if (overData?.type === 'row') {
+ setOverRowId(overData.rowId as number);
+ } else {
+ setOverRowId(null);
+ }
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ setActiveId(null);
+ setOverRowId(null);
+ if (!over) return;
+
+ const activeRowId = parseRowSortableId(active.id);
+ if (!activeRowId) return;
+
+ const overData = over.data.current as any;
+ if (!overData) return;
+
+ if (overData.type === 'row') {
+ const targetRowId = overData.rowId as number;
+ if (activeRowId === targetRowId) return;
+ moveRowMutation.mutate({ rowId: activeRowId, parentId: targetRowId, position: 0 });
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ next.add(targetRowId);
+ return next;
+ });
+ } else if (overData.type === 'row-gap') {
+ moveRowMutation.mutate({
+ rowId: activeRowId,
+ parentId: overData.parentId as number | null,
+ position: overData.position as number,
+ });
+ }
+ };
+
+ const dropAnimation: DropAnimation = {
+ sideEffects: defaultDropAnimationSideEffects({
+ styles: { active: { opacity: '0.5' } },
+ }),
+ };
+
const handleExport = async () => {
if (!data?.table) return;
const cols = data.table.columns as TableColumn[];
@@ -433,6 +599,10 @@ export default function TableEditor() {
u.id !== table.createdBy && !permissions.some(p => p.userId === u.id)
);
+ const activeRow = activeId
+ ? displayRows.find((r) => rowSortableId(r.id) === activeId || rowDroppableId(r.id) === activeId)
+ : undefined;
+
return (
@@ -449,6 +619,15 @@ export default function TableEditor() {