godcrm/components/TasksSourceConfig.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

448 lines
18 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.

/**
* TasksSourceConfig Component
* ADR-024: Configure source table for Tasks tab
*
* Features:
* - Select Project → Table
* - Bind to existing row (for context)
* - Uses useAllTables hook for hierarchical data
*/
import { useState, useEffect, useMemo } from 'react';
import {
X,
Table2,
Check,
Loader2,
Link2,
Unlink,
ChevronRight,
Search
} from 'lucide-react';
import { cn } from '@/shared/utils/cn';
import { useAllTables } from '@/features/tables/hooks/useAllTables';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/shared/utils/apiClient';
import { TasksSourceConfig as Config } from './ContactsList.v3';
export interface BoundRowInfo {
tableId: number;
rowId: number;
displayValue: string;
tableName?: string;
}
export interface TasksSourceConfigProps {
isOpen: boolean;
onClose: () => void;
currentConfig?: Config;
onSave: (config: Config) => void;
defaultSpaceId?: number;
// Row binding
boundRow?: BoundRowInfo;
onBindRow?: (row: BoundRowInfo) => void;
onUnbindRow?: () => void;
}
export function TasksSourceConfigModal({
isOpen,
onClose,
currentConfig,
onSave,
defaultSpaceId,
boundRow,
onBindRow,
onUnbindRow
}: TasksSourceConfigProps) {
const { data: allTablesData, isLoading } = useAllTables();
// Selected project and table
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null);
const [selectedTableId, setSelectedTableId] = useState<string | null>(
currentConfig?.tableId ? String(currentConfig.tableId) : null
);
// Row binding state
const [showRowBinding, setShowRowBinding] = useState(false);
const [bindingTableId, setBindingTableId] = useState<string | null>(null);
const [rowSearchQuery, setRowSearchQuery] = useState('');
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setSelectedTableId(currentConfig?.tableId ? String(currentConfig.tableId) : null);
setShowRowBinding(false);
setBindingTableId(null);
setRowSearchQuery('');
setSelectedRowId(null);
// Try to find the project of current table
if (currentConfig?.tableId && allTablesData?.flat) {
const currentTable = allTablesData.flat.find(t => t.id === String(currentConfig.tableId));
if (currentTable) {
setSelectedProjectId(currentTable.projectId);
}
}
}
}, [isOpen, currentConfig, allTablesData]);
// Fetch rows for binding
const { data: bindingRows = [], isLoading: isLoadingRows } = useQuery({
queryKey: ['binding-rows', bindingTableId, rowSearchQuery],
queryFn: async () => {
if (!bindingTableId) return [];
const params = new URLSearchParams({ limit: '50' });
if (rowSearchQuery) params.append('search', rowSearchQuery);
const response = await apiClient.get<{
success: boolean;
data: { rows: Array<{ id: number; data: Record<string, unknown> }> };
}>(`/tables/${bindingTableId}/rows?${params}`);
return response.success ? response.data.rows : [];
},
enabled: !!bindingTableId && showRowBinding
});
// Get binding table columns for display
const bindingTableInfo = useMemo(() => {
if (!bindingTableId) return null;
return allTablesData?.flat.find(t => t.id === bindingTableId);
}, [bindingTableId, allTablesData]);
// Get tables for selected project
const projectTables = useMemo(() => {
if (!selectedProjectId || !allTablesData?.spacesWithTables) return [];
for (const space of allTablesData.spacesWithTables) {
const project = space.projects.find(p => p.id === selectedProjectId);
if (project) {
return project.tables || [];
}
}
return [];
}, [selectedProjectId, allTablesData]);
// Handle project change
const handleProjectChange = (projectId: string) => {
const newProjectId = projectId ? Number(projectId) : null;
setSelectedProjectId(newProjectId);
setSelectedTableId(null); // Reset table when project changes
};
// Handle table selection
const handleTableChange = (tableId: string) => {
setSelectedTableId(tableId || null);
};
// Handle save
const handleSave = () => {
if (!selectedTableId) return;
const table = allTablesData?.flat.find(t => t.id === selectedTableId);
if (table) {
onSave({
tableId: Number(table.id),
tableName: table.displayName || table.name,
tableIcon: table.icon,
displayColumn: 'name' // Default, can be made configurable later
});
onClose();
}
};
if (!isOpen) return null;
// Get first display column from row
const getRowDisplayValue = (row: { id: number; data: Record<string, unknown> }) => {
const data = row.data;
return String(data['name'] || data['title'] || data['Название'] || data['Name'] || `#${row.id}`);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--bg-primary)] rounded-xl shadow-2xl w-full max-w-md flex flex-col overflow-hidden max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-secondary)]">
<h3 className="font-semibold text-[var(--text-primary)]">
{showRowBinding ? 'Привязать к записи' : 'Источник записей'}
</h3>
<button
onClick={showRowBinding ? () => setShowRowBinding(false) : onClose}
className="p-1 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Current bound row */}
{boundRow && !showRowBinding && (
<div className="px-4 py-2 bg-green-500/10 border-b border-[var(--border-secondary)]">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-[var(--text-tertiary)] mb-1">Привязано к записи:</div>
<div className="flex items-center gap-2 text-sm text-green-400">
<Link2 className="w-4 h-4" />
<span>{boundRow.displayValue}</span>
<span className="text-xs opacity-60">#{boundRow.rowId}</span>
</div>
</div>
{onUnbindRow && (
<button
onClick={onUnbindRow}
className="p-1.5 rounded hover:bg-red-500/20 text-red-400"
title="Отвязать"
>
<Unlink className="w-4 h-4" />
</button>
)}
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
{showRowBinding ? (
/* Row Binding View */
<div className="p-4 space-y-3">
{/* Table selector for binding */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
Таблица
</label>
<select
value={bindingTableId || ''}
onChange={(e) => {
setBindingTableId(e.target.value || null);
setSelectedRowId(null);
}}
className="w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30"
>
<option value=""> Выберите таблицу </option>
{(allTablesData?.spacesWithTables || []).map((space) => (
<optgroup key={space.id} label={`${space.icon || '⚙️'} ${space.name}`}>
{space.projects.flatMap((project) =>
(project.tables || []).map((table) => (
<option key={table.id} value={table.id}>
{table.icon || '📋'} {table.displayName}
</option>
))
)}
</optgroup>
))}
</select>
</div>
{/* Search */}
{bindingTableId && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<input
type="text"
value={rowSearchQuery}
onChange={(e) => setRowSearchQuery(e.target.value)}
placeholder="Поиск записей..."
className="w-full pl-9 pr-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30"
/>
</div>
)}
{/* Rows list */}
{bindingTableId && (
<div className="border border-[var(--border-secondary)] rounded-lg overflow-hidden max-h-64 overflow-y-auto">
{isLoadingRows ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-[var(--text-tertiary)]" />
</div>
) : bindingRows.length === 0 ? (
<div className="py-6 text-center text-sm text-[var(--text-tertiary)]">
{rowSearchQuery ? 'Ничего не найдено' : 'Нет записей'}
</div>
) : (
bindingRows.map((row) => (
<button
key={row.id}
onClick={() => setSelectedRowId(row.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left border-b border-[var(--border-secondary)] last:border-b-0 transition-colors",
selectedRowId === row.id
? "bg-[var(--color-primary-500)]/20"
: "hover:bg-[var(--bg-tertiary)]"
)}
>
<div className="w-6 h-6 rounded bg-[var(--bg-tertiary)] flex items-center justify-center text-xs text-[var(--text-tertiary)]">
#{row.id}
</div>
<span className="flex-1 text-sm text-[var(--text-primary)] truncate">
{getRowDisplayValue(row)}
</span>
{selectedRowId === row.id && (
<Check className="w-4 h-4 text-[var(--color-primary-500)]" />
)}
</button>
))
)}
</div>
)}
</div>
) : (
/* Main Config View */
<div className="p-4 space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-[var(--text-tertiary)]" />
</div>
) : (
<>
{/* Bind to Row button */}
{onBindRow && (
<button
onClick={() => setShowRowBinding(true)}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg border border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[var(--color-primary-500)]/20 flex items-center justify-center">
<Link2 className="w-4 h-4 text-[var(--color-primary-500)]" />
</div>
<div className="text-left">
<div className="text-sm font-medium text-[var(--text-primary)]">
Привязать к записи
</div>
<div className="text-xs text-[var(--text-tertiary)]">
Связать чат с существующей записью
</div>
</div>
</div>
<ChevronRight className="w-4 h-4 text-[var(--text-tertiary)]" />
</button>
)}
{/* Divider */}
{onBindRow && (
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-[var(--border-secondary)]" />
<span className="text-xs text-[var(--text-tertiary)]">или</span>
<div className="flex-1 h-px bg-[var(--border-secondary)]" />
</div>
)}
{/* Project selector with optgroups by Space */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
Источник задач Проект
</label>
<select
value={selectedProjectId ? String(selectedProjectId) : ''}
onChange={(e) => handleProjectChange(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30"
>
<option value=""> Выберите проект </option>
{(allTablesData?.spacesWithTables || []).map((space) => (
<optgroup key={space.id} label={`${space.icon || '⚙️'} ${space.name}`}>
{space.projects.map((project) => (
<option key={project.id} value={String(project.id)}>
{project.icon || '📂'} {project.name}
</option>
))}
</optgroup>
))}
</select>
</div>
{/* Table selector */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1.5">
Таблица
</label>
<select
value={selectedTableId || ''}
onChange={(e) => handleTableChange(e.target.value)}
disabled={!selectedProjectId}
className={cn(
"w-full px-3 py-2.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-primary)] text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-500)]/30",
!selectedProjectId && "opacity-50 cursor-not-allowed"
)}
>
<option value=""> Выберите таблицу </option>
{projectTables.map((table) => (
<option key={table.id} value={table.id}>
{table.icon || '📋'} {table.displayName}
</option>
))}
</select>
</div>
{/* Selected table preview */}
{selectedTableId && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 text-sm text-green-400">
<Check className="w-4 h-4" />
<span>
{(() => {
const table = allTablesData?.flat.find(t => t.id === selectedTableId);
if (!table) return 'Таблица выбрана';
return `${table.icon || '📋'} ${table.displayName}`;
})()}
</span>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)] flex items-center justify-end gap-2">
<button
onClick={showRowBinding ? () => setShowRowBinding(false) : onClose}
className="px-3 py-1.5 rounded-lg text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]"
>
{showRowBinding ? 'Назад' : 'Отмена'}
</button>
{showRowBinding ? (
<button
onClick={() => {
if (selectedRowId && bindingTableId && onBindRow) {
const row = bindingRows.find(r => r.id === selectedRowId);
if (row) {
onBindRow({
tableId: Number(bindingTableId),
rowId: selectedRowId,
displayValue: getRowDisplayValue(row),
tableName: bindingTableInfo?.displayName
});
setShowRowBinding(false);
}
}
}}
disabled={!selectedRowId}
className={cn(
"px-4 py-1.5 rounded-lg text-sm font-medium bg-[var(--color-primary-500)] text-white",
selectedRowId
? "hover:bg-[var(--color-primary-600)]"
: "opacity-50 cursor-not-allowed"
)}
>
Привязать
</button>
) : (
<button
onClick={handleSave}
disabled={!selectedTableId}
className={cn(
"px-4 py-1.5 rounded-lg text-sm font-medium bg-[var(--color-primary-500)] text-white",
selectedTableId
? "hover:bg-[var(--color-primary-600)]"
: "opacity-50 cursor-not-allowed"
)}
>
Сохранить
</button>
)}
</div>
</div>
</div>
);
}