Add progress bar and cancel option to bulk meeting duplication

Integrates a progress bar and cancel functionality into the bulk duplicate process for executive meetings, migrating from `Promise.allSettled` to a sequential loop to prevent race conditions and updating UI components and localization files.
This commit is contained in:
Riyadh
2026-05-05 07:22:12 +00:00
parent 96cfea312c
commit 0338e22df8
3 changed files with 208 additions and 94 deletions
+1
View File
@@ -1211,6 +1211,7 @@
"bulkDuplicateTitle": "تكرار {{n}} اجتماع إلى تاريخ",
"bulkDuplicateConfirm": "تكرار",
"bulkDuplicating": "جارٍ التكرار…",
"bulkDuplicateProgress": "جارٍ التكرار... {{current}} من {{total}} ({{percent}}%)",
"bulkDuplicateResultAll": "تم تكرار {{n}} اجتماع",
"bulkDuplicatePartial": "تم تكرار {{ok}} من {{total}} وفشل {{failed}}",
"bulkDuplicateNone": "تعذّر تكرار الاجتماعات المحددة"
+1
View File
@@ -1094,6 +1094,7 @@
"bulkDuplicateTitle": "Duplicate {{n}} meetings to date",
"bulkDuplicateConfirm": "Duplicate",
"bulkDuplicating": "Duplicating…",
"bulkDuplicateProgress": "Duplicating... {{current}} of {{total}} ({{percent}}%)",
"bulkDuplicateResultAll": "Duplicated {{n}} meetings",
"bulkDuplicatePartial": "Duplicated {{ok}} of {{total}}, {{failed}} failed",
"bulkDuplicateNone": "Could not duplicate the selected meetings"
+142 -30
View File
@@ -83,6 +83,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Progress } from "@/components/ui/progress";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { useToast } from "@/hooks/use-toast";
@@ -1750,6 +1751,14 @@ function ScheduleSection({
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
const [bulkDuplicating, setBulkDuplicating] = useState(false);
// Sequential bulk-duplicate progress to avoid the parallel
// race that produced 409s on shared dailyNumber slots. Tracks
// `{current,total}` so the dialog can render a progress bar
// and percentage instead of a static "Duplicating…" label.
const [bulkDuplicateProgress, setBulkDuplicateProgress] = useState<
{ current: number; total: number } | null
>(null);
const bulkDuplicateCancelRef = useRef(false);
const openBulkDuplicate = useCallback(() => {
setBulkDuplicateDate(date);
setBulkDuplicateOpen(true);
@@ -1766,19 +1775,31 @@ function ScheduleSection({
return;
}
setBulkDuplicating(true);
bulkDuplicateCancelRef.current = false;
setBulkDuplicateProgress({ current: 0, total: ids.length });
let ok = 0;
let failed = 0;
try {
// Mirror the bulk-delete pattern: allSettled so one failure
// doesn't abort the rest, then aggregate into a single toast.
const results = await Promise.allSettled(
ids.map((id) =>
apiJson(`/api/executive-meetings/${id}/duplicate`, {
// Sequential loop (was Promise.allSettled). The server's
// /duplicate endpoint reads nextDailyNumber then inserts;
// running 10 in parallel made all 10 read the same number,
// and the unique (date, dailyNumber) constraint collapsed
// most into 409s. One-at-a-time avoids the race entirely.
for (const id of ids) {
if (bulkDuplicateCancelRef.current) break;
try {
await apiJson(`/api/executive-meetings/${id}/duplicate`, {
method: "POST",
body: { targetDate: bulkDuplicateDate },
}),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
});
ok++;
} catch {
failed++;
}
setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
}
const total = ok + failed;
if (total > 0) {
if (failed === 0) {
toast({
title: t(
@@ -1794,11 +1815,12 @@ function ScheduleSection({
toast({
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{total}}", String(total))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
}
setSelectedMeetingIds(new Set());
setBulkDuplicateOpen(false);
// If the new rows landed on the day we're viewing, refresh in
@@ -1811,6 +1833,8 @@ function ScheduleSection({
}
} finally {
setBulkDuplicating(false);
setBulkDuplicateProgress(null);
bulkDuplicateCancelRef.current = false;
}
}, [
bulkDuplicating,
@@ -2826,25 +2850,60 @@ function ScheduleSection({
data-testid="em-bulk-duplicate-date"
/>
</FormRow>
{bulkDuplicating && bulkDuplicateProgress ? (
<div className="space-y-2 pt-2">
<div className="text-sm text-muted-foreground text-center">
{t("executiveMeetings.schedule.bulkDuplicateProgress")
.replace("{{current}}", String(bulkDuplicateProgress.current))
.replace("{{total}}", String(bulkDuplicateProgress.total))
.replace(
"{{percent}}",
String(
Math.round(
(bulkDuplicateProgress.current /
Math.max(bulkDuplicateProgress.total, 1)) *
100,
),
),
)}
</div>
<Progress
value={
(bulkDuplicateProgress.current /
Math.max(bulkDuplicateProgress.total, 1)) *
100
}
/>
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
bulkDuplicateCancelRef.current = true;
}}
>
{t("executiveMeetings.common.cancel")}
</Button>
</div>
</div>
) : (
<DialogFooter>
<Button
variant="ghost"
onClick={() => setBulkDuplicateOpen(false)}
disabled={bulkDuplicating}
>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={() => void performBulkDuplicate()}
disabled={bulkDuplicating || !bulkDuplicateDate}
disabled={!bulkDuplicateDate}
className="bg-[#0B1E3F]"
data-testid="em-bulk-duplicate-confirm"
>
{bulkDuplicating
? t("executiveMeetings.schedule.bulkDuplicating")
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
{t("executiveMeetings.schedule.bulkDuplicateConfirm")}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</div>
@@ -5313,6 +5372,12 @@ function ManageSection({
const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false);
const [bulkDuplicateDate, setBulkDuplicateDate] = useState<string>(date);
const [bulkDuplicating, setBulkDuplicating] = useState(false);
// See ScheduleSection's performBulkDuplicate comment for the
// race rationale — same fix applied here in ManageSection.
const [bulkDuplicateProgress, setBulkDuplicateProgress] = useState<
{ current: number; total: number } | null
>(null);
const bulkDuplicateCancelRef = useRef(false);
const openBulkDuplicate = useCallback(() => {
setBulkDuplicateDate(date);
setBulkDuplicateOpen(true);
@@ -5329,17 +5394,26 @@ function ManageSection({
return;
}
setBulkDuplicating(true);
bulkDuplicateCancelRef.current = false;
setBulkDuplicateProgress({ 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}/duplicate`, {
for (const id of ids) {
if (bulkDuplicateCancelRef.current) break;
try {
await apiJson(`/api/executive-meetings/${id}/duplicate`, {
method: "POST",
body: { targetDate: bulkDuplicateDate },
}),
),
);
const ok = results.filter((r) => r.status === "fulfilled").length;
const failed = results.length - ok;
});
ok++;
} catch {
failed++;
}
setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
}
const total = ok + failed;
if (total > 0) {
if (failed === 0) {
toast({
title: t(
@@ -5355,11 +5429,12 @@ function ManageSection({
toast({
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
.replace("{{ok}}", String(ok))
.replace("{{total}}", String(results.length))
.replace("{{total}}", String(total))
.replace("{{failed}}", String(failed)),
variant: "destructive",
});
}
}
setSelectedMeetingIds(new Set());
setBulkDuplicateOpen(false);
if (bulkDuplicateDate === date) {
@@ -5371,6 +5446,8 @@ function ManageSection({
}
} finally {
setBulkDuplicating(false);
setBulkDuplicateProgress(null);
bulkDuplicateCancelRef.current = false;
}
}, [
bulkDuplicating,
@@ -5979,25 +6056,60 @@ function ManageSection({
data-testid="em-manage-bulk-duplicate-date"
/>
</FormRow>
{bulkDuplicating && bulkDuplicateProgress ? (
<div className="space-y-2 pt-2">
<div className="text-sm text-muted-foreground text-center">
{t("executiveMeetings.schedule.bulkDuplicateProgress")
.replace("{{current}}", String(bulkDuplicateProgress.current))
.replace("{{total}}", String(bulkDuplicateProgress.total))
.replace(
"{{percent}}",
String(
Math.round(
(bulkDuplicateProgress.current /
Math.max(bulkDuplicateProgress.total, 1)) *
100,
),
),
)}
</div>
<Progress
value={
(bulkDuplicateProgress.current /
Math.max(bulkDuplicateProgress.total, 1)) *
100
}
/>
<div className="flex justify-end pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
bulkDuplicateCancelRef.current = true;
}}
>
{t("executiveMeetings.common.cancel")}
</Button>
</div>
</div>
) : (
<DialogFooter>
<Button
variant="ghost"
onClick={() => setBulkDuplicateOpen(false)}
disabled={bulkDuplicating}
>
{t("executiveMeetings.common.cancel")}
</Button>
<Button
onClick={() => void performBulkDuplicate()}
disabled={bulkDuplicating || !bulkDuplicateDate}
disabled={!bulkDuplicateDate}
className="bg-[#0B1E3F]"
data-testid="em-manage-bulk-duplicate-confirm"
>
{bulkDuplicating
? t("executiveMeetings.schedule.bulkDuplicating")
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
{t("executiveMeetings.schedule.bulkDuplicateConfirm")}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</div>