diff --git a/client/src/components/TableFieldMultiSelect.tsx b/client/src/components/TableFieldMultiSelect.tsx index cc9a6b8..1c5578d 100644 --- a/client/src/components/TableFieldMultiSelect.tsx +++ b/client/src/components/TableFieldMultiSelect.tsx @@ -37,6 +37,8 @@ export default function TableFieldMultiSelect({ columns: Array<{ name: string }>; treeColumnIndex: number | null; treeDisplayColumnIndex: number | null; + treeColumns: number[] | null; + treeDisplayColumns: number[] | null; }; }>({ queryKey: ['/api/directories', tableId], @@ -50,6 +52,8 @@ export default function TableFieldMultiSelect({ const { data: treeData } = useQuery<{ columnIndex: number | null; + treeColumns: number[]; + treeDisplayColumns: number[]; nodes: DirectoryTreeNode[]; }>({ queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr], @@ -75,7 +79,7 @@ export default function TableFieldMultiSelect({ let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn); if (displayColumnIndex === undefined || displayColumnIndex < 0) { - displayColumnIndex = table?.treeDisplayColumnIndex ?? 0; + displayColumnIndex = table?.treeDisplayColumnIndex ?? table?.treeDisplayColumns?.[0] ?? 0; } const selectedValues = useMemo(() => { diff --git a/client/src/components/TableFieldSelect.tsx b/client/src/components/TableFieldSelect.tsx index f513291..07214f4 100644 --- a/client/src/components/TableFieldSelect.tsx +++ b/client/src/components/TableFieldSelect.tsx @@ -35,6 +35,8 @@ export default function TableFieldSelect({ columns: Array<{ name: string }>; treeColumnIndex: number | null; treeDisplayColumnIndex: number | null; + treeColumns: number[] | null; + treeDisplayColumns: number[] | null; }; }>({ queryKey: ['/api/directories', tableId], @@ -48,6 +50,8 @@ export default function TableFieldSelect({ const { data: treeData } = useQuery<{ columnIndex: number | null; + treeColumns: number[]; + treeDisplayColumns: number[]; nodes: DirectoryTreeNode[]; }>({ queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr], @@ -73,7 +77,7 @@ export default function TableFieldSelect({ let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn); if (displayColumnIndex === undefined || displayColumnIndex < 0) { - displayColumnIndex = table?.treeDisplayColumnIndex ?? 0; + displayColumnIndex = table?.treeDisplayColumnIndex ?? table?.treeDisplayColumns?.[0] ?? 0; } const sortedRows = useMemo(() => { diff --git a/client/src/components/directories/DirectoryColumnTree.tsx b/client/src/components/directories/DirectoryColumnTree.tsx index 8eaa8b6..7f88eaa 100644 --- a/client/src/components/directories/DirectoryColumnTree.tsx +++ b/client/src/components/directories/DirectoryColumnTree.tsx @@ -6,28 +6,50 @@ import type { DataTableRow } from '@shared/schema'; interface DirectoryColumnTreeProps { rows: DataTableRow[]; - treeColumnIndex: number; - displayColumnIndex?: number; + treeColumns: number[]; + treeDisplayColumns: number[]; selectedRowId?: number | null; onSelect: (row: DataTableRow) => void; onEdit: (row: DataTableRow) => void; onDelete: (row: DataTableRow) => void; - onAddChild: (parentValue: string | null) => void; + onAddChild: (values: Record) => void; searchQuery?: string; } -interface ColumnTreeGroup { +interface TreeGroup { + id: string; label: string; + level: number; + values: Record; rows: DataTableRow[]; + children: TreeGroup[]; } -function groupRows(rows: DataTableRow[], columnIndex: number): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[] } { +function getRowLabel(row: DataTableRow, displayColumns: number[]): string { + const values = Array.isArray(row.values) ? row.values : []; + const parts = displayColumns + .map((ci) => String(values[ci] ?? '').trim()) + .filter(Boolean); + return parts.length > 0 ? parts.join(' ') : '(без названия)'; +} + +function buildTree( + rows: DataTableRow[], + treeColumns: number[], + level = 0, + values: Record = {} +): TreeGroup[] { + if (level >= treeColumns.length) { + return []; + } + + const columnIndex = treeColumns[level]; const groups = new Map(); const rootRows: DataTableRow[] = []; for (const row of rows) { - const values = Array.isArray(row.values) ? row.values : []; - const key = String(values[columnIndex] ?? '').trim(); + const vals = Array.isArray(row.values) ? row.values : []; + const key = String(vals[columnIndex] ?? '').trim(); if (!key) { rootRows.push(row); continue; @@ -36,64 +58,195 @@ function groupRows(rows: DataTableRow[], columnIndex: number): { groups: ColumnT groups.get(key)!.push(row); } - const sortedGroups = Array.from(groups.entries()) - .map(([label, groupRows]) => ({ + const result: TreeGroup[] = []; + for (const [label, groupRows] of groups) { + const groupValues = { ...values, [columnIndex]: label }; + result.push({ + id: `${level}:${label}`, label, - rows: [...groupRows].sort((a, b) => a.position - b.position), - })) - .sort((a, b) => a.label.localeCompare(b.label, 'ru')); + level, + values: groupValues, + rows: groupRows, + children: buildTree(groupRows, treeColumns, level + 1, groupValues), + }); + } - rootRows.sort((a, b) => a.position - b.position); + // Строки без значения в этой колонке относятся к корню текущего уровня + if (rootRows.length > 0) { + const rootGroup = buildTree(rootRows, treeColumns, level + 1, values); + result.push(...rootGroup); + } - return { groups: sortedGroups, rootRows }; + return result.sort((a, b) => a.label.localeCompare(b.label, 'ru')); } -function getRowLabel(row: DataTableRow, displayColumnIndex = 0): string { - const values = Array.isArray(row.values) ? row.values : []; - return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)'; -} - -function filterGroups( - groups: ColumnTreeGroup[], - rootRows: DataTableRow[], +function filterTree( + groups: TreeGroup[], query: string, - displayColumnIndex: number -): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[]; expanded: Set } { - if (!query.trim()) return { groups, rootRows, expanded: new Set() }; + displayColumns: number[] +): { groups: TreeGroup[]; expanded: Set } { + if (!query.trim()) return { groups, expanded: new Set() }; const q = query.toLowerCase(); const expanded = new Set(); - const filteredGroups = groups - .map((group) => ({ - ...group, - rows: group.rows.filter((row) => { - const label = getRowLabel(row, displayColumnIndex).toLowerCase(); - return label.includes(q); - }), - })) - .filter((group) => { - if (group.rows.length > 0) { - expanded.add(group.label); - return true; + function walk(items: TreeGroup[]): TreeGroup[] { + const visible: TreeGroup[] = []; + for (const group of items) { + const filteredRows = group.rows.filter((row) => + getRowLabel(row, displayColumns).toLowerCase().includes(q) + ); + const children = walk(group.children); + if (filteredRows.length > 0 || children.length > 0) { + visible.push({ ...group, rows: filteredRows, children }); + expanded.add(group.id); + } else if (group.label.toLowerCase().includes(q)) { + visible.push({ ...group, rows: [], children: [] }); } - if (group.label.toLowerCase().includes(q)) { - return true; - } - return false; - }); + } + return visible; + } - const filteredRootRows = rootRows.filter((row) => { - const label = getRowLabel(row, displayColumnIndex).toLowerCase(); - return label.includes(q); - }); + return { groups: walk(groups), expanded }; +} - return { groups: filteredGroups, rootRows: filteredRootRows, expanded }; +function collectRows(groups: TreeGroup[]): DataTableRow[] { + const result: DataTableRow[] = []; + for (const group of groups) { + result.push(...group.rows); + result.push(...collectRows(group.children)); + } + return result; +} + +interface TreeNodeProps { + group: TreeGroup; + displayColumns: number[]; + selectedRowId?: number | null; + expanded: Set; + onToggle: (id: string) => void; + onSelect: (row: DataTableRow) => void; + onEdit: (row: DataTableRow) => void; + onDelete: (row: DataTableRow) => void; + onAddChild: (values: Record) => void; +} + +function TreeNode({ + group, + displayColumns, + selectedRowId, + expanded, + onToggle, + onSelect, + onEdit, + onDelete, + onAddChild, +}: TreeNodeProps) { + const isExpanded = expanded.has(group.id); + const isLeaf = group.children.length === 0; + + return ( +
+
onToggle(group.id)} + > + {isExpanded ? ( + + ) : ( + + )} + {group.label} + {isLeaf && ( + + )} +
+ + {isExpanded && ( +
+ {group.rows.map((row) => { + const isSelected = selectedRowId === row.id; + return ( +
onSelect(row)} + > + + + {getRowLabel(row, displayColumns)} + +
e.stopPropagation()} + > + + +
+
+ ); + })} + {group.children.map((child) => ( + + ))} +
+ )} +
+ ); } export function DirectoryColumnTree({ rows, - treeColumnIndex, - displayColumnIndex = 0, + treeColumns, + treeDisplayColumns, selectedRowId, onSelect, onEdit, @@ -101,14 +254,14 @@ export function DirectoryColumnTree({ onAddChild, searchQuery, }: DirectoryColumnTreeProps) { - const { groups, rootRows } = useMemo( - () => groupRows(rows, treeColumnIndex), - [rows, treeColumnIndex] + const groups = useMemo( + () => buildTree(rows, treeColumns), + [rows, treeColumns] ); const filtered = useMemo( - () => filterGroups(groups, rootRows, searchQuery || '', displayColumnIndex), - [groups, rootRows, searchQuery, displayColumnIndex] + () => filterTree(groups, searchQuery || '', treeDisplayColumns), + [groups, searchQuery, treeDisplayColumns] ); const [manualExpanded, setManualExpanded] = useState>(new Set()); @@ -118,105 +271,90 @@ export function DirectoryColumnTree({ [searchQuery, filtered.expanded, manualExpanded] ); - const toggle = (label: string) => { + const toggle = (id: string) => { setManualExpanded((prev) => { const next = new Set(prev); - if (next.has(label)) next.delete(label); - else next.add(label); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; - const renderRow = (row: DataTableRow, level: number) => { - const isSelected = selectedRowId === row.id; - return ( -
onSelect(row)} - > - - {getRowLabel(row, displayColumnIndex)} -
e.stopPropagation()} - > - - -
-
- ); - }; + const allRows = useMemo(() => collectRows(groups), [groups]); + const displayRows = useMemo(() => { + if (!searchQuery?.trim()) return allRows; + const q = searchQuery.toLowerCase(); + return allRows.filter((row) => getRowLabel(row, treeDisplayColumns).toLowerCase().includes(q)); + }, [allRows, searchQuery, treeDisplayColumns]); - return ( -
- {filtered.groups.map((group) => { - const isExpanded = effectiveExpanded.has(group.label); - return ( -
+ if (treeColumns.length === 0) { + return ( +
+ {displayRows.map((row) => ( +
onSelect(row)} + > + + + {getRowLabel(row, treeDisplayColumns)} +
toggle(group.label)} + className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center" + onClick={(e) => e.stopPropagation()} > - {isExpanded ? ( - - ) : ( - - )} - {group.label} +
- {isExpanded && ( -
- {group.rows.map((row) => renderRow(row, 1))} -
- )}
- ); - })} - {filtered.rootRows.length > 0 && ( -
- {filtered.rootRows.map((row) => renderRow(row, 0))} -
- )} + ))} +
+ ); + } + + return ( +
+ {filtered.groups.map((group) => ( + + ))}
); } diff --git a/client/src/pages/TableEditor.tsx b/client/src/pages/TableEditor.tsx index 130393e..a1fdff1 100644 --- a/client/src/pages/TableEditor.tsx +++ b/client/src/pages/TableEditor.tsx @@ -89,6 +89,8 @@ export default function TableEditor() { const [tableColumns, setTableColumns] = useState([]); const [treeColumnIndex, setTreeColumnIndex] = useState(null); const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState(null); + const [treeColumns, setTreeColumns] = useState([]); + const [treeDisplayColumns, setTreeDisplayColumns] = useState([]); const [importOpen, setImportOpen] = useState(false); const [importPreview, setImportPreview] = useState<{ added: { values: string[]; parentId?: number | null }[]; @@ -108,7 +110,7 @@ export default function TableEditor() { const [rowEditorOpen, setRowEditorOpen] = useState(false); const [rowEditorInitialValues, setRowEditorInitialValues] = useState([]); const [rowEditorParentId, setRowEditorParentId] = useState(null); - const [rowEditorGroupValue, setRowEditorGroupValue] = useState(null); + const [rowEditorPresetValues, setRowEditorPresetValues] = useState | null>(null); const [editorValues, setEditorValues] = useState([]); const [treeSearchQuery, setTreeSearchQuery] = useState(''); const [subdirectoriesOpen, setSubdirectoriesOpen] = useState(false); @@ -198,6 +200,8 @@ export default function TableEditor() { setTableColumns(data.table.columns as TableColumn[]); setTreeColumnIndex(data.table.treeColumnIndex ?? null); setTreeDisplayColumnIndex(data.table.treeDisplayColumnIndex ?? null); + setTreeColumns(data.table.treeColumns ?? []); + setTreeDisplayColumns(data.table.treeDisplayColumns ?? []); setNewRowValues(new Array(data.table.columns.length).fill('')); } }, [data]); @@ -307,7 +311,7 @@ export default function TableEditor() { }; const updateTableMutation = useMutation({ - mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null }) => { + mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null; treeColumns?: number[]; treeDisplayColumns?: number[] }) => { return apiRequest('PATCH', `/api/directories/${tableId}`, updates); }, onSuccess: () => { @@ -482,10 +486,19 @@ export default function TableEditor() { }); }; - const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number, groupValue: string | null = null) => { + const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number, presetValues: Record | null = null) => { setRowEditorParentId(parentId); - setRowEditorGroupValue(groupValue); - setRowEditorInitialValues(initialValues ?? new Array(tableColumns.length).fill('')); + setRowEditorPresetValues(presetValues); + const baseValues = initialValues ?? new Array(tableColumns.length).fill(''); + if (presetValues) { + for (const [col, val] of Object.entries(presetValues)) { + const idx = Number(col); + if (!isNaN(idx) && idx >= 0 && idx < baseValues.length) { + baseValues[idx] = val; + } + } + } + setRowEditorInitialValues(baseValues); if (rowId) { setEditingRowId(rowId); } else { @@ -498,19 +511,15 @@ export default function TableEditor() { setRowEditorOpen(false); setRowEditorInitialValues([]); setRowEditorParentId(null); - setRowEditorGroupValue(null); + setRowEditorPresetValues(null); setEditingRowId(null); }; const handleSaveRowEditor = (values: string[]) => { - const finalValues = [...values]; - if (treeColumnIndex !== null && rowEditorGroupValue !== null && editingRowId === null) { - finalValues[treeColumnIndex] = rowEditorGroupValue; - } if (editingRowId !== null) { - updateRowMutation.mutate({ rowId: editingRowId, values: finalValues }); + updateRowMutation.mutate({ rowId: editingRowId, values }); } else { - createRowMutation.mutate({ values: finalValues, parentId: rowEditorParentId }); + createRowMutation.mutate({ values, parentId: rowEditorParentId }); } }; @@ -524,9 +533,9 @@ export default function TableEditor() { openRowEditor(parentId, new Array(tableColumns.length).fill('')); }; - const handleColumnAddChild = (groupValue: string | null) => { + const handleColumnAddChild = (values: Record) => { setSelectedRowId(null); - openRowEditor(null, new Array(tableColumns.length).fill(''), undefined, groupValue); + openRowEditor(null, new Array(tableColumns.length).fill(''), undefined, values); }; const handleTreeDelete = (row: DataTableRow) => { @@ -895,11 +904,11 @@ export default function TableEditor() {
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
- ) : treeColumnIndex != null ? ( + ) : treeColumns.length > 0 ? ( - {activeRow.values[treeDisplayColumnIndex ?? 0] || activeRow.values[0] || '—'} + {activeRow.values[treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0] || activeRow.values[0] || '—'}
) : null} @@ -1351,46 +1360,51 @@ export default function TableEditor() {
- - -

- Строки будут сгруппированы по уникальным значениям выбранной колонки. + +

+ Выберите колонки для группировки (уровни дерева) и для отображения в названии. + Порядок группировки соответствует порядку колонок.

-
-
- - +
+ + + + + + + + + + {tableColumns.map((col, idx) => ( + + + + + + ))} + +
КолонкаГруппироватьПоказывать
{col.name || `Колонка ${idx + 1}`} + { + setTreeColumns((prev) => + checked ? [...prev, idx] : prev.filter((i) => i !== idx) + ); + }} + /> + + { + setTreeDisplayColumns((prev) => + checked ? [...prev, idx] : prev.filter((i) => i !== idx) + ); + }} + /> +
+

- Значение этой колонки будет показано как название узла в дереве. + Если «Группировать» не выбрано ни одной колонки, дерево строится по иерархии строк.

@@ -1399,7 +1413,15 @@ export default function TableEditor() { Отмена