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:
@@ -1211,6 +1211,7 @@
|
||||
"bulkDuplicateTitle": "تكرار {{n}} اجتماع إلى تاريخ",
|
||||
"bulkDuplicateConfirm": "تكرار",
|
||||
"bulkDuplicating": "جارٍ التكرار…",
|
||||
"bulkDuplicateProgress": "جارٍ التكرار... {{current}} من {{total}} ({{percent}}%)",
|
||||
"bulkDuplicateResultAll": "تم تكرار {{n}} اجتماع",
|
||||
"bulkDuplicatePartial": "تم تكرار {{ok}} من {{total}} وفشل {{failed}}",
|
||||
"bulkDuplicateNone": "تعذّر تكرار الاجتماعات المحددة"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,38 +1775,51 @@ 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;
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||||
).replace("{{n}}", String(ok)),
|
||||
});
|
||||
} else if (ok === 0) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||||
.replace("{{ok}}", String(ok))
|
||||
.replace("{{total}}", String(results.length))
|
||||
.replace("{{failed}}", String(failed)),
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
ok++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
|
||||
}
|
||||
const total = ok + failed;
|
||||
if (total > 0) {
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||||
).replace("{{n}}", String(ok)),
|
||||
});
|
||||
} else if (ok === 0) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||||
.replace("{{ok}}", String(ok))
|
||||
.replace("{{total}}", String(total))
|
||||
.replace("{{failed}}", String(failed)),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
setSelectedMeetingIds(new Set());
|
||||
setBulkDuplicateOpen(false);
|
||||
@@ -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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setBulkDuplicateOpen(false)}
|
||||
disabled={bulkDuplicating}
|
||||
>
|
||||
{t("executiveMeetings.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void performBulkDuplicate()}
|
||||
disabled={bulkDuplicating || !bulkDuplicateDate}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-bulk-duplicate-confirm"
|
||||
>
|
||||
{bulkDuplicating
|
||||
? t("executiveMeetings.schedule.bulkDuplicating")
|
||||
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{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)}
|
||||
>
|
||||
{t("executiveMeetings.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void performBulkDuplicate()}
|
||||
disabled={!bulkDuplicateDate}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-bulk-duplicate-confirm"
|
||||
>
|
||||
{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,36 +5394,46 @@ 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;
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||||
).replace("{{n}}", String(ok)),
|
||||
});
|
||||
} else if (ok === 0) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||||
.replace("{{ok}}", String(ok))
|
||||
.replace("{{total}}", String(results.length))
|
||||
.replace("{{failed}}", String(failed)),
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
ok++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
setBulkDuplicateProgress({ current: ok + failed, total: ids.length });
|
||||
}
|
||||
const total = ok + failed;
|
||||
if (total > 0) {
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"executiveMeetings.schedule.bulkDuplicateResultAll",
|
||||
).replace("{{n}}", String(ok)),
|
||||
});
|
||||
} else if (ok === 0) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicateNone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDuplicatePartial")
|
||||
.replace("{{ok}}", String(ok))
|
||||
.replace("{{total}}", String(total))
|
||||
.replace("{{failed}}", String(failed)),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
setSelectedMeetingIds(new Set());
|
||||
setBulkDuplicateOpen(false);
|
||||
@@ -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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setBulkDuplicateOpen(false)}
|
||||
disabled={bulkDuplicating}
|
||||
>
|
||||
{t("executiveMeetings.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void performBulkDuplicate()}
|
||||
disabled={bulkDuplicating || !bulkDuplicateDate}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-manage-bulk-duplicate-confirm"
|
||||
>
|
||||
{bulkDuplicating
|
||||
? t("executiveMeetings.schedule.bulkDuplicating")
|
||||
: t("executiveMeetings.schedule.bulkDuplicateConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{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)}
|
||||
>
|
||||
{t("executiveMeetings.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void performBulkDuplicate()}
|
||||
disabled={!bulkDuplicateDate}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-manage-bulk-duplicate-confirm"
|
||||
>
|
||||
{t("executiveMeetings.schedule.bulkDuplicateConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user