Add progress tracking and cancel functionality to bulk delete

Update localization files and executive-meetings.tsx to implement sequential bulk deletion with progress tracking and cancellation, mirroring the UX of the bulk duplicate feature.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 3a577026-2b09-48ef-a405-e40e0b782577
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/UggHvbd
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-05-05 07:27:21 +00:00
parent 57e8ca848a
commit 7f5f9fd572
4 changed files with 193 additions and 45 deletions
+6 -5
View File
@@ -1195,13 +1195,14 @@
"bulkSelectionCount": "محدد: {{n}} من {{total}}",
"bulkClearSelection": "إلغاء التحديد",
"bulkDeleteSelected": "حذف المحدد",
"bulkDeleteConfirm": "حذف {{n}} اجتماعاً؟",
"bulkDeleteResultAll": "تم حذف {{n}} اجتماعاً",
"bulkDeletePartial": "تم حذف {{ok}} من {{total}} اجتماعاً، فشل {{failed}}",
"bulkDeleteConfirm": "حذف {{n}}؟",
"bulkDeleteResultAll": "تم حذف {{n}}",
"bulkDeletePartial": "تم حذف {{ok}} من {{total}}، فشل {{failed}}",
"bulkDeleteResultNone": "فشل حذف الاجتماعات المحددة",
"bulkDeleteUndo": "تراجع",
"bulkDeleteUndone": "تمت استعادة {{n}} اجتماعاً",
"bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}} اجتماعاً",
"bulkDeleteUndone": "تمت استعادة {{n}}",
"bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}}",
"bulkDeleteProgress": "جارٍ الحذف... {{current}} من {{total}} ({{percent}}%)",
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات",
"searchPlaceholder": "ابحث عن اجتماع…",
"searchAria": "ابحث في اجتماعات اليوم",
+1
View File
@@ -1086,6 +1086,7 @@
"bulkDeleteUndone": "Restored {{n}} meetings",
"bulkDeleteUndoPartial": "Restored {{ok}} of {{total}} meetings",
"bulkDeleteUndoFailed": "Could not restore meetings",
"bulkDeleteProgress": "Deleting... {{current}} of {{total}} ({{percent}}%)",
"searchPlaceholder": "Search a meeting…",
"searchAria": "Search today's meetings",
"searchClear": "Clear search",
+186 -40
View File
@@ -1742,6 +1742,11 @@ function ScheduleSection({
return n;
}, [displayedMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
// Sequential bulk-delete progress (mirrors duplicate UX).
const [bulkDeleteProgress, setBulkDeleteProgress] = useState<
{ current: number; total: number } | null
>(null);
const bulkDeleteCancelRef = useRef(false);
// #330: bulk-duplicate-to-date dialog state. `targetDate` defaults to
// the currently viewed `date` so the dialog opens on a sensible
// value; busy flag disables the confirm button while the per-row
@@ -1909,19 +1914,32 @@ function ScheduleSection({
const idsSet = new Set(ids);
const snapshots = meetings.filter((m) => idsSet.has(m.id));
setBulkDeleting(true);
bulkDeleteCancelRef.current = false;
setBulkDeleteProgress({ current: 0, total: ids.length });
// Sequential to mirror the duplicate UX (progress + cancel).
// DELETE has no race like duplicate, but consistent UX wins.
const okIds: number[] = [];
const failedIds: number[] = [];
try {
// Deliberately allSettled, not all: a single failed DELETE shouldn't
// skip the others. We aggregate the outcome into one toast so the
// user sees a single, actionable result instead of N toasts.
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
const okIds = ids.filter((_, idx) => results[idx].status === "fulfilled");
const okSnapshots = snapshots.filter((s) => okIds.includes(s.id));
for (const id of ids) {
if (bulkDeleteCancelRef.current) break;
try {
await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
okIds.push(id);
} catch {
failedIds.push(id);
}
setBulkDeleteProgress({
current: okIds.length + failedIds.length,
total: ids.length,
});
}
const ok = okIds.length;
const failed = failedIds.length;
const total = ok + failed;
if (total === 0) return;
const okIdsSet = new Set(okIds);
const okSnapshots = snapshots.filter((s) => okIdsSet.has(s.id));
// #199: guard against double-click — restore is non-idempotent.
let undoFired = false;
const undo = async () => {
@@ -1987,7 +2005,7 @@ function ScheduleSection({
handle = toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{total}}", String(total))
.replace("{{failed}}", String(failed)),
variant: "destructive",
action: undoAction,
@@ -1997,6 +2015,8 @@ function ScheduleSection({
refreshDay();
} finally {
setBulkDeleting(false);
setBulkDeleteProgress(null);
bulkDeleteCancelRef.current = false;
}
}, [
bulkDeleting,
@@ -2906,6 +2926,61 @@ function ScheduleSection({
)}
</DialogContent>
</Dialog>
{/* Bulk-delete progress dialog. Opens once the user has
confirmed via the destructive confirm() modal and the
sequential DELETE loop is in flight. */}
<Dialog
open={bulkDeleting && bulkDeleteProgress !== null}
onOpenChange={() => {
/* Non-dismissible while running. */
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</DialogTitle>
</DialogHeader>
{bulkDeleteProgress && (
<div className="space-y-2 pt-2">
<div className="text-sm text-muted-foreground text-center">
{t("executiveMeetings.schedule.bulkDeleteProgress")
.replace("{{current}}", String(bulkDeleteProgress.current))
.replace("{{total}}", String(bulkDeleteProgress.total))
.replace(
"{{percent}}",
String(
Math.round(
(bulkDeleteProgress.current /
Math.max(bulkDeleteProgress.total, 1)) *
100,
),
),
)}
</div>
<Progress
value={
(bulkDeleteProgress.current /
Math.max(bulkDeleteProgress.total, 1)) *
100
}
/>
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
bulkDeleteCancelRef.current = true;
}}
>
{t("executiveMeetings.common.cancel")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
@@ -5369,6 +5444,11 @@ function ManageSection({
return n;
}, [filteredMeetings, selectedMeetingIds]);
const [bulkDeleting, setBulkDeleting] = useState(false);
// Sequential bulk-delete progress (mirrors duplicate UX).
const [bulkDeleteProgress, setBulkDeleteProgress] = useState<
{ current: number; total: number } | null
>(null);
const bulkDeleteCancelRef = useRef(false);
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
const [bulkDuplicating, setBulkDuplicating] = useState(false);
@@ -5472,34 +5552,44 @@ function ManageSection({
).replace("{{n}}", String(ids.length));
if (!(await confirm({ message, destructive: true }))) return;
setBulkDeleting(true);
bulkDeleteCancelRef.current = false;
setBulkDeleteProgress({ current: 0, total: ids.length });
let ok = 0;
let failed = 0;
try {
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
if (failed === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
"{{n}}",
String(ok),
),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
for (const id of ids) {
if (bulkDeleteCancelRef.current) break;
try {
await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
ok++;
} catch {
failed++;
}
setBulkDeleteProgress({ current: ok + failed, total: ids.length });
}
const total = ok + failed;
if (total > 0) {
if (failed === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
"{{n}}",
String(ok),
),
});
} else if (ok === 0) {
toast({
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
variant: "destructive",
});
} else {
toast({
title: t("executiveMeetings.schedule.bulkDeletePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(total))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
}
setSelectedMeetingIds(new Set());
await qc.invalidateQueries({
@@ -5507,6 +5597,8 @@ function ManageSection({
});
} finally {
setBulkDeleting(false);
setBulkDeleteProgress(null);
bulkDeleteCancelRef.current = false;
}
}, [
bulkDeleting,
@@ -5514,6 +5606,7 @@ function ManageSection({
filteredMeetings,
qc,
date,
confirm,
toast,
t,
]);
@@ -6112,6 +6205,59 @@ function ManageSection({
)}
</DialogContent>
</Dialog>
{/* Bulk-delete progress dialog (ManageSection mirror). */}
<Dialog
open={bulkDeleting && bulkDeleteProgress !== null}
onOpenChange={() => {
/* Non-dismissible while running. */
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("executiveMeetings.schedule.bulkDeleteSelected")}
</DialogTitle>
</DialogHeader>
{bulkDeleteProgress && (
<div className="space-y-2 pt-2">
<div className="text-sm text-muted-foreground text-center">
{t("executiveMeetings.schedule.bulkDeleteProgress")
.replace("{{current}}", String(bulkDeleteProgress.current))
.replace("{{total}}", String(bulkDeleteProgress.total))
.replace(
"{{percent}}",
String(
Math.round(
(bulkDeleteProgress.current /
Math.max(bulkDeleteProgress.total, 1)) *
100,
),
),
)}
</div>
<Progress
value={
(bulkDeleteProgress.current /
Math.max(bulkDeleteProgress.total, 1)) *
100
}
/>
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
bulkDeleteCancelRef.current = true;
}}
>
{t("executiveMeetings.common.cancel")}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB