Files
iistwin/client/src/components/TaskRegistry.tsx

1114 lines
48 KiB
TypeScript
Raw 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.

import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQueries } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Settings2, Calendar, User, Search, X as XIcon, Paperclip, AlertCircle, CheckCircle2, GripVertical } from 'lucide-react';
import { type ColumnSort } from '@/hooks/useColumnFilters';
import { resolveFieldDisplayValue, type FieldDisplayContext } from '@/lib/fieldDisplay';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useTableMaps } from '@/hooks/useTableMaps';
import { ColumnFilter } from '@/components/ui/ColumnFilter';
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { addFileToken, openFilePresigned } from '@/lib/fileUtils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import ContractNumberDisplay from '@/components/ContractNumberDisplay';
import RegistryCellEditor from './RegistryCellEditor';
interface TaskAssigneeInfo {
userId: number;
user: { firstName: string | null; lastName: string | null; middleName?: string | null; email: string };
}
interface TaskData {
id: number;
title: string;
description?: string;
currentStatusId: number;
assignedTo?: number;
assignees?: TaskAssigneeInfo[];
createdBy: number;
createdAt: string;
updatedAt: string;
completedAt?: string;
isCompleted?: boolean;
dueDate?: string;
fieldValues?: { [fieldId: number]: unknown };
}
interface FormFieldData {
id: number;
name: string;
code: string;
type: string;
isRequired: boolean;
placeholder?: string;
options?: any;
autoProlongation?: boolean;
autoProlongationLabel?: string;
ganttRole?: 'start' | 'end' | 'milestone' | null;
}
interface FormStatusData {
id: number;
name: string;
color: string;
isInitial: boolean;
isFinal: boolean;
}
interface UserData {
id: number;
firstName: string;
lastName: string;
middleName?: string;
email: string;
fullName?: string;
}
interface TaskRegistryProps {
tasks: TaskData[];
relatedTaskTitles?: Record<number, string>;
fields: FormFieldData[];
statuses: FormStatusData[];
users?: UserData[];
roles?: Array<{ id: number; name?: string }>;
formId: number;
onFilteredCountChange?: (count: number) => void;
onTaskClick?: (task: TaskData) => void;
// External filter state from TaskView (server-driven)
filters: Record<string, string>;
sort: ColumnSort | null;
setFilter: (column: string, value: string) => void;
clearFilter: (column: string) => void;
clearAll: () => void;
setSort: (column: string) => void;
clearSort: () => void;
hasActiveFilters: boolean;
// Global search (URL-synced, multi-term)
searches: string[];
onAddSearch: (value: string) => void;
onRemoveSearch: (index: number) => void;
// Bulk selection
isAdmin?: boolean;
selectedTaskIds?: Set<number>;
onToggleSelected?: (taskId: number) => void;
onToggleSelectAll?: () => void;
onSelectRange?: (taskIds: number[]) => void;
}
// Отображает значение поля с учётом типа
function displayFieldValue(
field: FormFieldData | undefined,
value: unknown,
context: FieldDisplayContext
): string {
return resolveFieldDisplayValue(value, (field || {}) as { type?: string; options?: unknown; [key: string]: unknown }, context);
}
interface FileEntry {
name: string;
url?: string;
}
function extractFileEntries(value: unknown): FileEntry[] {
if (value === null || value === undefined || value === '') return [];
if (Array.isArray(value)) {
return value
.filter((f): f is Record<string, unknown> => typeof f === 'object' && f !== null)
.map(f => ({ name: typeof f.name === 'string' ? f.name : '', url: typeof f.url === 'string' ? f.url : undefined }))
.filter(f => f.name);
}
if (typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
if (typeof obj.name === 'string' && obj.name) {
return [{ name: obj.name, url: typeof obj.url === 'string' ? obj.url : undefined }];
}
}
return [];
}
function pluralizeFiles(count: number): string {
if (count % 10 === 1 && count % 100 !== 11) return `${count} файл`;
if (count % 10 >= 2 && count % 10 <= 4 && (count % 100 < 10 || count % 100 >= 20)) return `${count} файла`;
return `${count} файлов`;
}
function FileFieldCell({ value }: { value: unknown }) {
const files = extractFileEntries(value);
if (files.length === 0) return <span className="text-muted-foreground">—</span>;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 cursor-pointer text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={e => e.stopPropagation()}
>
<Paperclip className="w-3 h-3 text-muted-foreground" />
{pluralizeFiles(files.length)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<ul className="space-y-1">
{files.map((f, i) =>
f.url ? (
<li key={i}>
<a
href={addFileToken(f.url)}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-primary break-all"
onClick={e => { e.stopPropagation(); e.preventDefault(); openFilePresigned(f.url!); }}
>
{f.name}
</a>
</li>
) : (
<li key={i} className="break-all">{f.name}</li>
)
)}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
interface SortableColumnItemProps {
id: string;
label: React.ReactNode;
checked: boolean;
onCheckedChange: () => void;
}
function SortableColumnItem({ id, label, checked, onCheckedChange }: SortableColumnItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted/50"
>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="w-4 h-4" />
</button>
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} data-testid={`checkbox-column-${id}`} />
<Label htmlFor={id} className="cursor-pointer text-sm">
{label}
</Label>
</div>
);
}
// Типы полей, поддерживающие inline-редактирование в реестре
const REGISTRY_INLINE_TYPES = new Set([
'text', 'number', 'textarea', 'rich-text', 'date', 'datetime',
'select', 'radio-group', 'user', 'role', 'table', 'company',
'geo', 'contract-number', 'contact',
]);
// Типы полей, которые переключаются по одиночному клику
const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']);
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, onToggleSelected, onToggleSelectAll, onSelectRange }: TaskRegistryProps) => {
const { toast } = useToast();
// Текущая редактируемая ячейка: { taskId, columnKey }
const [editingCell, setEditingCell] = useState<{ taskId: number; columnKey: string } | null>(null);
// Последняя выделенная задача — якорь для Shift+клика
const [lastSelectedTaskId, setLastSelectedTaskId] = useState<number | null>(null);
// Функция для получения имени пользователя по ID
const getUserName = (userId: number): string => {
const user = users.find(u => u.id === userId);
if (user) {
return user.fullName || `${user.firstName} ${user.middleName || ''} ${user.lastName}`.trim() || user.email;
}
return `ID ${userId}`;
};
// Карта названий задач для полей типа 'task'
const taskTitleMap = useMemo(() => {
const map = new Map<number, string>();
for (const task of tasks) {
if (task.id) map.set(task.id, task.title);
}
for (const [id, title] of Object.entries(relatedTaskTitles)) {
const numId = Number(id);
if (!isNaN(numId) && title) map.set(numId, title);
}
return map;
}, [tasks, relatedTaskTitles]);
// Карты справочников для полей типа 'table'
const { tableMap } = useTableMaps(fields);
// Общий контекст для resolveFieldDisplayValue
const displayContext = useMemo<FieldDisplayContext>(
() => ({ users, roles, taskTitleMap, tableMap }),
[users, roles, taskTitleMap, tableMap]
);
// Загрузка юрлиц для полей типа 'contract-number' (для клиентского поиска)
const contractNumberFields = useMemo(() => fields.filter(f => f.type === 'contract-number'), [fields]);
const legalEntityFormIds = useMemo(() => {
const ids = new Set<number>();
for (const f of contractNumberFields) {
const leFormId = (f.options as any)?.contractNumber?.legalEntityFormId;
if (leFormId) ids.add(leFormId);
}
return Array.from(ids);
}, [contractNumberFields]);
const legalEntityQueries = useQueries({
queries: legalEntityFormIds.map(formId => ({
queryKey: ['/api/forms', formId, 'tasks'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/forms/${formId}/tasks`);
const data = await res.json() as { tasks: Array<{ id: number; title: string }> };
if (process.env.NODE_ENV === 'development') {
console.log('[TaskRegistry] Loaded legal entities for form', formId, 'count:', data?.tasks?.length);
}
return data;
},
enabled: !!formId,
staleTime: Infinity,
gcTime: 10 * 60 * 1000,
})),
});
const legalEntityMap = useMemo(() => {
const map = new Map<number, string>();
for (const query of legalEntityQueries) {
if (query.data?.tasks) {
for (const task of query.data.tasks) {
map.set(task.id, task.title);
}
}
}
if (process.env.NODE_ENV === 'development') {
console.log('[TaskRegistry] legalEntityMap size:', map.size, 'formIds:', legalEntityFormIds);
}
return map;
}, [legalEntityQueries, legalEntityFormIds]);
// Инициализация видимых колонок с учетом кастомных полей
const storageKey = `registry-columns-${formId}`;
const getDefaultColumns = () => {
const defaultColumns: { [key: string]: boolean } = {
title: true,
status: true,
assignedTo: true,
createdAt: true,
dueDate: true,
description: false,
updatedAt: false,
createdBy: false,
completedAt: false,
};
fields.forEach(field => {
defaultColumns[`field_${field.id}`] = false;
});
return defaultColumns;
};
const getDefaultColumnOrder = (): string[] => {
const order = [
'title',
'status',
'assignedTo',
'createdAt',
'dueDate',
'description',
'updatedAt',
'createdBy',
'completedAt',
];
fields.forEach(field => {
order.push(`field_${field.id}`);
});
return order;
};
const loadColumns = () => {
const defaults = getDefaultColumns();
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved) as { [key: string]: boolean };
return { ...defaults, ...parsed };
}
} catch { /* ignore */ }
return defaults;
};
const loadColumnOrder = (): string[] => {
try {
const saved = localStorage.getItem(`${storageKey}-order`);
if (saved) {
const parsed = JSON.parse(saved) as string[];
const defaults = getDefaultColumnOrder();
// Добавляем новые поля в конец, сохраняя существующий порядок
const merged = [...parsed];
defaults.forEach(key => {
if (!merged.includes(key)) merged.push(key);
});
return merged;
}
} catch { /* ignore */ }
return getDefaultColumnOrder();
};
const [visibleColumns, setVisibleColumns] = useState<{ [key: string]: boolean }>(loadColumns);
const [columnOrder, setColumnOrder] = useState<string[]>(loadColumnOrder);
// searches приходит как prop из TaskView (синхронизирован с URL)
const [inputValue, setInputValue] = useState('');
const [showCompleted, setShowCompleted] = useState(() => {
try { return JSON.parse(localStorage.getItem(`registry-completed-${formId}`) || 'false'); } catch { return false; }
});
const formatDate = (dateString: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
// Сохраняем настройки при любом изменении
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify(visibleColumns)); } catch { /* ignore */ }
}, [visibleColumns, storageKey]);
useEffect(() => {
try { localStorage.setItem(`${storageKey}-order`, JSON.stringify(columnOrder)); } catch { /* ignore */ }
}, [columnOrder, storageKey]);
useEffect(() => {
try { localStorage.setItem(`registry-completed-${formId}`, JSON.stringify(showCompleted)); } catch { /* ignore */ }
}, [showCompleted, formId]);
// При появлении новых полей добавляем их в состояние (не затирая сохранённые)
useEffect(() => {
setVisibleColumns(prev => {
const updated = { ...prev };
fields.forEach(field => {
const key = `field_${field.id}`;
if (!(key in updated)) updated[key] = false;
});
return updated;
});
setColumnOrder(prev => {
const updated = [...prev];
fields.forEach(field => {
const key = `field_${field.id}`;
if (!updated.includes(key)) updated.push(key);
});
return updated;
});
}, [fields]);
// DND для настройки порядка колонок
const columnSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleColumnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setColumnOrder(prev => {
const oldIndex = prev.indexOf(active.id as string);
const newIndex = prev.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
};
// Создаем карты для быстрого поиска
const statusMap = useMemo(() => {
const map = new Map();
statuses.forEach(status => map.set(status.id, status));
return map;
}, [statuses]);
// Проверяет, содержит ли задача заданный поисковый запрос
const taskMatchesSearch = useCallback((task: TaskData, qLower: string): boolean => {
if (task.title?.toLowerCase().includes(qLower)) return true;
if (task.description?.toLowerCase().includes(qLower)) return true;
const status = statusMap.get(task.currentStatusId);
if (status?.name?.toLowerCase().includes(qLower)) return true;
if (task.assignees && task.assignees.length > 0) {
const assigneeNames = task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(' ');
if (assigneeNames.toLowerCase().includes(qLower)) return true;
} else if (task.assignedTo) {
if (getUserName(task.assignedTo).toLowerCase().includes(qLower)) return true;
}
if (task.createdAt && formatDate(task.createdAt).includes(qLower)) return true;
if (task.dueDate && formatDate(task.dueDate).includes(qLower)) return true;
if (task.fieldValues) {
for (const [fieldIdStr, val] of Object.entries(task.fieldValues)) {
const fieldId = parseInt(fieldIdStr);
const field = fields.find(f => f.id === fieldId);
if (field?.type === 'contract-number') {
let parsed: any;
try {
parsed = typeof val === 'string' ? JSON.parse(val) : val;
} catch {
parsed = null;
}
const num = parsed?.value ? String(parsed.value) : '';
const entityTitle = parsed?.legalEntityTaskId ? legalEntityMap.get(Number(parsed.legalEntityTaskId)) : undefined;
const display = entityTitle ? `${num} — ${entityTitle}` : num;
if (display.toLowerCase().includes(qLower)) return true;
} else if (field?.type === 'task') {
const display = displayFieldValue(field, val, displayContext);
if (display.toLowerCase().includes(qLower)) return true;
} else {
const str = Array.isArray(val)
? (val as Record<string, unknown>[]).map(f => f?.name ?? '').join(' ')
: String(val ?? '');
if (str.toLowerCase().includes(qLower)) return true;
}
}
}
return false;
}, [statusMap, fields, legalEntityMap, getUserName, displayContext]);
// Клиентский поиск + скрытие завершённых поверх серверной фильтрации
const filteredTasks = useMemo(() => {
return tasks.filter(task => {
const status = statusMap.get(task.currentStatusId);
const isCompleted = task.isCompleted || status?.isFinal || false;
if (!showCompleted && isCompleted) return false;
// Проверяем зафиксированные поисковые термы (badges)
for (const q of searches) {
const qLower = q.toLowerCase().trim();
if (!qLower) continue;
if (!taskMatchesSearch(task, qLower)) return false;
}
// Проверяем текущее значение input в реальном времени
const inputLower = inputValue.toLowerCase().trim();
if (inputLower && !taskMatchesSearch(task, inputLower)) return false;
return true;
});
}, [tasks, searches, inputValue, showCompleted, statusMap, taskMatchesSearch]);
const fetchTaskColumnSuggestions = useCallback((column: string) => async (search: string): Promise<string[]> => {
const params = new URLSearchParams({ column, search });
const context = Object.fromEntries(Object.entries(filters).filter(([k, v]) => k !== column && !!v));
if (Object.keys(context).length > 0) params.set('context', JSON.stringify(context));
const res = await apiRequest('GET', `/api/forms/${formId}/tasks/column-values?${params.toString()}`);
const data = await res.json();
const values: string[] = data.values || [];
// Если завершённые задачи скрыты, убираем финальные статусы из подсказок фильтра
if (column === 'status' && !showCompleted) {
const finalStatusNames = new Set(statuses.filter(s => s.isFinal).map(s => s.name));
return values.filter(value => !finalStatusNames.has(value));
}
return values;
}, [formId, filters, showCompleted, statuses]);
const taskColumnLabels: Record<string, string> = useMemo(() => {
const base: Record<string, string> = {
title: 'Название',
status: 'Статус',
currentStatusId: 'Статус',
assignedTo: 'Ответственный',
dueDate: 'Срок',
createdAt: 'Создана',
updatedAt: 'Обновлена',
createdBy: 'Автор',
overdue: 'Просрочено',
};
fields.forEach(f => { base[`field_${f.id}`] = f.name; });
return base;
}, [fields]);
const toggleColumnVisibility = (column: string) => {
setVisibleColumns(prev => ({
...prev,
[column]: !prev[column as keyof typeof prev]
}));
};
const getStatusBadge = (statusId: number) => {
const status = statusMap.get(statusId);
if (!status) return <Badge variant="secondary">Неизвестно</Badge>;
return (
<Badge
style={{ backgroundColor: status.color, color: '#fff' }}
data-testid={`badge-status-${statusId}`}
>
{status.name}
</Badge>
);
};
// Дебаг: логируем состояние поиска по юрлицам
useEffect(() => {
if (process.env.NODE_ENV === 'development' && searches.length > 0) {
console.log('[TaskRegistry] Searches:', searches, 'filtered:', filteredTasks.length, 'legalEntityMap:', legalEntityMap.size, 'contractFields:', contractNumberFields.length);
}
}, [searches, filteredTasks.length, legalEntityMap.size, contractNumberFields.length]);
// Уведомляем родительский компонент о количестве отфильтрованных задач
useEffect(() => {
onFilteredCountChange?.(filteredTasks.length);
}, [filteredTasks.length, onFilteredCountChange]);
const handleQuickToggle = async (taskId: number, fieldId: number, currentValue: unknown) => {
const newVal = !(currentValue === true || currentValue === 'true');
try {
await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value: newVal });
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
} catch (err: unknown) {
if (err instanceof OfflineQueuedError) {
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
toast({ title: 'Изменения сохранены локально', description: 'Отправим при появлении сети' });
return;
}
const msg = err instanceof Error ? err.message : 'Ошибка сохранения';
toast({ title: 'Ошибка', description: msg, variant: 'destructive' });
}
};
const handleRowClick = (task: TaskData, e: React.MouseEvent) => {
if (!isAdmin || !onToggleSelected) {
onTaskClick?.(task);
return;
}
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (isShift && lastSelectedTaskId != null) {
e.preventDefault();
const ids = filteredTasks.map(t => t.id);
const start = ids.indexOf(lastSelectedTaskId);
const end = ids.indexOf(task.id);
if (start !== -1 && end !== -1) {
const range = ids.slice(Math.min(start, end), Math.max(start, end) + 1);
onSelectRange?.(range);
}
setLastSelectedTaskId(task.id);
return;
}
if (isCtrl) {
e.preventDefault();
onToggleSelected(task.id);
setLastSelectedTaskId(task.id);
return;
}
// Обычный клик по строке открывает задачу
onTaskClick?.(task);
};
const isEditing = (taskId: number, columnKey: string) =>
editingCell?.taskId === taskId && editingCell?.columnKey === columnKey;
const getColumnLabel = (key: string): string => {
if (key === 'title') return 'Название';
if (key === 'status') return 'Статус';
if (key === 'assignedTo') return 'Ответственный';
if (key === 'createdAt') return 'Создана';
if (key === 'dueDate') return 'Срок';
if (key === 'description') return 'Описание';
if (key === 'updatedAt') return 'Обновлена';
if (key === 'createdBy') return 'Автор';
if (key === 'completedAt') return 'Завершена';
if (key.startsWith('field_')) {
const fieldId = parseInt(key.replace('field_', ''));
const field = fields.find(f => f.id === fieldId);
return field ? field.name : 'Неизвестное поле';
}
return key;
};
return (
<div className="h-full flex flex-col">
{/* Панель управления + активные фильтры (один ряд) */}
<div className="flex items-center gap-2 mb-1">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-5 w-5 shrink-0 rounded-full" title="Настроить колонки" data-testid="button-column-settings">
<Settings2 className="w-3 h-3" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Настройки реестра</DialogTitle>
<DialogDescription>
Отображение колонок и задач
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Показать завершённые */}
<div className="flex items-center space-x-2 pb-2 border-b">
<Checkbox
id="show-completed"
checked={showCompleted}
onCheckedChange={v => setShowCompleted(Boolean(v))}
/>
<Label htmlFor="show-completed">Показать завершённые</Label>
</div>
<DndContext sensors={columnSensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
<div className="space-y-1 max-h-[50vh] overflow-y-auto pr-1">
{columnOrder
.filter(key => key in visibleColumns)
.map(key => (
<SortableColumnItem
key={key}
id={key}
label={getColumnLabel(key)}
checked={visibleColumns[key]}
onCheckedChange={() => toggleColumnVisibility(key)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</DialogContent>
</Dialog>
{/* Глобальный поиск по всем колонкам (многоступенчатый) */}
<div className="relative flex items-center gap-1.5 flex-wrap">
<div className="relative flex items-center">
<Search className="absolute left-2.5 w-3 h-3 text-muted-foreground pointer-events-none" />
<Input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
if (inputValue.trim()) {
onAddSearch(inputValue.trim());
setInputValue('');
}
}
}}
placeholder="Поиск..."
className="h-5 pl-6 pr-5 text-xs rounded-full border-border/60 w-36 focus:w-52 transition-all duration-200"
/>
{inputValue && (
<button
onClick={() => setInputValue('')}
className="absolute right-1.5 text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon className="w-3 h-3" />
</button>
)}
</div>
{searches.map((term, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs border border-primary/20">
<span className="font-medium">Поиск:</span>
<span className="max-w-[100px] truncate">{term}</span>
<button
onClick={() => onRemoveSearch(i)}
className="ml-0.5 hover:text-primary/60 transition-colors"
title="Убрать поиск"
>
<XIcon className="w-3 h-3" />
</button>
</span>
))}
</div>
{hasActiveFilters && (
<ActiveFilterBadges
filters={filters}
sort={sort}
columnLabels={taskColumnLabels}
onRemoveFilter={clearFilter}
onRemoveSort={clearSort}
onClearAll={clearAll}
className="py-0"
/>
)}
</div>
{/* Таблица задач */}
<div className="flex-1 overflow-auto">
{filteredTasks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{tasks.length === 0 ? (
<div>
<p className="font-medium">Нет задач</p>
<p className="text-sm mt-1">Создайте первую задачу чтобы начать работу</p>
</div>
) : (
<div>
<p className="font-medium">Не найдено задач</p>
<p className="text-sm mt-1">Попробуйте изменить условия поиска или фильтры</p>
</div>
)}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{isAdmin && (
<TableHead className="w-8 px-2">
<Checkbox
checked={filteredTasks.length > 0 && filteredTasks.every(t => selectedTaskIds?.has(t.id))}
data-state={
filteredTasks.length > 0 &&
filteredTasks.some(t => selectedTaskIds?.has(t.id)) &&
!filteredTasks.every(t => selectedTaskIds?.has(t.id))
? 'indeterminate'
: (filteredTasks.length > 0 && filteredTasks.every(t => selectedTaskIds?.has(t.id)) ? 'checked' : 'unchecked')
}
onCheckedChange={() => onToggleSelectAll?.()}
aria-label="Выделить все видимые задачи"
/>
</TableHead>
)}
{columnOrder.filter(key => visibleColumns[key]).map(key => {
if (key === 'title') {
return (
<TableHead key={key}>
<ColumnFilter column="title" label="Название" filterValue={filters['title'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('title')} />
</TableHead>
);
}
if (key === 'status') {
return (
<TableHead key={key}>
<ColumnFilter column="status" label="Статус" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('status')} />
</TableHead>
);
}
if (key === 'assignedTo') {
return (
<TableHead key={key}>
<ColumnFilter column="assignedTo" label="Ответственный" filterValue={filters['assignedTo'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('assignedTo')} />
</TableHead>
);
}
if (key === 'createdAt') {
return (
<TableHead key={key}>
<ColumnFilter column="createdAt" label="Создана" filterValue={filters['createdAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdAt')} />
</TableHead>
);
}
if (key === 'dueDate') {
return (
<TableHead key={key}>
<ColumnFilter column="dueDate" label="Срок" filterValue={filters['dueDate'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('dueDate')} />
</TableHead>
);
}
if (key === 'description') {
return <TableHead key={key}>Описание</TableHead>;
}
if (key === 'updatedAt') {
return (
<TableHead key={key}>
<ColumnFilter column="updatedAt" label="Обновлена" filterValue={filters['updatedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('updatedAt')} />
</TableHead>
);
}
if (key === 'createdBy') {
return (
<TableHead key={key}>
<ColumnFilter column="createdBy" label="Автор" filterValue={filters['createdBy'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdBy')} />
</TableHead>
);
}
if (key === 'completedAt') {
return (
<TableHead key={key}>
<ColumnFilter column="completedAt" label="Завершена" filterValue={filters['completedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('completedAt')} />
</TableHead>
);
}
if (key.startsWith('field_')) {
const fieldId = parseInt(key.replace('field_', ''));
const field = fields.find(f => f.id === fieldId);
return field ? (
<TableHead key={key}>
<ColumnFilter column={key} label={field.name} filterValue={filters[key] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions(key)} />
</TableHead>
) : null;
}
return null;
})}
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks.map((task) => {
const taskStatus = statusMap.get(task.currentStatusId);
const isCompleted = task.isCompleted || taskStatus?.isFinal || false;
const isOverdue = !isCompleted && !!task.dueDate && new Date(task.dueDate) < new Date();
return (
<TableRow
key={task.id}
data-testid={`row-task-${task.id}`}
className={`${isCompleted ? 'bg-gray-100 dark:bg-gray-900 opacity-70' : ''} ${isOverdue ? 'border-l-2 border-l-red-500 bg-red-50 dark:bg-red-950/20' : ''} ${isAdmin ? 'cursor-pointer' : ''}`}
onClick={(e) => handleRowClick(task, e)}
>
{isAdmin && (
<TableCell className="py-1 px-2 w-8" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedTaskIds?.has(task.id) ?? false}
onCheckedChange={() => onToggleSelected?.(task.id)}
onClick={(e) => e.stopPropagation()}
aria-label="Выделить задачу"
/>
</TableCell>
)}
{columnOrder.filter(key => visibleColumns[key]).map(key => {
if (key === 'title') {
return (
<TableCell key={key} className="py-1 font-medium max-w-xs">
<div>
<button
className={`font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left truncate block w-full ${isCompleted ? 'line-through text-gray-500 dark:text-gray-400' : ''}`}
title={task.title}
data-testid={`button-task-title-${task.id}`}
onClick={() => onTaskClick?.(task)}
>
{task.title}
</button>
{task.description && (
<p className={`text-xs text-muted-foreground truncate mt-0.5 ${isCompleted ? 'line-through' : ''}`} title={task.description}>
{task.description}
</p>
)}
</div>
</TableCell>
);
}
if (key === 'status') {
return <TableCell key={key} className="py-1">{getStatusBadge(task.currentStatusId)}</TableCell>;
}
if (key === 'assignedTo') {
return (
<TableCell key={key} className="py-1">
{isEditing(task.id, 'assignedTo') ? (
<RegistryCellEditor
taskId={task.id}
systemField="assignedTo"
value={task.assignedTo}
users={users}
formId={formId}
onSaved={() => setEditingCell(null)}
onCancel={() => setEditingCell(null)}
/>
) : (
<div
className="cursor-pointer"
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: 'assignedTo' })}
>
{(task.assignees && task.assignees.length > 0) ? (
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">
{task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
</span>
</div>
) : task.assignedTo ? (
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{getUserName(task.assignedTo)}</span>
</div>
) : (
<span className="text-muted-foreground text-sm">—</span>
)}
</div>
)}
</TableCell>
);
}
if (key === 'createdAt') {
return (
<TableCell key={key} className="py-1">
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{formatDate(task.createdAt)}</span>
</div>
</TableCell>
);
}
if (key === 'dueDate') {
return (
<TableCell key={key} className="py-1">
{task.dueDate ? (
(() => {
const overdue = !isCompleted && new Date(task.dueDate) < new Date();
return (
<div className={`flex items-center ${overdue ? 'text-red-600 dark:text-red-500' : ''}`}>
<Calendar className={`w-3 h-3 mr-1 ${overdue ? 'text-red-600 dark:text-red-500' : 'text-orange-500'}`} />
<span className="text-sm">{formatDate(task.dueDate)}</span>
{overdue && <AlertCircle className="w-3 h-3 ml-1" />}
</div>
);
})()
) : (
<span className="text-muted-foreground text-sm">—</span>
)}
</TableCell>
);
}
if (key === 'description') {
return (
<TableCell key={key} className="py-2 max-w-sm">
{task.description ? (
<p className="text-sm truncate" title={task.description}>
{task.description}
</p>
) : (
<span className="text-muted-foreground text-sm">—</span>
)}
</TableCell>
);
}
if (key === 'updatedAt') {
return (
<TableCell key={key} className="py-2">
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{formatDate(task.updatedAt)}</span>
</div>
</TableCell>
);
}
if (key === 'createdBy') {
return (
<TableCell key={key} className="py-1">
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{getUserName(task.createdBy)}</span>
</div>
</TableCell>
);
}
if (key === 'completedAt') {
return task.completedAt ? (
<TableCell key={key} className="py-1">
<div className="flex items-center">
<CheckCircle2 className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{formatDate(task.completedAt)}</span>
</div>
</TableCell>
) : (
<TableCell key={key} className="py-1" />
);
}
if (key.startsWith('field_')) {
const fieldId = parseInt(key.replace('field_', ''));
const field = fields.find(f => f.id === fieldId);
const rawValue = task.fieldValues?.[fieldId];
return field ? (
<TableCell key={key} className="py-2">
{isEditing(task.id, key) ? (
<RegistryCellEditor
taskId={task.id}
field={field}
value={rawValue}
users={users}
roles={roles}
formId={formId}
onSaved={() => setEditingCell(null)}
onCancel={() => setEditingCell(null)}
/>
) : field.type === 'contract-number' ? (
<span className="text-sm">
<ContractNumberDisplay
value={rawValue}
legalEntityFormId={(field.options as any)?.contractNumber?.legalEntityFormId}
/>
</span>
) : field.type === 'file' ? (
<FileFieldCell value={rawValue} />
) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (
<button
type="button"
className="text-sm cursor-pointer hover:text-primary"
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
>
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</button>
) : REGISTRY_INLINE_TYPES.has(field.type) ? (
<div
className="cursor-pointer"
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
>
<span className="text-sm">
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
</div>
) : (
<span className="text-sm">
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
)}
</TableCell>
) : null;
}
return null;
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
};
export default TaskRegistry;