fix(registry): Ctrl/Cmd выделение не конфликтует с bulk-выбором; лимит bulk-delete 1000 с чанками по 100

This commit is contained in:
2026-07-08 15:27:13 +03:00
parent eec350c6da
commit 64ba1b3b66
3 changed files with 47 additions and 6 deletions

View File

@@ -618,6 +618,14 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
} }
}; };
const handleRowMouseDown = (e: React.MouseEvent) => {
if (!isAdmin) return;
// Предотвращаем браузерное выделение текста при Ctrl/Cmd/Shift + клике
if (e.ctrlKey || e.metaKey || e.shiftKey) {
e.preventDefault();
}
};
const handleRowClick = (task: TaskData, e: React.MouseEvent) => { const handleRowClick = (task: TaskData, e: React.MouseEvent) => {
if (!isAdmin || !onToggleSelected) { if (!isAdmin || !onToggleSelected) {
onTaskClick?.(task); onTaskClick?.(task);
@@ -896,6 +904,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
data-testid={`row-task-${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' : ''}`} 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)} onClick={(e) => handleRowClick(task, e)}
onMouseDown={handleRowMouseDown}
> >
{isAdmin && ( {isAdmin && (
<TableCell className="py-1 px-2 w-8" onClick={(e) => e.stopPropagation()}> <TableCell className="py-1 px-2 w-8" onClick={(e) => e.stopPropagation()}>

View File

@@ -76,6 +76,7 @@ const TaskView = () => {
const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false); const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false);
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<number>>(new Set()); const [selectedTaskIds, setSelectedTaskIds] = useState<Set<number>>(new Set());
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteProgress, setDeleteProgress] = useState<{ processed: number; total: number } | null>(null);
// Предзаполнение полей из URL (например, ?prefill_field_3=123 для QR-кодов машин) // Предзаполнение полей из URL (например, ?prefill_field_3=123 для QR-кодов машин)
// Флаг ref, чтобы автооткрытие произошло только один раз при первоначальной загрузке страницы. // Флаг ref, чтобы автооткрытие произошло только один раз при первоначальной загрузке страницы.
@@ -243,19 +244,43 @@ const TaskView = () => {
}); });
// Массовое удаление задач (только для администратора приложения) // Массовое удаление задач (только для администратора приложения)
const BULK_DELETE_CHUNK_SIZE = 100;
const bulkDeleteMutation = useMutation({ const bulkDeleteMutation = useMutation({
mutationFn: async (taskIds: number[]) => { mutationFn: async (taskIds: number[]) => {
const res = await apiRequest('DELETE', '/api/tasks/bulk', { taskIds }); setDeleteProgress({ processed: 0, total: taskIds.length });
if (!res.ok) throw new Error('Ошибка при удалении задач'); let totalDeleted = 0;
return res.json() as Promise<{ success: boolean; deleted: number; total: number }>; let totalErrors = 0;
const chunks: number[][] = [];
for (let i = 0; i < taskIds.length; i += BULK_DELETE_CHUNK_SIZE) {
chunks.push(taskIds.slice(i, i + BULK_DELETE_CHUNK_SIZE));
}
let processed = 0;
for (const chunk of chunks) {
const res = await apiRequest('DELETE', '/api/tasks/bulk', { taskIds: chunk });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Ошибка при удалении пакета задач`);
}
const data = await res.json() as { success: boolean; deleted: number; total: number; errors?: Array<{ taskId: number; error: string }> };
totalDeleted += data.deleted ?? 0;
totalErrors += data.errors?.length ?? 0;
processed += chunk.length;
setDeleteProgress({ processed, total: taskIds.length });
}
return { deleted: totalDeleted, total: taskIds.length, errors: totalErrors };
}, },
onSuccess: (data) => { onSuccess: (data) => {
setDeleteProgress(null);
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
setSelectedTaskIds(new Set()); setSelectedTaskIds(new Set());
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
toast({ title: 'Удалено', description: `Удалено задач: ${data.deleted}` }); const desc = data.errors > 0
? `Удалено задач: ${data.deleted}, ошибок: ${data.errors}`
: `Удалено задач: ${data.deleted}`;
toast({ title: 'Удалено', description: desc });
}, },
onError: (err: any) => { onError: (err: any) => {
setDeleteProgress(null);
toast({ title: 'Ошибка', description: err.message, variant: 'destructive' }); toast({ title: 'Ошибка', description: err.message, variant: 'destructive' });
}, },
}); });
@@ -533,6 +558,11 @@ const TaskView = () => {
<DialogTitle className="text-sm">Удалить задачи</DialogTitle> <DialogTitle className="text-sm">Удалить задачи</DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
Будет удалено {selectedTaskIds.size} задач. Это действие нельзя отменить. Будет удалено {selectedTaskIds.size} задач. Это действие нельзя отменить.
{selectedTaskIds.size > BULK_DELETE_CHUNK_SIZE && (
<span className="block mt-1 text-muted-foreground">
Будет разбито на пакеты по {BULK_DELETE_CHUNK_SIZE} задач.
</span>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
@@ -552,7 +582,9 @@ const TaskView = () => {
disabled={bulkDeleteMutation.isPending} disabled={bulkDeleteMutation.isPending}
onClick={() => bulkDeleteMutation.mutate(Array.from(selectedTaskIds))} onClick={() => bulkDeleteMutation.mutate(Array.from(selectedTaskIds))}
> >
{bulkDeleteMutation.isPending ? 'Удаление...' : `Удалить ${selectedTaskIds.size}`} {bulkDeleteMutation.isPending && deleteProgress
? `Удаление ${deleteProgress.processed}/${deleteProgress.total}...`
: `Удалить ${selectedTaskIds.size}`}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -9,7 +9,7 @@ import type { ReminderRecipient } from '@shared/schema';
const taskBulkRouter = Router(); const taskBulkRouter = Router();
const MAX_BULK_TASKS = 100; const MAX_BULK_TASKS = 1000;
function validateTaskIds(body: unknown): number[] | null { function validateTaskIds(body: unknown): number[] | null {
if (!body || typeof body !== 'object') return null; if (!body || typeof body !== 'object') return null;