fix(registry): Ctrl/Cmd выделение не конфликтует с bulk-выбором; лимит bulk-delete 1000 с чанками по 100
This commit is contained in:
@@ -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) => {
|
||||
if (!isAdmin || !onToggleSelected) {
|
||||
onTaskClick?.(task);
|
||||
@@ -896,6 +904,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
|
||||
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)}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
>
|
||||
{isAdmin && (
|
||||
<TableCell className="py-1 px-2 w-8" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -76,6 +76,7 @@ const TaskView = () => {
|
||||
const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false);
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<number>>(new Set());
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteProgress, setDeleteProgress] = useState<{ processed: number; total: number } | null>(null);
|
||||
|
||||
// Предзаполнение полей из URL (например, ?prefill_field_3=123 для QR-кодов машин)
|
||||
// Флаг ref, чтобы автооткрытие произошло только один раз при первоначальной загрузке страницы.
|
||||
@@ -243,19 +244,43 @@ const TaskView = () => {
|
||||
});
|
||||
|
||||
// Массовое удаление задач (только для администратора приложения)
|
||||
const BULK_DELETE_CHUNK_SIZE = 100;
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: async (taskIds: number[]) => {
|
||||
const res = await apiRequest('DELETE', '/api/tasks/bulk', { taskIds });
|
||||
if (!res.ok) throw new Error('Ошибка при удалении задач');
|
||||
return res.json() as Promise<{ success: boolean; deleted: number; total: number }>;
|
||||
setDeleteProgress({ processed: 0, total: taskIds.length });
|
||||
let totalDeleted = 0;
|
||||
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) => {
|
||||
setDeleteProgress(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] });
|
||||
setSelectedTaskIds(new Set());
|
||||
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) => {
|
||||
setDeleteProgress(null);
|
||||
toast({ title: 'Ошибка', description: err.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
@@ -533,6 +558,11 @@ const TaskView = () => {
|
||||
<DialogTitle className="text-sm">Удалить задачи</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Будет удалено {selectedTaskIds.size} задач. Это действие нельзя отменить.
|
||||
{selectedTaskIds.size > BULK_DELETE_CHUNK_SIZE && (
|
||||
<span className="block mt-1 text-muted-foreground">
|
||||
Будет разбито на пакеты по {BULK_DELETE_CHUNK_SIZE} задач.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
@@ -552,7 +582,9 @@ const TaskView = () => {
|
||||
disabled={bulkDeleteMutation.isPending}
|
||||
onClick={() => bulkDeleteMutation.mutate(Array.from(selectedTaskIds))}
|
||||
>
|
||||
{bulkDeleteMutation.isPending ? 'Удаление...' : `Удалить ${selectedTaskIds.size}`}
|
||||
{bulkDeleteMutation.isPending && deleteProgress
|
||||
? `Удаление ${deleteProgress.processed}/${deleteProgress.total}...`
|
||||
: `Удалить ${selectedTaskIds.size}`}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ReminderRecipient } from '@shared/schema';
|
||||
|
||||
const taskBulkRouter = Router();
|
||||
|
||||
const MAX_BULK_TASKS = 100;
|
||||
const MAX_BULK_TASKS = 1000;
|
||||
|
||||
function validateTaskIds(body: unknown): number[] | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
|
||||
Reference in New Issue
Block a user