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:
@@ -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": "ابحث في اجتماعات اليوم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user