198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
import { useState, useRef, useMemo } from 'react';
|
||
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, type DirectoryTreeNode } from '@/components/directories/DirectoryTreeSelect';
|
||
import { apiRequest } from '@/lib/queryClient';
|
||
import type { DataTableRow } from '@shared/schema';
|
||
|
||
interface TableFieldSelectProps {
|
||
fieldId: number;
|
||
fieldCode: string;
|
||
placeholder?: string;
|
||
options?: unknown;
|
||
value: any;
|
||
onChange: (value: string) => void;
|
||
}
|
||
|
||
export default function TableFieldSelect({
|
||
fieldId,
|
||
fieldCode,
|
||
placeholder,
|
||
options,
|
||
value,
|
||
onChange,
|
||
}: TableFieldSelectProps) {
|
||
const opts = options && !Array.isArray(options) ? (options as Record<string, any>) : {};
|
||
const tableId = opts.tableId as number | undefined;
|
||
const displayColumn = (opts.displayColumn as string) || '';
|
||
const rowFilters = opts.filters as Record<string, string> | undefined;
|
||
|
||
const { data: tableData, isLoading, error } = useQuery<{
|
||
table: {
|
||
rows: DataTableRow[];
|
||
columns: Array<{ name: string }>;
|
||
treeColumnIndex: number | null;
|
||
treeDisplayColumnIndex: number | null;
|
||
treeColumns: number[] | null;
|
||
treeDisplayColumns: number[] | null;
|
||
};
|
||
}>({
|
||
queryKey: ['/api/directories', tableId],
|
||
enabled: !!tableId,
|
||
staleTime: 5 * 60 * 1000,
|
||
gcTime: 10 * 60 * 1000,
|
||
retry: false,
|
||
});
|
||
|
||
const filtersStr = useMemo(() => (rowFilters ? JSON.stringify(rowFilters) : ''), [rowFilters]);
|
||
|
||
const { data: treeData } = useQuery<{
|
||
columnIndex: number | null;
|
||
treeColumns: number[];
|
||
treeDisplayColumns: number[];
|
||
nodes: DirectoryTreeNode[];
|
||
}>({
|
||
queryKey: ['/api/directories', tableId, 'rows', 'tree', filtersStr],
|
||
queryFn: async () => {
|
||
const params = new URLSearchParams();
|
||
if (filtersStr) params.set('filters', filtersStr);
|
||
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree?${params.toString()}`);
|
||
return res.json();
|
||
},
|
||
enabled: !!tableId && !!tableData,
|
||
staleTime: 5 * 60 * 1000,
|
||
gcTime: 10 * 60 * 1000,
|
||
retry: false,
|
||
});
|
||
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const table = tableData?.table;
|
||
const rows = table?.rows || [];
|
||
const nodes = treeData?.nodes || [];
|
||
|
||
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
|
||
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
|
||
displayColumnIndex = table?.treeDisplayColumnIndex ?? table?.treeDisplayColumns?.[0] ?? 0;
|
||
}
|
||
|
||
const sortedRows = useMemo(() => {
|
||
return [...rows].sort((a, b) => {
|
||
const av = a.values?.[displayColumnIndex!] || a.values?.[0] || '';
|
||
const bv = b.values?.[displayColumnIndex!] || b.values?.[0] || '';
|
||
return av.localeCompare(bv, 'ru');
|
||
});
|
||
}, [rows, displayColumnIndex]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!searchTerm.trim()) return sortedRows;
|
||
const term = searchTerm.toLowerCase();
|
||
return sortedRows.filter((r) => {
|
||
const display = r.values?.[displayColumnIndex!] || r.values?.[0] || '';
|
||
return display.toLowerCase().includes(term);
|
||
});
|
||
}, [sortedRows, searchTerm, displayColumnIndex]);
|
||
|
||
const hasTree = nodes.some((n) => n.type === 'group');
|
||
|
||
if (!tableId) {
|
||
return <div className="text-sm text-muted-foreground">Таблица не настроена</div>;
|
||
}
|
||
|
||
if (isLoading) {
|
||
return <Input disabled className="h-8" placeholder="Загрузка..." />;
|
||
}
|
||
|
||
if (error) {
|
||
return <div className="text-sm text-destructive">Нет доступа к таблице</div>;
|
||
}
|
||
|
||
if (!table || !table.rows?.length) {
|
||
return <div className="text-sm text-muted-foreground">Таблица пуста</div>;
|
||
}
|
||
|
||
const selectedRow = table.rows?.find((r) => r.id.toString() === value?.toString());
|
||
const selectedDisplay = selectedRow
|
||
? selectedRow.values[displayColumnIndex!] || selectedRow.values[0] || ''
|
||
: '';
|
||
|
||
const handleSelect = (rowId: string) => {
|
||
onChange(rowId);
|
||
setSearchTerm('');
|
||
setIsOpen(false);
|
||
};
|
||
|
||
return (
|
||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||
<PopoverAnchor asChild>
|
||
<div className="relative w-full">
|
||
<Input
|
||
ref={inputRef}
|
||
className="h-8"
|
||
placeholder={placeholder || 'Выберите значение...'}
|
||
value={isOpen ? searchTerm : selectedDisplay || ''}
|
||
onChange={(e) => {
|
||
setSearchTerm(e.target.value);
|
||
setIsOpen(true);
|
||
}}
|
||
onFocus={() => {
|
||
setSearchTerm('');
|
||
setIsOpen(true);
|
||
}}
|
||
data-testid={`select-table-${fieldCode}`}
|
||
/>
|
||
</div>
|
||
</PopoverAnchor>
|
||
<PopoverContent
|
||
className="p-0"
|
||
style={{ width: 'var(--radix-popper-anchor-width)' }}
|
||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => {
|
||
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
|
||
e.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<div className="max-h-60 overflow-auto">
|
||
{hasTree ? (
|
||
<DirectoryTreeSelect
|
||
nodes={nodes}
|
||
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>
|
||
)}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|