godcrm/components/ChatBindingSettings.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

387 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* ChatBindingSettings Component
* ADR-024: Chat & Message Architecture Redesign
*
* Settings panel for configuring chat bindings:
* - Default space/table for task binding
* - Current row bindings (multiple rows allowed)
* - Participant management
*/
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/shared/utils/apiClient';
import { cn } from '@/shared/utils/cn';
import {
Settings,
Link2,
Unlink,
Table2,
FolderOpen,
Hash,
Plus,
X,
Check,
ChevronDown,
Search,
Loader2
} from 'lucide-react';
interface Space {
id: number;
name: string;
icon?: string;
}
interface Table {
id: number;
name: string;
slug: string;
icon?: string;
display_column?: string;
}
interface Row {
id: number;
data: Record<string, unknown>;
table_id: number;
}
interface RowBinding {
tableId: number;
tableName: string;
rowId: number;
rowDisplay: string;
}
export interface ChatBindingConfig {
defaultSpaceId?: number;
defaultTableId?: number;
bindings: RowBinding[];
}
export interface ChatBindingSettingsProps {
config: ChatBindingConfig;
onConfigChange: (config: ChatBindingConfig) => void;
currentSpaceId?: number;
className?: string;
}
export function ChatBindingSettings({
config,
onConfigChange,
currentSpaceId,
className
}: ChatBindingSettingsProps) {
const [isAddingBinding, setIsAddingBinding] = useState(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<number | null>(
config.defaultSpaceId || currentSpaceId || null
);
const [selectedTableId, setSelectedTableId] = useState<number | null>(
config.defaultTableId || null
);
const [searchQuery, setSearchQuery] = useState('');
// Fetch spaces
const { data: spaces = [], isLoading: isLoadingSpaces } = useQuery({
queryKey: ['spaces'],
queryFn: async () => {
const response = await apiClient.get<{
success: boolean;
data: Space[];
}>('/spaces');
return response.success ? response.data : [];
}
});
// Fetch tables for selected space
const { data: tables = [], isLoading: isLoadingTables } = useQuery({
queryKey: ['tables', selectedSpaceId],
queryFn: async () => {
if (!selectedSpaceId) return [];
const response = await apiClient.get<{
success: boolean;
data: Table[];
}>(`/tables?spaceId=${selectedSpaceId}`);
return response.success ? response.data : [];
},
enabled: !!selectedSpaceId
});
// Fetch rows for selected table
const { data: rows = [], isLoading: isLoadingRows } = useQuery({
queryKey: ['rows', selectedTableId, searchQuery],
queryFn: async () => {
if (!selectedTableId) return [];
const params = new URLSearchParams();
if (searchQuery) params.append('search', searchQuery);
params.append('limit', '20');
const response = await apiClient.get<{
success: boolean;
data: { rows: Row[] };
}>(`/tables/${selectedTableId}/rows?${params}`);
return response.success ? response.data.rows : [];
},
enabled: !!selectedTableId && isAddingBinding
});
// Get display value for a row
const getRowDisplay = (row: Row, table?: Table): string => {
const displayColumn = table?.display_column || 'name';
const data = row.data;
return String(data[displayColumn] || data['title'] || data['name'] || `#${row.id}`);
};
// Handle adding a binding
const handleAddBinding = (row: Row) => {
const table = tables.find(t => t.id === selectedTableId);
if (!table) return;
const newBinding: RowBinding = {
tableId: table.id,
tableName: table.name,
rowId: row.id,
rowDisplay: getRowDisplay(row, table)
};
// Check if already bound
const exists = config.bindings.some(
b => b.tableId === newBinding.tableId && b.rowId === newBinding.rowId
);
if (exists) return;
onConfigChange({
...config,
bindings: [...config.bindings, newBinding]
});
setIsAddingBinding(false);
setSearchQuery('');
};
// Handle removing a binding
const handleRemoveBinding = (binding: RowBinding) => {
onConfigChange({
...config,
bindings: config.bindings.filter(
b => !(b.tableId === binding.tableId && b.rowId === binding.rowId)
)
});
};
// Handle default space change
const handleDefaultSpaceChange = (spaceId: number) => {
setSelectedSpaceId(spaceId);
setSelectedTableId(null);
onConfigChange({
...config,
defaultSpaceId: spaceId,
defaultTableId: undefined
});
};
// Handle default table change
const handleDefaultTableChange = (tableId: number) => {
setSelectedTableId(tableId);
onConfigChange({
...config,
defaultTableId: tableId
});
};
return (
<div className={cn('space-y-4', className)}>
{/* Default Binding Settings */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Settings className="w-4 h-4" />
Настройки привязки
</h4>
{/* Default Space */}
<div className="space-y-1">
<label className="text-xs text-gray-500">Пространство по умолчанию</label>
<div className="relative">
<select
value={selectedSpaceId || ''}
onChange={(e) => handleDefaultSpaceChange(Number(e.target.value))}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg appearance-none cursor-pointer pr-8"
>
<option value="">Выберите пространство...</option>
{spaces.map(space => (
<option key={space.id} value={space.id}>
{space.icon || '📁'} {space.name}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
{/* Default Table */}
{selectedSpaceId && (
<div className="space-y-1">
<label className="text-xs text-gray-500">Таблица задач по умолчанию</label>
<div className="relative">
<select
value={selectedTableId || ''}
onChange={(e) => handleDefaultTableChange(Number(e.target.value))}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg appearance-none cursor-pointer pr-8"
disabled={isLoadingTables}
>
<option value="">Выберите таблицу...</option>
{tables.map(table => (
<option key={table.id} value={table.id}>
{table.icon || '📋'} {table.name}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
)}
</div>
{/* Current Bindings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Link2 className="w-4 h-4" />
Привязанные записи
</h4>
<button
onClick={() => setIsAddingBinding(true)}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Добавить
</button>
</div>
{/* Bindings List */}
{config.bindings.length === 0 ? (
<p className="text-xs text-gray-400 italic py-2">
Нет привязанных записей
</p>
) : (
<div className="space-y-1">
{config.bindings.map((binding, index) => (
<div
key={`${binding.tableId}-${binding.rowId}`}
className="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<Table2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="text-gray-500 flex-shrink-0">{binding.tableName}</span>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="truncate font-medium">{binding.rowDisplay}</span>
</div>
<button
onClick={() => handleRemoveBinding(binding)}
className="p-1 text-gray-400 hover:text-red-500 flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Add Binding Modal */}
{isAddingBinding && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium">Привязать запись</h3>
<button
onClick={() => {
setIsAddingBinding(false);
setSearchQuery('');
}}
className="p-1 text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-3">
{/* Table Selector */}
<div className="space-y-1">
<label className="text-xs text-gray-500">Таблица</label>
<div className="relative">
<select
value={selectedTableId || ''}
onChange={(e) => setSelectedTableId(Number(e.target.value))}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg appearance-none cursor-pointer pr-8"
>
<option value="">Выберите таблицу...</option>
{tables.map(table => (
<option key={table.id} value={table.id}>
{table.icon || '📋'} {table.name}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
{/* Search */}
{selectedTableId && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск записей..."
className="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg"
/>
</div>
)}
{/* Rows List */}
{selectedTableId && (
<div className="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
{isLoadingRows ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div>
) : rows.length === 0 ? (
<p className="text-center py-8 text-sm text-gray-400">
Записи не найдены
</p>
) : (
rows.map(row => {
const table = tables.find(t => t.id === selectedTableId);
const isAlreadyBound = config.bindings.some(
b => b.tableId === selectedTableId && b.rowId === row.id
);
return (
<button
key={row.id}
onClick={() => !isAlreadyBound && handleAddBinding(row)}
disabled={isAlreadyBound}
className={cn(
'w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center gap-2 border-b border-gray-100 dark:border-gray-800 last:border-b-0',
isAlreadyBound && 'opacity-50 cursor-not-allowed'
)}
>
<Hash className="w-4 h-4 text-gray-400" />
<span className="truncate">{getRowDisplay(row, table)}</span>
{isAlreadyBound && (
<Check className="w-4 h-4 text-green-500 ml-auto" />
)}
</button>
);
})
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}