Древовидные справочники: parent_id, drag&drop, древовидный выбор в полях table
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
371
client/src/components/directories/DirectoryRowTree.tsx
Normal file
371
client/src/components/directories/DirectoryRowTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
client/src/components/directories/DirectoryTreeMultiSelect.tsx
Normal file
182
client/src/components/directories/DirectoryTreeMultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
193
client/src/components/directories/DirectoryTreeSelect.tsx
Normal file
193
client/src/components/directories/DirectoryTreeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user