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) => {
|
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()}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user