diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 67ef5e31..5ed9626a 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "ابحث في اجتماعات اليوم", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d8fe2fbb..398d2d28 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 27074aa9..2340986d 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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({ )} + + {/* Bulk-delete progress dialog. Opens once the user has + confirmed via the destructive confirm() modal and the + sequential DELETE loop is in flight. */} + { + /* Non-dismissible while running. */ + }} + > + + + + {t("executiveMeetings.schedule.bulkDeleteSelected")} + + + {bulkDeleteProgress && ( +
+
+ {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, + ), + ), + )} +
+ +
+ +
+
+ )} +
+
); } @@ -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(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({ )} + + {/* Bulk-delete progress dialog (ManageSection mirror). */} + { + /* Non-dismissible while running. */ + }} + > + + + + {t("executiveMeetings.schedule.bulkDeleteSelected")} + + + {bulkDeleteProgress && ( +
+
+ {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, + ), + ), + )} +
+ +
+ +
+
+ )} +
+
); } diff --git a/attached_assets/image_1777965794791.png b/attached_assets/image_1777965794791.png new file mode 100644 index 00000000..0b7c4ada Binary files /dev/null and b/attached_assets/image_1777965794791.png differ