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