feat: многоуровневое дерево справочника и настраиваемое отображение колонок
This commit is contained in:
@@ -37,6 +37,8 @@ export default function TableFieldMultiSelect({
|
||||
columns: Array<{ name: string }>;
|
||||
treeColumnIndex: number | null;
|
||||
treeDisplayColumnIndex: number | null;
|
||||
treeColumns: number[] | null;
|
||||
treeDisplayColumns: number[] | null;
|
||||
};
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId],
|
||||
@@ -50,6 +52,8 @@ export default function TableFieldMultiSelect({
|
||||
|
||||
const { data: treeData } = useQuery<{
|
||||
columnIndex: number | null;
|
||||
treeColumns: number[];
|
||||
treeDisplayColumns: number[];
|
||||
nodes: DirectoryTreeNode[];
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr],
|
||||
@@ -75,7 +79,7 @@ export default function TableFieldMultiSelect({
|
||||
|
||||
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
|
||||
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? table?.treeDisplayColumns?.[0] ?? 0;
|
||||
}
|
||||
|
||||
const selectedValues = useMemo(() => {
|
||||
|
||||
@@ -35,6 +35,8 @@ export default function TableFieldSelect({
|
||||
columns: Array<{ name: string }>;
|
||||
treeColumnIndex: number | null;
|
||||
treeDisplayColumnIndex: number | null;
|
||||
treeColumns: number[] | null;
|
||||
treeDisplayColumns: number[] | null;
|
||||
};
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId],
|
||||
@@ -48,6 +50,8 @@ export default function TableFieldSelect({
|
||||
|
||||
const { data: treeData } = useQuery<{
|
||||
columnIndex: number | null;
|
||||
treeColumns: number[];
|
||||
treeDisplayColumns: number[];
|
||||
nodes: DirectoryTreeNode[];
|
||||
}>({
|
||||
queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr],
|
||||
@@ -73,7 +77,7 @@ export default function TableFieldSelect({
|
||||
|
||||
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
|
||||
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
|
||||
displayColumnIndex = table?.treeDisplayColumnIndex ?? table?.treeDisplayColumns?.[0] ?? 0;
|
||||
}
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
|
||||
@@ -6,28 +6,50 @@ import type { DataTableRow } from '@shared/schema';
|
||||
|
||||
interface DirectoryColumnTreeProps {
|
||||
rows: DataTableRow[];
|
||||
treeColumnIndex: number;
|
||||
displayColumnIndex?: number;
|
||||
treeColumns: number[];
|
||||
treeDisplayColumns: number[];
|
||||
selectedRowId?: number | null;
|
||||
onSelect: (row: DataTableRow) => void;
|
||||
onEdit: (row: DataTableRow) => void;
|
||||
onDelete: (row: DataTableRow) => void;
|
||||
onAddChild: (parentValue: string | null) => void;
|
||||
onAddChild: (values: Record<number, string>) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
interface ColumnTreeGroup {
|
||||
interface TreeGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
level: number;
|
||||
values: Record<number, string>;
|
||||
rows: DataTableRow[];
|
||||
children: TreeGroup[];
|
||||
}
|
||||
|
||||
function groupRows(rows: DataTableRow[], columnIndex: number): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[] } {
|
||||
function getRowLabel(row: DataTableRow, displayColumns: number[]): string {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
const parts = displayColumns
|
||||
.map((ci) => String(values[ci] ?? '').trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : '(без названия)';
|
||||
}
|
||||
|
||||
function buildTree(
|
||||
rows: DataTableRow[],
|
||||
treeColumns: number[],
|
||||
level = 0,
|
||||
values: Record<number, string> = {}
|
||||
): TreeGroup[] {
|
||||
if (level >= treeColumns.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columnIndex = treeColumns[level];
|
||||
const groups = new Map<string, DataTableRow[]>();
|
||||
const rootRows: DataTableRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
const key = String(values[columnIndex] ?? '').trim();
|
||||
const vals = Array.isArray(row.values) ? row.values : [];
|
||||
const key = String(vals[columnIndex] ?? '').trim();
|
||||
if (!key) {
|
||||
rootRows.push(row);
|
||||
continue;
|
||||
@@ -36,64 +58,195 @@ function groupRows(rows: DataTableRow[], columnIndex: number): { groups: ColumnT
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(groups.entries())
|
||||
.map(([label, groupRows]) => ({
|
||||
const result: TreeGroup[] = [];
|
||||
for (const [label, groupRows] of groups) {
|
||||
const groupValues = { ...values, [columnIndex]: label };
|
||||
result.push({
|
||||
id: `${level}:${label}`,
|
||||
label,
|
||||
rows: [...groupRows].sort((a, b) => a.position - b.position),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
|
||||
level,
|
||||
values: groupValues,
|
||||
rows: groupRows,
|
||||
children: buildTree(groupRows, treeColumns, level + 1, groupValues),
|
||||
});
|
||||
}
|
||||
|
||||
rootRows.sort((a, b) => a.position - b.position);
|
||||
// Строки без значения в этой колонке относятся к корню текущего уровня
|
||||
if (rootRows.length > 0) {
|
||||
const rootGroup = buildTree(rootRows, treeColumns, level + 1, values);
|
||||
result.push(...rootGroup);
|
||||
}
|
||||
|
||||
return { groups: sortedGroups, rootRows };
|
||||
return result.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
|
||||
}
|
||||
|
||||
function getRowLabel(row: DataTableRow, displayColumnIndex = 0): string {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
|
||||
}
|
||||
|
||||
function filterGroups(
|
||||
groups: ColumnTreeGroup[],
|
||||
rootRows: DataTableRow[],
|
||||
function filterTree(
|
||||
groups: TreeGroup[],
|
||||
query: string,
|
||||
displayColumnIndex: number
|
||||
): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[]; expanded: Set<string> } {
|
||||
if (!query.trim()) return { groups, rootRows, expanded: new Set<string>() };
|
||||
displayColumns: number[]
|
||||
): { groups: TreeGroup[]; expanded: Set<string> } {
|
||||
if (!query.trim()) return { groups, expanded: new Set<string>() };
|
||||
const q = query.toLowerCase();
|
||||
const expanded = new Set<string>();
|
||||
|
||||
const filteredGroups = groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
rows: group.rows.filter((row) => {
|
||||
const label = getRowLabel(row, displayColumnIndex).toLowerCase();
|
||||
return label.includes(q);
|
||||
}),
|
||||
}))
|
||||
.filter((group) => {
|
||||
if (group.rows.length > 0) {
|
||||
expanded.add(group.label);
|
||||
return true;
|
||||
function walk(items: TreeGroup[]): TreeGroup[] {
|
||||
const visible: TreeGroup[] = [];
|
||||
for (const group of items) {
|
||||
const filteredRows = group.rows.filter((row) =>
|
||||
getRowLabel(row, displayColumns).toLowerCase().includes(q)
|
||||
);
|
||||
const children = walk(group.children);
|
||||
if (filteredRows.length > 0 || children.length > 0) {
|
||||
visible.push({ ...group, rows: filteredRows, children });
|
||||
expanded.add(group.id);
|
||||
} else if (group.label.toLowerCase().includes(q)) {
|
||||
visible.push({ ...group, rows: [], children: [] });
|
||||
}
|
||||
if (group.label.toLowerCase().includes(q)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
const filteredRootRows = rootRows.filter((row) => {
|
||||
const label = getRowLabel(row, displayColumnIndex).toLowerCase();
|
||||
return label.includes(q);
|
||||
});
|
||||
return { groups: walk(groups), expanded };
|
||||
}
|
||||
|
||||
return { groups: filteredGroups, rootRows: filteredRootRows, expanded };
|
||||
function collectRows(groups: TreeGroup[]): DataTableRow[] {
|
||||
const result: DataTableRow[] = [];
|
||||
for (const group of groups) {
|
||||
result.push(...group.rows);
|
||||
result.push(...collectRows(group.children));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
group: TreeGroup;
|
||||
displayColumns: number[];
|
||||
selectedRowId?: number | null;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onSelect: (row: DataTableRow) => void;
|
||||
onEdit: (row: DataTableRow) => void;
|
||||
onDelete: (row: DataTableRow) => void;
|
||||
onAddChild: (values: Record<number, string>) => void;
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
group,
|
||||
displayColumns,
|
||||
selectedRowId,
|
||||
expanded,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddChild,
|
||||
}: TreeNodeProps) {
|
||||
const isExpanded = expanded.has(group.id);
|
||||
const isLeaf = group.children.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none hover:bg-muted font-medium"
|
||||
style={{ paddingLeft: `${group.level * 16 + 8}px` }}
|
||||
onClick={() => onToggle(group.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
{isLeaf && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Добавить строку"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddChild(group.values);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{group.rows.map((row) => {
|
||||
const isSelected = selectedRowId === row.id;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
|
||||
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
|
||||
)}
|
||||
style={{ paddingLeft: `${(group.level + 1) * 16 + 8}px` }}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 break-words whitespace-normal leading-tight">
|
||||
{getRowLabel(row, displayColumns)}
|
||||
</span>
|
||||
<div
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="Редактировать"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(row);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
title="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(row);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{group.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
group={child}
|
||||
displayColumns={displayColumns}
|
||||
selectedRowId={selectedRowId}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectoryColumnTree({
|
||||
rows,
|
||||
treeColumnIndex,
|
||||
displayColumnIndex = 0,
|
||||
treeColumns,
|
||||
treeDisplayColumns,
|
||||
selectedRowId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
@@ -101,14 +254,14 @@ export function DirectoryColumnTree({
|
||||
onAddChild,
|
||||
searchQuery,
|
||||
}: DirectoryColumnTreeProps) {
|
||||
const { groups, rootRows } = useMemo(
|
||||
() => groupRows(rows, treeColumnIndex),
|
||||
[rows, treeColumnIndex]
|
||||
const groups = useMemo(
|
||||
() => buildTree(rows, treeColumns),
|
||||
[rows, treeColumns]
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterGroups(groups, rootRows, searchQuery || '', displayColumnIndex),
|
||||
[groups, rootRows, searchQuery, displayColumnIndex]
|
||||
() => filterTree(groups, searchQuery || '', treeDisplayColumns),
|
||||
[groups, searchQuery, treeDisplayColumns]
|
||||
);
|
||||
|
||||
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
|
||||
@@ -118,105 +271,90 @@ export function DirectoryColumnTree({
|
||||
[searchQuery, filtered.expanded, manualExpanded]
|
||||
);
|
||||
|
||||
const toggle = (label: string) => {
|
||||
const toggle = (id: string) => {
|
||||
setManualExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderRow = (row: DataTableRow, level: number) => {
|
||||
const isSelected = selectedRowId === row.id;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
|
||||
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 28}px` }}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 break-words whitespace-normal leading-tight">{getRowLabel(row, displayColumnIndex)}</span>
|
||||
<div
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="Редактировать"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(row);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
title="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(row);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const allRows = useMemo(() => collectRows(groups), [groups]);
|
||||
const displayRows = useMemo(() => {
|
||||
if (!searchQuery?.trim()) return allRows;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return allRows.filter((row) => getRowLabel(row, treeDisplayColumns).toLowerCase().includes(q));
|
||||
}, [allRows, searchQuery, treeDisplayColumns]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{filtered.groups.map((group) => {
|
||||
const isExpanded = effectiveExpanded.has(group.label);
|
||||
return (
|
||||
<div key={group.label}>
|
||||
if (treeColumns.length === 0) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{displayRows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
|
||||
selectedRowId === row.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
|
||||
)}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 break-words whitespace-normal leading-tight">
|
||||
{getRowLabel(row, treeDisplayColumns)}
|
||||
</span>
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none hover:bg-muted font-medium"
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={() => toggle(group.label)}
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{group.label}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Добавить в группу"
|
||||
className="h-6 w-6"
|
||||
title="Редактировать"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddChild(group.label);
|
||||
onEdit(row);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive"
|
||||
title="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(row);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{group.rows.map((row) => renderRow(row, 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.rootRows.length > 0 && (
|
||||
<div className="pt-1">
|
||||
{filtered.rootRows.map((row) => renderRow(row, 0))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{filtered.groups.map((group) => (
|
||||
<TreeNode
|
||||
key={group.id}
|
||||
group={group}
|
||||
displayColumns={treeDisplayColumns}
|
||||
selectedRowId={selectedRowId}
|
||||
expanded={effectiveExpanded}
|
||||
onToggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onAddChild={onAddChild}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export default function TableEditor() {
|
||||
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
|
||||
const [treeColumnIndex, setTreeColumnIndex] = useState<number | null>(null);
|
||||
const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState<number | null>(null);
|
||||
const [treeColumns, setTreeColumns] = useState<number[]>([]);
|
||||
const [treeDisplayColumns, setTreeDisplayColumns] = useState<number[]>([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState<{
|
||||
added: { values: string[]; parentId?: number | null }[];
|
||||
@@ -108,7 +110,7 @@ export default function TableEditor() {
|
||||
const [rowEditorOpen, setRowEditorOpen] = useState(false);
|
||||
const [rowEditorInitialValues, setRowEditorInitialValues] = useState<string[]>([]);
|
||||
const [rowEditorParentId, setRowEditorParentId] = useState<number | null>(null);
|
||||
const [rowEditorGroupValue, setRowEditorGroupValue] = useState<string | null>(null);
|
||||
const [rowEditorPresetValues, setRowEditorPresetValues] = useState<Record<number, string> | null>(null);
|
||||
const [editorValues, setEditorValues] = useState<string[]>([]);
|
||||
const [treeSearchQuery, setTreeSearchQuery] = useState('');
|
||||
const [subdirectoriesOpen, setSubdirectoriesOpen] = useState(false);
|
||||
@@ -198,6 +200,8 @@ export default function TableEditor() {
|
||||
setTableColumns(data.table.columns as TableColumn[]);
|
||||
setTreeColumnIndex(data.table.treeColumnIndex ?? null);
|
||||
setTreeDisplayColumnIndex(data.table.treeDisplayColumnIndex ?? null);
|
||||
setTreeColumns(data.table.treeColumns ?? []);
|
||||
setTreeDisplayColumns(data.table.treeDisplayColumns ?? []);
|
||||
setNewRowValues(new Array(data.table.columns.length).fill(''));
|
||||
}
|
||||
}, [data]);
|
||||
@@ -307,7 +311,7 @@ export default function TableEditor() {
|
||||
};
|
||||
|
||||
const updateTableMutation = useMutation({
|
||||
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null }) => {
|
||||
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null; treeColumns?: number[]; treeDisplayColumns?: number[] }) => {
|
||||
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -482,10 +486,19 @@ export default function TableEditor() {
|
||||
});
|
||||
};
|
||||
|
||||
const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number, groupValue: string | null = null) => {
|
||||
const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number, presetValues: Record<number, string> | null = null) => {
|
||||
setRowEditorParentId(parentId);
|
||||
setRowEditorGroupValue(groupValue);
|
||||
setRowEditorInitialValues(initialValues ?? new Array(tableColumns.length).fill(''));
|
||||
setRowEditorPresetValues(presetValues);
|
||||
const baseValues = initialValues ?? new Array(tableColumns.length).fill('');
|
||||
if (presetValues) {
|
||||
for (const [col, val] of Object.entries(presetValues)) {
|
||||
const idx = Number(col);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < baseValues.length) {
|
||||
baseValues[idx] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
setRowEditorInitialValues(baseValues);
|
||||
if (rowId) {
|
||||
setEditingRowId(rowId);
|
||||
} else {
|
||||
@@ -498,19 +511,15 @@ export default function TableEditor() {
|
||||
setRowEditorOpen(false);
|
||||
setRowEditorInitialValues([]);
|
||||
setRowEditorParentId(null);
|
||||
setRowEditorGroupValue(null);
|
||||
setRowEditorPresetValues(null);
|
||||
setEditingRowId(null);
|
||||
};
|
||||
|
||||
const handleSaveRowEditor = (values: string[]) => {
|
||||
const finalValues = [...values];
|
||||
if (treeColumnIndex !== null && rowEditorGroupValue !== null && editingRowId === null) {
|
||||
finalValues[treeColumnIndex] = rowEditorGroupValue;
|
||||
}
|
||||
if (editingRowId !== null) {
|
||||
updateRowMutation.mutate({ rowId: editingRowId, values: finalValues });
|
||||
updateRowMutation.mutate({ rowId: editingRowId, values });
|
||||
} else {
|
||||
createRowMutation.mutate({ values: finalValues, parentId: rowEditorParentId });
|
||||
createRowMutation.mutate({ values, parentId: rowEditorParentId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -524,9 +533,9 @@ export default function TableEditor() {
|
||||
openRowEditor(parentId, new Array(tableColumns.length).fill(''));
|
||||
};
|
||||
|
||||
const handleColumnAddChild = (groupValue: string | null) => {
|
||||
const handleColumnAddChild = (values: Record<number, string>) => {
|
||||
setSelectedRowId(null);
|
||||
openRowEditor(null, new Array(tableColumns.length).fill(''), undefined, groupValue);
|
||||
openRowEditor(null, new Array(tableColumns.length).fill(''), undefined, values);
|
||||
};
|
||||
|
||||
const handleTreeDelete = (row: DataTableRow) => {
|
||||
@@ -895,11 +904,11 @@ export default function TableEditor() {
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
|
||||
</div>
|
||||
) : treeColumnIndex != null ? (
|
||||
) : treeColumns.length > 0 ? (
|
||||
<DirectoryColumnTree
|
||||
rows={displayRows}
|
||||
treeColumnIndex={treeColumnIndex}
|
||||
displayColumnIndex={treeDisplayColumnIndex ?? 0}
|
||||
treeColumns={treeColumns}
|
||||
treeDisplayColumns={treeDisplayColumns}
|
||||
selectedRowId={selectedRowId}
|
||||
onSelect={handleTreeSelect}
|
||||
onEdit={handleTreeSelect}
|
||||
@@ -917,7 +926,7 @@ export default function TableEditor() {
|
||||
>
|
||||
<DirectoryRowTree
|
||||
rows={displayRows}
|
||||
labelColumnIndex={treeDisplayColumnIndex ?? 0}
|
||||
labelColumnIndex={treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0}
|
||||
selectedRowId={selectedRowId}
|
||||
expanded={expanded}
|
||||
onSelect={handleTreeSelect}
|
||||
@@ -932,7 +941,7 @@ export default function TableEditor() {
|
||||
{activeRow ? (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm bg-accent text-accent-foreground shadow-sm border">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{activeRow.values[treeDisplayColumnIndex ?? 0] || activeRow.values[0] || '—'}</span>
|
||||
<span>{activeRow.values[treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0] || activeRow.values[0] || '—'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
@@ -1351,46 +1360,51 @@ export default function TableEditor() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-table-tree-column">Колонка для дерева</Label>
|
||||
<Select
|
||||
value={treeColumnIndex === null ? 'none' : String(treeColumnIndex)}
|
||||
onValueChange={(val) => setTreeColumnIndex(val === 'none' ? null : Number(val))}
|
||||
>
|
||||
<SelectTrigger id="edit-table-tree-column" className="mt-2">
|
||||
<SelectValue placeholder="Не используется" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Не используется</SelectItem>
|
||||
{tableColumns.map((col, idx) => (
|
||||
<SelectItem key={idx} value={String(idx)}>
|
||||
{col.name || `Колонка ${idx + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Строки будут сгруппированы по уникальным значениям выбранной колонки.
|
||||
<Label>Настройка дерева</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Выберите колонки для группировки (уровни дерева) и для отображения в названии.
|
||||
Порядок группировки соответствует порядку колонок.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-table-tree-display-column">Колонка для отображения в дереве</Label>
|
||||
<Select
|
||||
value={treeDisplayColumnIndex === null ? '0' : String(treeDisplayColumnIndex)}
|
||||
onValueChange={(val) => setTreeDisplayColumnIndex(Number(val))}
|
||||
>
|
||||
<SelectTrigger id="edit-table-tree-display-column" className="mt-2">
|
||||
<SelectValue placeholder="Первая колонка" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col, idx) => (
|
||||
<SelectItem key={idx} value={String(idx)}>
|
||||
{col.name || `Колонка ${idx + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium">Колонка</th>
|
||||
<th className="text-center p-2 font-medium w-28">Группировать</th>
|
||||
<th className="text-center p-2 font-medium w-28">Показывать</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableColumns.map((col, idx) => (
|
||||
<tr key={idx} className="border-t">
|
||||
<td className="p-2">{col.name || `Колонка ${idx + 1}`}</td>
|
||||
<td className="p-2 text-center">
|
||||
<Checkbox
|
||||
checked={treeColumns.includes(idx)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTreeColumns((prev) =>
|
||||
checked ? [...prev, idx] : prev.filter((i) => i !== idx)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-center">
|
||||
<Checkbox
|
||||
checked={treeDisplayColumns.includes(idx)}
|
||||
onCheckedChange={(checked) => {
|
||||
setTreeDisplayColumns((prev) =>
|
||||
checked ? [...prev, idx] : prev.filter((i) => i !== idx)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Значение этой колонки будет показано как название узла в дереве.
|
||||
Если «Группировать» не выбрано ни одной колонки, дерево строится по иерархии строк.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1399,7 +1413,15 @@ export default function TableEditor() {
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex, treeDisplayColumnIndex })}
|
||||
onClick={() => updateTableMutation.mutate({
|
||||
name: tableName,
|
||||
description: tableDescription,
|
||||
columns: tableColumns,
|
||||
treeColumnIndex: treeColumns.length > 0 ? treeColumns[0] : null,
|
||||
treeDisplayColumnIndex: treeDisplayColumns.length > 0 ? treeDisplayColumns[0] : null,
|
||||
treeColumns,
|
||||
treeDisplayColumns,
|
||||
})}
|
||||
disabled={updateTableMutation.isPending}
|
||||
data-testid="button-save-settings"
|
||||
>
|
||||
|
||||
15
migrations/0061_data_table_tree_columns.sql
Normal file
15
migrations/0061_data_table_tree_columns.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- data_tables: многоуровневое дерево и настраиваемое отображение колонок
|
||||
ALTER TABLE data_tables ADD COLUMN IF NOT EXISTS tree_columns JSONB DEFAULT NULL;
|
||||
ALTER TABLE data_tables ADD COLUMN IF NOT EXISTS tree_display_columns JSONB DEFAULT NULL;
|
||||
|
||||
-- Заполняем новые поля из старых, если они ещё пустые
|
||||
UPDATE data_tables
|
||||
SET tree_columns = CASE
|
||||
WHEN tree_column_index IS NOT NULL THEN jsonb_build_array(tree_column_index)
|
||||
ELSE NULL
|
||||
END,
|
||||
tree_display_columns = CASE
|
||||
WHEN tree_display_column_index IS NOT NULL THEN jsonb_build_array(tree_display_column_index)
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE tree_columns IS NULL;
|
||||
@@ -323,13 +323,15 @@ export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
|
||||
}
|
||||
|
||||
const { name, description, columns, treeColumnIndex, treeDisplayColumnIndex } = req.body;
|
||||
const { name, description, columns, treeColumnIndex, treeDisplayColumnIndex, treeColumns, treeDisplayColumns } = req.body;
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (columns !== undefined) updates.columns = columns;
|
||||
if (treeColumnIndex !== undefined) updates.treeColumnIndex = treeColumnIndex === null ? null : Number(treeColumnIndex);
|
||||
if (treeDisplayColumnIndex !== undefined) updates.treeDisplayColumnIndex = treeDisplayColumnIndex === null ? null : Number(treeDisplayColumnIndex);
|
||||
if (treeColumns !== undefined) updates.treeColumns = Array.isArray(treeColumns) ? treeColumns : null;
|
||||
if (treeDisplayColumns !== undefined) updates.treeDisplayColumns = Array.isArray(treeDisplayColumns) ? treeDisplayColumns : null;
|
||||
|
||||
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
|
||||
res.json({ table: updatedTable });
|
||||
@@ -503,6 +505,7 @@ export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
|
||||
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
|
||||
const treeColumnIndex = table.treeColumnIndex;
|
||||
const treeDisplayColumnIndex = table.treeDisplayColumnIndex;
|
||||
const search = String(req.query.search || '').toLowerCase();
|
||||
|
||||
const filtersStr = req.query.filters as string;
|
||||
@@ -521,34 +524,47 @@ export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const displayColumnIndex = table.treeDisplayColumnIndex ?? 0;
|
||||
const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []);
|
||||
const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]);
|
||||
const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0;
|
||||
|
||||
const getRowLabel = (row: typeof rows[number]): string => {
|
||||
const values = Array.isArray(row.values) ? row.values : [];
|
||||
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
|
||||
const parts = treeDisplayColumns
|
||||
.map((ci) => String(values[ci] ?? '').trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : '(без названия)';
|
||||
};
|
||||
|
||||
type TreeNode =
|
||||
| { type: 'group'; id: string; label: string; children: TreeNode[] }
|
||||
| { type: 'row'; id: number; label: string; row: typeof rows[number] };
|
||||
| { type: 'group'; id: string; label: string; level: number; children: TreeNode[] }
|
||||
| { type: 'row'; id: number; label: string; level: number; row: typeof rows[number] };
|
||||
|
||||
const buildParentTree = (parentRows: typeof rows, parentId: number | null): TreeNode[] => {
|
||||
const buildParentTree = (parentRows: typeof rows, parentId: number | null, level = 0): TreeNode[] => {
|
||||
const items = parentRows
|
||||
.filter((r) => (r.parentId ?? null) === parentId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
return items.map((row) => {
|
||||
const children = buildParentTree(parentRows, row.id);
|
||||
const children = buildParentTree(parentRows, row.id, level + 1);
|
||||
const label = getRowLabel(row);
|
||||
if (children.length > 0) {
|
||||
return { type: 'group' as const, id: `row-group:${row.id}`, label, children: [...children, { type: 'row' as const, id: row.id, label, row }] };
|
||||
return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] };
|
||||
}
|
||||
return { type: 'row' as const, id: row.id, label, row };
|
||||
return { type: 'row' as const, id: row.id, label, level, row };
|
||||
});
|
||||
};
|
||||
|
||||
const buildColumnTree = (allRows: typeof rows, columnIndex: number): TreeNode[] => {
|
||||
const buildMultiColumnTree = (allRows: typeof rows, columns: number[], level = 0): TreeNode[] => {
|
||||
if (level >= columns.length) {
|
||||
return allRows
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row }));
|
||||
}
|
||||
|
||||
const columnIndex = columns[level];
|
||||
const groups = new Map<string, typeof rows[number][]>();
|
||||
const rootRows: typeof rows[number][] = [];
|
||||
|
||||
for (const row of allRows) {
|
||||
const vals = Array.isArray(row.values) ? row.values : [];
|
||||
const key = String(vals[columnIndex] ?? '').trim();
|
||||
@@ -559,20 +575,22 @@ export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const nodes: TreeNode[] = [];
|
||||
for (const [label, groupRows] of groups) {
|
||||
const sorted = [...groupRows].sort((a, b) => a.position - b.position);
|
||||
nodes.push({
|
||||
type: 'group',
|
||||
id: `group:${label}`,
|
||||
id: `group:${level}:${label}`,
|
||||
label,
|
||||
children: sorted.map((row) => ({ type: 'row', id: row.id, label: getRowLabel(row), row })),
|
||||
level,
|
||||
children: buildMultiColumnTree(groupRows, columns, level + 1),
|
||||
});
|
||||
}
|
||||
|
||||
if (rootRows.length > 0) {
|
||||
rootRows.sort((a, b) => a.position - b.position);
|
||||
nodes.push(...rootRows.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), row })));
|
||||
nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1));
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
const al = a.type === 'group' ? a.label : getRowLabel(a.row);
|
||||
const bl = b.type === 'group' ? b.label : getRowLabel(b.row);
|
||||
@@ -599,12 +617,19 @@ export function registerDataTableRoutes(app: import("express").Express): void {
|
||||
return result;
|
||||
};
|
||||
|
||||
const rawNodes = treeColumnIndex != null
|
||||
? buildColumnTree(rows, treeColumnIndex)
|
||||
const rawNodes = treeColumns.length > 0
|
||||
? buildMultiColumnTree(rows, treeColumns)
|
||||
: buildParentTree(rows, null);
|
||||
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
|
||||
|
||||
res.json({ columnIndex: treeColumnIndex, displayColumnIndex, nodes });
|
||||
res.json({
|
||||
columnIndex: treeColumnIndex,
|
||||
treeColumnIndex,
|
||||
treeColumns,
|
||||
treeDisplayColumns,
|
||||
displayColumnIndex,
|
||||
nodes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get table rows tree error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения дерева строк' });
|
||||
|
||||
@@ -1845,6 +1845,8 @@ export const dataTables = pgTable("data_tables", {
|
||||
folderId: integer("folder_id"),
|
||||
treeColumnIndex: integer("tree_column_index"),
|
||||
treeDisplayColumnIndex: integer("tree_display_column_index"),
|
||||
treeColumns: jsonb("tree_columns").$type<number[] | null>().default(null),
|
||||
treeDisplayColumns: jsonb("tree_display_columns").$type<number[] | null>().default(null),
|
||||
}, (table) => ({
|
||||
folderIdx: index("data_tables_folder_idx").on(table.folderId),
|
||||
}));
|
||||
@@ -2002,6 +2004,8 @@ export const insertDataTableSchema = createInsertSchema(dataTables).omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isDeleted: true,
|
||||
treeColumns: true,
|
||||
treeDisplayColumns: true,
|
||||
}).extend({
|
||||
columns: z.array(z.object({
|
||||
name: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user