- Добавлено поле data_tables.tree_display_column_index. - Backend /api/directories/:id/rows/tree возвращает displayColumnIndex и формирует label для row-узлов на основе выбранной колонки. - В редакторе справочника добавлен селектор «Колонка для отображения в дереве». - DirectoryRowTree, DirectoryColumnTree, DirectoryTreeSelect, DirectoryTreeMultiSelect используют настроенную колонку. - TableFieldSelect / TableFieldMultiSelect используют treeDisplayColumnIndex справочника как fallback, если в поле формы не задан displayColumn. - Миграция 0057 + запись в _journal.json.
172 lines
5.0 KiB
TypeScript
172 lines
5.0 KiB
TypeScript
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';
|
||
|
||
export type DirectoryTreeNode =
|
||
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
|
||
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
|
||
|
||
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
|
||
return nodes.map((node) => {
|
||
if (node.type === 'row') {
|
||
return { ...node, level };
|
||
}
|
||
return { ...node, level, children: assignLevels(node.children, level + 1) };
|
||
});
|
||
}
|
||
|
||
interface DirectoryTreeMultiSelectProps {
|
||
nodes: DirectoryTreeNode[];
|
||
selected: Set<string>;
|
||
onToggle: (rowId: string) => void;
|
||
searchTerm?: string;
|
||
}
|
||
|
||
function filterNodes(
|
||
nodes: DirectoryTreeNode[],
|
||
query: string
|
||
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
|
||
if (!query.trim()) return { nodes, expanded: new Set<string>() };
|
||
const q = query.toLowerCase();
|
||
const expanded = new Set<string>();
|
||
|
||
function walk(items: DirectoryTreeNode[]): DirectoryTreeNode[] {
|
||
const visible: DirectoryTreeNode[] = [];
|
||
for (const node of items) {
|
||
if (node.type === 'row') {
|
||
const label = node.label.toLowerCase();
|
||
if (label.includes(q)) visible.push(node);
|
||
} else {
|
||
const children = walk(node.children);
|
||
if (children.length > 0) {
|
||
visible.push({ ...node, children });
|
||
expanded.add(node.id);
|
||
} else if (node.label.toLowerCase().includes(q)) {
|
||
visible.push({ ...node, children: [] });
|
||
}
|
||
}
|
||
}
|
||
return visible;
|
||
}
|
||
|
||
return { nodes: walk(nodes), expanded };
|
||
}
|
||
|
||
function TreeNode({
|
||
node,
|
||
selected,
|
||
onToggle,
|
||
expanded,
|
||
onToggleExpand,
|
||
}: {
|
||
node: DirectoryTreeNode;
|
||
selected: Set<string>;
|
||
onToggle: (rowId: string) => void;
|
||
expanded: Set<string>;
|
||
onToggleExpand: (id: string) => void;
|
||
}) {
|
||
if (node.type === 'row') {
|
||
const rowId = node.id.toString();
|
||
return (
|
||
<div
|
||
className="flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer"
|
||
style={{ paddingLeft: `${node.level * 16 + 28}px` }}
|
||
onMouseDown={(e) => { e.preventDefault(); onToggle(rowId); }}
|
||
>
|
||
<Checkbox checked={selected.has(rowId)} />
|
||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||
<span className={cn('flex-1 truncate', selected.has(rowId) && 'font-medium')}>
|
||
{node.label}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isExpanded = expanded.has(node.id);
|
||
return (
|
||
<div>
|
||
<div
|
||
className="flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer font-medium"
|
||
style={{ paddingLeft: `${node.level * 16 + 8}px` }}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
onToggleExpand(node.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">{node.label}</span>
|
||
</div>
|
||
{isExpanded && (
|
||
<div>
|
||
{node.children.map((child) => (
|
||
<TreeNode
|
||
key={child.type === 'group' ? child.id : child.id}
|
||
node={child}
|
||
selected={selected}
|
||
onToggle={onToggle}
|
||
expanded={expanded}
|
||
onToggleExpand={onToggleExpand}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function DirectoryTreeMultiSelect({
|
||
nodes,
|
||
selected,
|
||
onToggle,
|
||
searchTerm = '',
|
||
}: DirectoryTreeMultiSelectProps) {
|
||
const nodesWithLevels = useMemo(() => assignLevels(nodes), [nodes]);
|
||
const filtered = useMemo(
|
||
() => filterNodes(nodesWithLevels, searchTerm),
|
||
[nodesWithLevels, searchTerm]
|
||
);
|
||
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
|
||
|
||
const effectiveExpanded = useMemo(
|
||
() => (searchTerm.trim() ? filtered.expanded : manualExpanded),
|
||
[searchTerm, filtered.expanded, manualExpanded]
|
||
);
|
||
const displayTree = useMemo(
|
||
() => (searchTerm.trim() ? filtered.nodes : nodes),
|
||
[searchTerm, filtered.nodes, nodes]
|
||
);
|
||
|
||
const toggleExpand = (id: string) => {
|
||
setManualExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="py-1">
|
||
{displayTree.map((node) => (
|
||
<TreeNode
|
||
key={node.type === 'group' ? node.id : node.id}
|
||
node={node}
|
||
selected={selected}
|
||
onToggle={onToggle}
|
||
expanded={effectiveExpanded}
|
||
onToggleExpand={toggleExpand}
|
||
/>
|
||
))}
|
||
{displayTree.length === 0 && (
|
||
<div className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|