Files
iistwin/client/src/components/directories/DirectoryTreeMultiSelect.tsx
Ильяс Султанов 09861fc966 feat: настраиваемая колонка отображения в дереве справочника
- Добавлено поле 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.
2026-07-12 23:07:34 +03:00

172 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}