diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 16ff07f9..67ef5e31 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1211,6 +1211,7 @@ "bulkDuplicateTitle": "تكرار {{n}} اجتماع إلى تاريخ", "bulkDuplicateConfirm": "تكرار", "bulkDuplicating": "جارٍ التكرار…", + "bulkDuplicateProgress": "جارٍ التكرار... {{current}} من {{total}} ({{percent}}%)", "bulkDuplicateResultAll": "تم تكرار {{n}} اجتماع", "bulkDuplicatePartial": "تم تكرار {{ok}} من {{total}} وفشل {{failed}}", "bulkDuplicateNone": "تعذّر تكرار الاجتماعات المحددة" diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index f2bdcafd..d8fe2fbb 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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" diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 9d51934b..27074aa9 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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(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" /> - - - - + {bulkDuplicating && bulkDuplicateProgress ? ( +
+
+ {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, + ), + ), + )} +
+ +
+ +
+
+ ) : ( + + + + + )} @@ -5313,6 +5372,12 @@ function ManageSection({ const [bulkDuplicateOpen, setBulkDuplicateOpen] = useState(false); const [bulkDuplicateDate, setBulkDuplicateDate] = useState(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" /> - - - - + {bulkDuplicating && bulkDuplicateProgress ? ( +
+
+ {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, + ), + ), + )} +
+ +
+ +
+
+ ) : ( + + + + + )} diff --git a/attached_assets/image_1777921817832.png b/attached_assets/image_1777921817832.png new file mode 100644 index 00000000..f69ed56d Binary files /dev/null and b/attached_assets/image_1777921817832.png differ diff --git a/attached_assets/image_1777964908252.png b/attached_assets/image_1777964908252.png new file mode 100644 index 00000000..2bb58903 Binary files /dev/null and b/attached_assets/image_1777964908252.png differ diff --git a/attached_assets/image_1777965090828.png b/attached_assets/image_1777965090828.png new file mode 100644 index 00000000..1f06451f Binary files /dev/null and b/attached_assets/image_1777965090828.png differ diff --git a/attached_assets/image_1777965189289.png b/attached_assets/image_1777965189289.png new file mode 100644 index 00000000..02d86e88 Binary files /dev/null and b/attached_assets/image_1777965189289.png differ