Древовидные справочники: parent_id, drag&drop, древовидный выбор в полях table

This commit is contained in:
2026-07-12 20:55:30 +03:00
parent 833fa97f56
commit ec08f3222d
10 changed files with 1284 additions and 68 deletions

View File

@@ -5,6 +5,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Table, X } from 'lucide-react';
import { DirectoryTreeMultiSelect } from '@/components/directories/DirectoryTreeMultiSelect';
import type { DataTableRow } from '@shared/schema';
interface TableFieldMultiSelectProps {
fieldId: number;
@@ -29,7 +31,7 @@ export default function TableFieldMultiSelect({
const { data: tableData, isLoading, error } = useQuery<{
table: {
rows: Array<{ id: number; values: string[] }>;
rows: DataTableRow[];
columns: Array<{ name: string }>;
};
}>({
@@ -86,6 +88,8 @@ export default function TableFieldMultiSelect({
.filter(Boolean);
}, [selectedValues, rows, displayColumnIndex]);
const hasHierarchy = rows.some((r) => r.parentId !== null && r.parentId !== undefined);
const toggle = (rowId: string) => {
const next = new Set(selectedSet);
if (next.has(rowId)) next.delete(rowId);
@@ -140,26 +144,38 @@ export default function TableFieldMultiSelect({
}
}}
>
<ul className="max-h-60 overflow-auto py-1">
{filtered.map((row) => {
const rowId = row.id.toString();
const label = row.values[displayColumnIndex!] || row.values[0] || rowId;
return (
<li
key={row.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent cursor-pointer text-sm"
onMouseDown={(e) => { e.preventDefault(); toggle(rowId); }}
>
<Checkbox checked={selectedSet.has(rowId)} />
<Table className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="flex-1">{label}</span>
</li>
);
})}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
<div className="max-h-60 overflow-auto">
{hasHierarchy ? (
<DirectoryTreeMultiSelect
rows={rows}
displayColumnIndex={displayColumnIndex!}
selected={selectedSet}
onToggle={toggle}
searchTerm={searchTerm}
/>
) : (
<ul className="py-1">
{filtered.map((row) => {
const rowId = row.id.toString();
const label = row.values[displayColumnIndex!] || row.values[0] || rowId;
return (
<li
key={row.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent cursor-pointer text-sm"
onMouseDown={(e) => { e.preventDefault(); toggle(rowId); }}
>
<Checkbox checked={selectedSet.has(rowId)} />
<Table className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="flex-1">{label}</span>
</li>
);
})}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
)}
</ul>
</div>
</PopoverContent>
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">

View File

@@ -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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
@@ -125,29 +129,41 @@ export default function TableFieldSelect({
}
}}
>
<ul className="max-h-60 overflow-auto py-1">
<li
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
>
—
</li>
{filtered.map((row) => (
<li
key={row.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(row.id.toString()); }}
>
<div className="flex items-center gap-2 text-sm">
<Table className="w-3 h-3 text-muted-foreground" />
{row.values[displayColumnIndex!] || row.values[0]}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
<div className="max-h-60 overflow-auto">
{hasHierarchy ? (
<DirectoryTreeSelect
rows={rows}
displayColumnIndex={displayColumnIndex!}
value={value}
onChange={handleSelect}
searchTerm={searchTerm}
/>
) : (
<ul className="py-1">
<li
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
>
—
</li>
{filtered.map((row) => (
<li
key={row.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(row.id.toString()); }}
>
<div className="flex items-center gap-2 text-sm">
<Table className="w-3 h-3 text-muted-foreground" />
{row.values[displayColumnIndex!] || row.values[0]}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
)}
</ul>
</div>
</PopoverContent>
</Popover>
);

View File

@@ -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<number>;
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<number, DirectoryRowTreeItem>();
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<number>): 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<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
const q = query.toLowerCase();
const expanded = new Set<number>();
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 (
<div
ref={setNodeRef}
className={cn('h-1.5 rounded transition-colors', isOver ? 'bg-primary/60' : 'bg-transparent')}
style={{ marginLeft: `${level * 16 + 8}px` }}
/>
);
}
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 (
<div
ref={(node) => {
setNodeRef(node);
setDropRef(node);
}}
style={style}
>
<div
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',
isOverRow && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: `${item.level * 16 + 8}px` }}
onClick={() => onSelect(item)}
onDoubleClick={() => hasChildren && onToggleExpand(item.id)}
>
<button
type="button"
className={cn('shrink-0 p-0.5 rounded hover:bg-muted-foreground/10', !hasChildren && 'invisible')}
onClick={(e) => {
e.stopPropagation();
onToggleExpand(item.id);
}}
>
{expanded.has(item.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</button>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 break-words whitespace-normal leading-tight">{getRowLabel(item, labelColumnIndex)}</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();
onAddChild(item.id);
}}
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Редактировать"
onClick={(e) => {
e.stopPropagation();
onEdit(item);
}}
>
<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(item);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<div
className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground/60 opacity-0 group-hover:opacity-100 transition-opacity"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="w-3.5 h-3.5" />
</div>
</div>
</div>
);
}
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 (
<SortableContext items={visibleItems.map((item) => rowSortableId(item.id))} strategy={verticalListSortingStrategy}>
<div className="space-y-0">
{rowsWithGaps.map((row, idx) =>
row.type === 'gap' ? (
<DropGap
key={`gap-${row.parentId ?? 'root'}-${row.position}-${idx}`}
parentId={row.parentId}
position={row.position}
level={row.level}
/>
) : (
<SortableRowNode
key={row.item.id}
item={row.item}
rows={rows}
labelColumnIndex={labelColumnIndex}
selectedRowId={selectedRowId}
expanded={expanded}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
overRowId={overRowId}
/>
)
)}
</div>
</SortableContext>
);
}

View File

@@ -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<string>;
onToggle: (rowId: string) => void;
searchTerm?: string;
}
function buildTree(rows: DataTableRow[]): TreeItem[] {
const map = new Map<number, TreeItem>();
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<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
const q = query.toLowerCase();
const expanded = new Set<number>();
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<string>;
onToggle: (rowId: string) => void;
expanded: Set<number>;
onToggleExpand: (id: number) => void;
}) {
const hasChildren = node.children.length > 0;
const rowId = node.id.toString();
return (
<div>
<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 + 8}px` }}
onMouseDown={(e) => { e.preventDefault(); onToggle(rowId); }}
>
{hasChildren ? (
<span
className="shrink-0 p-0.5 rounded hover:bg-muted-foreground/10"
onMouseDown={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
>
{expanded.has(node.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
) : (
<span className="w-5" />
)}
<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')}>{getRowLabel(node, displayColumnIndex)}</span>
</div>
{hasChildren && expanded.has(node.id) && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
displayColumnIndex={displayColumnIndex}
selected={selected}
onToggle={onToggle}
expanded={expanded}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)}
</div>
);
}
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<Set<number>>(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 (
<div className="py-1">
{displayTree.map((node) => (
<TreeNode
key={node.id}
node={node}
displayColumnIndex={displayColumnIndex}
selected={selected}
onToggle={onToggle}
expanded={effectiveExpanded}
onToggleExpand={toggleExpand}
/>
))}
{displayTree.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</div>
)}
</div>
);
}

View File

@@ -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<number, TreeItem>();
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<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
const q = query.toLowerCase();
const expanded = new Set<number>();
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<number>;
onToggle: (id: number) => void;
}) {
const hasChildren = node.children.length > 0;
const isSelected = value?.toString() === node.id.toString();
return (
<div>
<button
type="button"
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent text-left',
isSelected && 'bg-accent text-accent-foreground'
)}
style={{ paddingLeft: `${node.level * 16 + 8}px` }}
onClick={() => onChange(node.id.toString())}
>
{hasChildren ? (
<span
className="shrink-0 p-0.5 rounded hover:bg-muted-foreground/10"
onClick={(e) => {
e.stopPropagation();
onToggle(node.id);
}}
>
{expanded.has(node.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
) : (
<span className="w-5" />
)}
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{getRowLabel(node, displayColumnIndex)}</span>
</button>
{hasChildren && expanded.has(node.id) && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
displayColumnIndex={displayColumnIndex}
value={value}
onChange={onChange}
expanded={expanded}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
);
}
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<Set<number>>(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 (
<div className="py-1">
<button
type="button"
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-muted-foreground"
onClick={() => onChange('')}
>
<span className="w-5" />
<FileText className="w-4 h-4 shrink-0" />
<span>—</span>
</button>
{displayTree.map((node) => (
<TreeNode
key={node.id}
node={node}
displayColumnIndex={displayColumnIndex}
value={value}
onChange={onChange}
expanded={effectiveExpanded}
onToggle={toggle}
/>
))}
{displayTree.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</div>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement>(null);
const [viewMode, setViewMode] = useState<'table' | 'tree'>('table');
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const [activeId, setActiveId] = useState<string | number | null>(null);
const [overRowId, setOverRowId] = useState<number | null>(null);
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorInitialValues, setRowEditorInitialValues] = useState<string[]>([]);
const [rowEditorParentId, setRowEditorParentId] = useState<number | null>(null);
const [editorValues, setEditorValues] = useState<string[]>([]);
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 (
<Layout>
<div className="space-y-4">
@@ -449,6 +619,15 @@ export default function TableEditor() {
</div>
</div>
<div className="flex items-center gap-2">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'table' | 'tree')}>
<TabsList className="h-8">
<TabsTrigger value="table" className="text-xs px-2">Таблица</TabsTrigger>
<TabsTrigger value="tree" className="text-xs px-2">
<ListTree className="w-3.5 h-3.5 mr-1" />
Дерево
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" onClick={handleExport} data-testid="button-export">
<Download className="w-4 h-4 mr-2" />
Экспорт
@@ -502,7 +681,62 @@ export default function TableEditor() {
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
{viewMode === 'tree' ? (
<DndContext
sensors={treeSensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="p-2 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Поиск в дереве..."
className="pl-7 h-8 text-xs"
value={treeSearchQuery}
onChange={(e) => setTreeSearchQuery(e.target.value)}
/>
</div>
{canEdit && (
<Button onClick={() => handleTreeAddChild(null)} className="h-8 px-2.5 text-xs" data-testid="button-add-tree-row">
<Plus className="w-3.5 h-3.5 mr-1" />
Добавить
</Button>
)}
</div>
{displayRows.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
</div>
) : (
<DirectoryRowTree
rows={displayRows}
selectedRowId={selectedRowId}
expanded={expanded}
onSelect={handleTreeSelect}
onToggleExpand={toggleExpand}
onEdit={handleTreeSelect}
onDelete={handleTreeDelete}
onAddChild={handleTreeAddChild}
overRowId={overRowId}
searchQuery={treeSearchQuery}
/>
)}
</div>
<DragOverlay dropAnimation={dropAnimation}>
{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[0] || '—'}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
@@ -737,10 +971,94 @@ export default function TableEditor() {
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
<Dialog open={rowEditorOpen} onOpenChange={(open) => { if (!open) closeRowEditor(); setRowEditorOpen(open); }}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRowId !== null ? 'Редактировать строку' : 'Новая строка'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
{columns.map((col, idx) => (
<div key={idx}>
<Label className="text-xs">{col.name}</Label>
{col.type === 'user' ? (
<UserRoleEditor
field={col as any}
value={editorValues[idx]}
onChange={(v) => {
const updated = [...editorValues];
updated[idx] = v ?? '';
setEditorValues(updated);
}}
users={users}
roles={roles}
/>
) : col.type === 'select' && normalizeSelectOptions(col.options).length > 0 ? (
<Select
value={editorValues[idx] || ''}
onValueChange={(v) => {
const updated = [...editorValues];
updated[idx] = v;
setEditorValues(updated);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={col.name} />
</SelectTrigger>
<SelectContent>
{normalizeSelectOptions(col.options).map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : col.type === 'checkbox' ? (
<div className="flex items-center h-8">
<input
type="checkbox"
checked={editorValues[idx] === 'true' || editorValues[idx] === '1'}
onChange={(e) => {
const updated = [...editorValues];
updated[idx] = e.target.checked ? 'true' : 'false';
setEditorValues(updated);
}}
className="w-4 h-4"
/>
</div>
) : (
<Input
value={editorValues[idx] || ''}
onChange={(e) => {
const updated = [...editorValues];
updated[idx] = e.target.value;
setEditorValues(updated);
}}
placeholder={col.name}
className="h-8 text-xs"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={closeRowEditor}>
Отмена
</Button>
<Button
size="sm"
className="h-8 text-xs"
onClick={() => handleSaveRowEditor(editorValues)}
disabled={!editorValues.some((v) => v.trim()) || updateRowMutation.isPending || createRowMutation.isPending}
>
{updateRowMutation.isPending || createRowMutation.isPending ? 'Сохранение...' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>