feat: многоуровневое дерево справочника и настраиваемое отображение колонок

This commit is contained in:
2026-07-20 21:59:36 +03:00
parent 9fff937209
commit 7cf5ffff9a
7 changed files with 424 additions and 212 deletions

View File

@@ -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(() => {

View File

@@ -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(() => {

View File

@@ -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<number, string>) => void;
searchQuery?: string;
}
interface ColumnTreeGroup {
interface TreeGroup {
id: string;
label: string;
level: number;
values: Record<number, string>;
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<number, string> = {}
): TreeGroup[] {
if (level >= treeColumns.length) {
return [];
}
const columnIndex = treeColumns[level];
const groups = new Map<string, DataTableRow[]>();
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<string> } {
if (!query.trim()) return { groups, rootRows, expanded: new Set<string>() };
displayColumns: number[]
): { groups: TreeGroup[]; expanded: Set<string> } {
if (!query.trim()) return { groups, expanded: new Set<string>() };
const q = query.toLowerCase();
const expanded = new Set<string>();
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<string>;
onToggle: (id: string) => void;
onSelect: (row: DataTableRow) => void;
onEdit: (row: DataTableRow) => void;
onDelete: (row: DataTableRow) => void;
onAddChild: (values: Record<number, string>) => 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 (
<div>
<div
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none hover:bg-muted font-medium"
style={{ paddingLeft: `${group.level * 16 + 8}px` }}
onClick={() => onToggle(group.id)}
>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<span className="flex-1 truncate">{group.label}</span>
{isLeaf && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
title="Добавить строку"
onClick={(e) => {
e.stopPropagation();
onAddChild(group.values);
}}
>
<Plus className="w-3.5 h-3.5" />
</Button>
)}
</div>
{isExpanded && (
<div>
{group.rows.map((row) => {
const isSelected = selectedRowId === row.id;
return (
<div
key={row.id}
className={cn(
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
)}
style={{ paddingLeft: `${(group.level + 1) * 16 + 8}px` }}
onClick={() => onSelect(row)}
>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 break-words whitespace-normal leading-tight">
{getRowLabel(row, displayColumns)}
</span>
<div
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Редактировать"
onClick={(e) => {
e.stopPropagation();
onEdit(row);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
title="Удалить"
onClick={(e) => {
e.stopPropagation();
onDelete(row);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
})}
{group.children.map((child) => (
<TreeNode
key={child.id}
group={child}
displayColumns={displayColumns}
selectedRowId={selectedRowId}
expanded={expanded}
onToggle={onToggle}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
)}
</div>
);
}
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<Set<string>>(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 (
<div
key={row.id}
className={cn(
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
)}
style={{ paddingLeft: `${level * 16 + 28}px` }}
onClick={() => onSelect(row)}
>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 break-words whitespace-normal leading-tight">{getRowLabel(row, displayColumnIndex)}</span>
<div
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Редактировать"
onClick={(e) => {
e.stopPropagation();
onEdit(row);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
title="Удалить"
onClick={(e) => {
e.stopPropagation();
onDelete(row);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
};
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 (
<div className="space-y-0">
{filtered.groups.map((group) => {
const isExpanded = effectiveExpanded.has(group.label);
return (
<div key={group.label}>
if (treeColumns.length === 0) {
return (
<div className="space-y-0">
{displayRows.map((row) => (
<div
key={row.id}
className={cn(
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
selectedRowId === row.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
)}
style={{ paddingLeft: '8px' }}
onClick={() => onSelect(row)}
>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 break-words whitespace-normal leading-tight">
{getRowLabel(row, treeDisplayColumns)}
</span>
<div
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none hover:bg-muted font-medium"
style={{ paddingLeft: '8px' }}
onClick={() => toggle(group.label)}
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
onClick={(e) => e.stopPropagation()}
>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<span className="flex-1 truncate">{group.label}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
title="Добавить в группу"
className="h-6 w-6"
title="Редактировать"
onClick={(e) => {
e.stopPropagation();
onAddChild(group.label);
onEdit(row);
}}
>
<Plus className="w-3.5 h-3.5" />
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
title="Удалить"
onClick={(e) => {
e.stopPropagation();
onDelete(row);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
{isExpanded && (
<div>
{group.rows.map((row) => renderRow(row, 1))}
</div>
)}
</div>
);
})}
{filtered.rootRows.length > 0 && (
<div className="pt-1">
{filtered.rootRows.map((row) => renderRow(row, 0))}
</div>
)}
))}
</div>
);
}
return (
<div className="space-y-0">
{filtered.groups.map((group) => (
<TreeNode
key={group.id}
group={group}
displayColumns={treeDisplayColumns}
selectedRowId={selectedRowId}
expanded={effectiveExpanded}
onToggle={toggle}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
);
}

View File

@@ -89,6 +89,8 @@ export default function TableEditor() {
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
const [treeColumnIndex, setTreeColumnIndex] = useState<number | null>(null);
const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState<number | null>(null);
const [treeColumns, setTreeColumns] = useState<number[]>([]);
const [treeDisplayColumns, setTreeDisplayColumns] = useState<number[]>([]);
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<string[]>([]);
const [rowEditorParentId, setRowEditorParentId] = useState<number | null>(null);
const [rowEditorGroupValue, setRowEditorGroupValue] = useState<string | null>(null);
const [rowEditorPresetValues, setRowEditorPresetValues] = useState<Record<number, string> | null>(null);
const [editorValues, setEditorValues] = useState<string[]>([]);
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<number, string> | 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<number, string>) => {
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() {
<div className="text-center py-8 text-muted-foreground text-sm">
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
</div>
) : treeColumnIndex != null ? (
) : treeColumns.length > 0 ? (
<DirectoryColumnTree
rows={displayRows}
treeColumnIndex={treeColumnIndex}
displayColumnIndex={treeDisplayColumnIndex ?? 0}
treeColumns={treeColumns}
treeDisplayColumns={treeDisplayColumns}
selectedRowId={selectedRowId}
onSelect={handleTreeSelect}
onEdit={handleTreeSelect}
@@ -917,7 +926,7 @@ export default function TableEditor() {
>
<DirectoryRowTree
rows={displayRows}
labelColumnIndex={treeDisplayColumnIndex ?? 0}
labelColumnIndex={treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0}
selectedRowId={selectedRowId}
expanded={expanded}
onSelect={handleTreeSelect}
@@ -932,7 +941,7 @@ export default function TableEditor() {
{activeRow ? (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm bg-accent text-accent-foreground shadow-sm border">
<FileText className="w-4 h-4" />
<span>{activeRow.values[treeDisplayColumnIndex ?? 0] || activeRow.values[0] || '—'}</span>
<span>{activeRow.values[treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0] || activeRow.values[0] || '—'}</span>
</div>
) : null}
</DragOverlay>
@@ -1351,46 +1360,51 @@ export default function TableEditor() {
</div>
</div>
<div>
<Label htmlFor="edit-table-tree-column">Колонка для дерева</Label>
<Select
value={treeColumnIndex === null ? 'none' : String(treeColumnIndex)}
onValueChange={(val) => setTreeColumnIndex(val === 'none' ? null : Number(val))}
>
<SelectTrigger id="edit-table-tree-column" className="mt-2">
<SelectValue placeholder="Не используется" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не используется</SelectItem>
{tableColumns.map((col, idx) => (
<SelectItem key={idx} value={String(idx)}>
{col.name || `Колонка ${idx + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Строки будут сгруппированы по уникальным значениям выбранной колонки.
<Label>Настройка дерева</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
Выберите колонки для группировки (уровни дерева) и для отображения в названии.
Порядок группировки соответствует порядку колонок.
</p>
</div>
<div>
<Label htmlFor="edit-table-tree-display-column">Колонка для отображения в дереве</Label>
<Select
value={treeDisplayColumnIndex === null ? '0' : String(treeDisplayColumnIndex)}
onValueChange={(val) => setTreeDisplayColumnIndex(Number(val))}
>
<SelectTrigger id="edit-table-tree-display-column" className="mt-2">
<SelectValue placeholder="Первая колонка" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col, idx) => (
<SelectItem key={idx} value={String(idx)}>
{col.name || `Колонка ${idx + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-2 font-medium">Колонка</th>
<th className="text-center p-2 font-medium w-28">Группировать</th>
<th className="text-center p-2 font-medium w-28">Показывать</th>
</tr>
</thead>
<tbody>
{tableColumns.map((col, idx) => (
<tr key={idx} className="border-t">
<td className="p-2">{col.name || `Колонка ${idx + 1}`}</td>
<td className="p-2 text-center">
<Checkbox
checked={treeColumns.includes(idx)}
onCheckedChange={(checked) => {
setTreeColumns((prev) =>
checked ? [...prev, idx] : prev.filter((i) => i !== idx)
);
}}
/>
</td>
<td className="p-2 text-center">
<Checkbox
checked={treeDisplayColumns.includes(idx)}
onCheckedChange={(checked) => {
setTreeDisplayColumns((prev) =>
checked ? [...prev, idx] : prev.filter((i) => i !== idx)
);
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground mt-1">
Значение этой колонки будет показано как название узла в дереве.
Если «Группировать» не выбрано ни одной колонки, дерево строится по иерархии строк.
</p>
</div>
</div>
@@ -1399,7 +1413,15 @@ export default function TableEditor() {
Отмена
</Button>
<Button
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex, treeDisplayColumnIndex })}
onClick={() => updateTableMutation.mutate({
name: tableName,
description: tableDescription,
columns: tableColumns,
treeColumnIndex: treeColumns.length > 0 ? treeColumns[0] : null,
treeDisplayColumnIndex: treeDisplayColumns.length > 0 ? treeDisplayColumns[0] : null,
treeColumns,
treeDisplayColumns,
})}
disabled={updateTableMutation.isPending}
data-testid="button-save-settings"
>

View File

@@ -0,0 +1,15 @@
-- data_tables: многоуровневое дерево и настраиваемое отображение колонок
ALTER TABLE data_tables ADD COLUMN IF NOT EXISTS tree_columns JSONB DEFAULT NULL;
ALTER TABLE data_tables ADD COLUMN IF NOT EXISTS tree_display_columns JSONB DEFAULT NULL;
-- Заполняем новые поля из старых, если они ещё пустые
UPDATE data_tables
SET tree_columns = CASE
WHEN tree_column_index IS NOT NULL THEN jsonb_build_array(tree_column_index)
ELSE NULL
END,
tree_display_columns = CASE
WHEN tree_display_column_index IS NOT NULL THEN jsonb_build_array(tree_display_column_index)
ELSE NULL
END
WHERE tree_columns IS NULL;

View File

@@ -323,13 +323,15 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
}
const { name, description, columns, treeColumnIndex, treeDisplayColumnIndex } = req.body;
const { name, description, columns, treeColumnIndex, treeDisplayColumnIndex, treeColumns, treeDisplayColumns } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = name;
if (description !== undefined) updates.description = description;
if (columns !== undefined) updates.columns = columns;
if (treeColumnIndex !== undefined) updates.treeColumnIndex = treeColumnIndex === null ? null : Number(treeColumnIndex);
if (treeDisplayColumnIndex !== undefined) updates.treeDisplayColumnIndex = treeDisplayColumnIndex === null ? null : Number(treeDisplayColumnIndex);
if (treeColumns !== undefined) updates.treeColumns = Array.isArray(treeColumns) ? treeColumns : null;
if (treeDisplayColumns !== undefined) updates.treeDisplayColumns = Array.isArray(treeDisplayColumns) ? treeDisplayColumns : null;
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
res.json({ table: updatedTable });
@@ -503,6 +505,7 @@ export function registerDataTableRoutes(app: import("express").Express): void {
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
const treeColumnIndex = table.treeColumnIndex;
const treeDisplayColumnIndex = table.treeDisplayColumnIndex;
const search = String(req.query.search || '').toLowerCase();
const filtersStr = req.query.filters as string;
@@ -521,34 +524,47 @@ export function registerDataTableRoutes(app: import("express").Express): void {
} catch {}
}
const displayColumnIndex = table.treeDisplayColumnIndex ?? 0;
const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []);
const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]);
const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0;
const getRowLabel = (row: typeof rows[number]): string => {
const values = Array.isArray(row.values) ? row.values : [];
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
const parts = treeDisplayColumns
.map((ci) => String(values[ci] ?? '').trim())
.filter(Boolean);
return parts.length > 0 ? parts.join(' ') : '(без названия)';
};
type TreeNode =
| { type: 'group'; id: string; label: string; children: TreeNode[] }
| { type: 'row'; id: number; label: string; row: typeof rows[number] };
| { type: 'group'; id: string; label: string; level: number; children: TreeNode[] }
| { type: 'row'; id: number; label: string; level: number; row: typeof rows[number] };
const buildParentTree = (parentRows: typeof rows, parentId: number | null): TreeNode[] => {
const buildParentTree = (parentRows: typeof rows, parentId: number | null, level = 0): TreeNode[] => {
const items = parentRows
.filter((r) => (r.parentId ?? null) === parentId)
.sort((a, b) => a.position - b.position);
return items.map((row) => {
const children = buildParentTree(parentRows, row.id);
const children = buildParentTree(parentRows, row.id, level + 1);
const label = getRowLabel(row);
if (children.length > 0) {
return { type: 'group' as const, id: `row-group:${row.id}`, label, children: [...children, { type: 'row' as const, id: row.id, label, row }] };
return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] };
}
return { type: 'row' as const, id: row.id, label, row };
return { type: 'row' as const, id: row.id, label, level, row };
});
};
const buildColumnTree = (allRows: typeof rows, columnIndex: number): TreeNode[] => {
const buildMultiColumnTree = (allRows: typeof rows, columns: number[], level = 0): TreeNode[] => {
if (level >= columns.length) {
return allRows
.sort((a, b) => a.position - b.position)
.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row }));
}
const columnIndex = columns[level];
const groups = new Map<string, typeof rows[number][]>();
const rootRows: typeof rows[number][] = [];
for (const row of allRows) {
const vals = Array.isArray(row.values) ? row.values : [];
const key = String(vals[columnIndex] ?? '').trim();
@@ -559,20 +575,22 @@ export function registerDataTableRoutes(app: import("express").Express): void {
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const nodes: TreeNode[] = [];
for (const [label, groupRows] of groups) {
const sorted = [...groupRows].sort((a, b) => a.position - b.position);
nodes.push({
type: 'group',
id: `group:${label}`,
id: `group:${level}:${label}`,
label,
children: sorted.map((row) => ({ type: 'row', id: row.id, label: getRowLabel(row), row })),
level,
children: buildMultiColumnTree(groupRows, columns, level + 1),
});
}
if (rootRows.length > 0) {
rootRows.sort((a, b) => a.position - b.position);
nodes.push(...rootRows.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), row })));
nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1));
}
return nodes.sort((a, b) => {
const al = a.type === 'group' ? a.label : getRowLabel(a.row);
const bl = b.type === 'group' ? b.label : getRowLabel(b.row);
@@ -599,12 +617,19 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return result;
};
const rawNodes = treeColumnIndex != null
? buildColumnTree(rows, treeColumnIndex)
const rawNodes = treeColumns.length > 0
? buildMultiColumnTree(rows, treeColumns)
: buildParentTree(rows, null);
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
res.json({ columnIndex: treeColumnIndex, displayColumnIndex, nodes });
res.json({
columnIndex: treeColumnIndex,
treeColumnIndex,
treeColumns,
treeDisplayColumns,
displayColumnIndex,
nodes,
});
} catch (error) {
console.error('Get table rows tree error:', error);
res.status(500).json({ error: 'Ошибка получения дерева строк' });

View File

@@ -1845,6 +1845,8 @@ export const dataTables = pgTable("data_tables", {
folderId: integer("folder_id"),
treeColumnIndex: integer("tree_column_index"),
treeDisplayColumnIndex: integer("tree_display_column_index"),
treeColumns: jsonb("tree_columns").$type<number[] | null>().default(null),
treeDisplayColumns: jsonb("tree_display_columns").$type<number[] | null>().default(null),
}, (table) => ({
folderIdx: index("data_tables_folder_idx").on(table.folderId),
}));
@@ -2002,6 +2004,8 @@ export const insertDataTableSchema = createInsertSchema(dataTables).omit({
createdAt: true,
updatedAt: true,
isDeleted: true,
treeColumns: true,
treeDisplayColumns: true,
}).extend({
columns: z.array(z.object({
name: z.string(),