Task #242: Executive Meetings UX — print page removal, inter-person chip, bulk delete undo
Original umbrella covered #144, #145, #168, #169, #199, #200, #211, #217, #222. Three landed here; #200 was already implemented; remaining five proposed as follow-ups #248–#250. #169 — Removed dead /executive-meetings/print route: - Deleted artifacts/tx-os/src/pages/executive-meetings-print.tsx - Removed import + Route from App.tsx - Removed executiveMeetings.print blocks from en.json and ar.json #222 — "+ شخص هنا" chip in inter-person gaps: - Added addPersonHere i18n key (ar/en) - Threaded addPersonHereLabel through AttendeeFlowSharedProps → flowProps → AttendeeFlow - Renders chip after a person row when next item is also a person (not a subheading); reuses existing onStartAdd("person", i+1) plumbing - Test IDs em-add-person-after-row-${i} / em-add-person-after-${i} #199 — Bulk delete undo via toast action: - ToastAction wired into deleteSelectedMeetings result toast - Snapshots captured client-side before DELETE - Undo recreates each row via existing POST /api/executive-meetings (omitting dailyNumber to avoid 409s on stolen slots), then PATCH if merge fields existed - Single-fire guard prevents double-click duplicates - Updated bulkDeleteConfirm to drop the now-untrue "cannot be undone" line - New strings: bulkDeleteUndo / bulkDeleteUndone / bulkDeleteUndoPartial / bulkDeleteUndoFailed in ar+en Verification: - tsc --noEmit clean for all touched files (admin.tsx errors are pre-existing, unrelated to this diff) - Playwright executive-meetings-bulk-actions.spec.mjs: 5/5 pass - Pre-existing flake meeting_created socket fan-out test passes in isolation (unrelated to changes)
This commit is contained in:
@@ -18,7 +18,6 @@ import NotesPage from "@/pages/notes";
|
||||
import MyOrdersPage from "@/pages/my-orders";
|
||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
||||
import ExecutiveMeetingsPrintPage from "@/pages/executive-meetings-print";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -51,10 +50,6 @@ function Router() {
|
||||
<Route path="/my-orders" component={MyOrdersPage} />
|
||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||
<Route path="/executive-meetings" component={ExecutiveMeetingsPage} />
|
||||
<Route
|
||||
path="/executive-meetings/print"
|
||||
component={ExecutiveMeetingsPrintPage}
|
||||
/>
|
||||
<Route path="/" component={HomePage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -976,6 +976,7 @@
|
||||
"timeEnd": "وقت النهاية",
|
||||
"timeOrderError": "وقت النهاية قبل وقت البداية",
|
||||
"addAttendee": "أضف حاضر",
|
||||
"addPersonHere": "+ شخص هنا",
|
||||
"addSubheading": "+ عنوان فرعي",
|
||||
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
|
||||
"addInternalAttendee": "+ حاضر داخلي",
|
||||
@@ -1000,10 +1001,14 @@
|
||||
"bulkSelectionCount": "محدد: {{n}} من {{total}}",
|
||||
"bulkClearSelection": "إلغاء التحديد",
|
||||
"bulkDeleteSelected": "حذف المحدد",
|
||||
"bulkDeleteConfirm": "حذف {{n}} اجتماعاً؟\n\nلا يمكن التراجع عن هذا الإجراء.",
|
||||
"bulkDeleteConfirm": "حذف {{n}} اجتماعاً؟",
|
||||
"bulkDeleteResultAll": "تم حذف {{n}} اجتماعاً",
|
||||
"bulkDeletePartial": "تم حذف {{ok}} من {{total}} اجتماعاً، فشل {{failed}}",
|
||||
"bulkDeleteResultNone": "فشل حذف الاجتماعات المحددة"
|
||||
"bulkDeleteResultNone": "فشل حذف الاجتماعات المحددة",
|
||||
"bulkDeleteUndo": "تراجع",
|
||||
"bulkDeleteUndone": "تمت استعادة {{n}} اجتماعاً",
|
||||
"bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}} اجتماعاً",
|
||||
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات"
|
||||
},
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
@@ -1247,18 +1252,6 @@
|
||||
"done": "تم"
|
||||
}
|
||||
},
|
||||
"print": {
|
||||
"title": "جدول الاجتماعات التنفيذية",
|
||||
"no": "#",
|
||||
"meeting": "الاجتماع",
|
||||
"attendees": "الحضور",
|
||||
"time": "الوقت",
|
||||
"location": "المكان",
|
||||
"none": "لا توجد اجتماعات لهذا اليوم.",
|
||||
"print": "طباعة",
|
||||
"back": "إغلاق",
|
||||
"loading": "جارٍ التحميل…"
|
||||
},
|
||||
"pdf": {
|
||||
"heading": "تصدير PDF",
|
||||
"intro": "يتم توليد ملف PDF حقيقي لجدول اليوم على الخادم باستخدام إعدادات الخط المحفوظة. كل عملية تنزيل تتم أرشفتها.",
|
||||
|
||||
@@ -893,6 +893,7 @@
|
||||
"timeEnd": "End time",
|
||||
"timeOrderError": "End time is before start time",
|
||||
"addAttendee": "Add attendee",
|
||||
"addPersonHere": "+ person here",
|
||||
"addSubheading": "+ subheading",
|
||||
"addVirtualAttendee": "+ Virtual",
|
||||
"addInternalAttendee": "+ Internal",
|
||||
@@ -917,10 +918,14 @@
|
||||
"bulkSelectionCount": "Selected: {{n}} of {{total}}",
|
||||
"bulkClearSelection": "Clear selection",
|
||||
"bulkDeleteSelected": "Delete selected",
|
||||
"bulkDeleteConfirm": "Delete {{n}} meetings?\n\nThis cannot be undone.",
|
||||
"bulkDeleteConfirm": "Delete {{n}} meetings?",
|
||||
"bulkDeleteResultAll": "Deleted {{n}} meetings",
|
||||
"bulkDeletePartial": "Deleted {{ok}} of {{total}} meetings; {{failed}} failed",
|
||||
"bulkDeleteResultNone": "Failed to delete the selected meetings"
|
||||
"bulkDeleteResultNone": "Failed to delete the selected meetings",
|
||||
"bulkDeleteUndo": "Undo",
|
||||
"bulkDeleteUndone": "Restored {{n}} meetings",
|
||||
"bulkDeleteUndoPartial": "Restored {{ok}} of {{total}} meetings",
|
||||
"bulkDeleteUndoFailed": "Could not restore meetings"
|
||||
},
|
||||
"nav": {
|
||||
"schedule": "Daily Schedule",
|
||||
@@ -1160,18 +1165,6 @@
|
||||
"downloadArchive": "Download",
|
||||
"legacyArchive": "Legacy snapshot"
|
||||
},
|
||||
"print": {
|
||||
"title": "Executive Meetings Schedule",
|
||||
"no": "#",
|
||||
"meeting": "Meeting",
|
||||
"attendees": "Attendees",
|
||||
"time": "Time",
|
||||
"location": "Location",
|
||||
"none": "No meetings for this day.",
|
||||
"print": "Print",
|
||||
"back": "Close",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"fontSettingsPage": {
|
||||
"heading": "Font Settings",
|
||||
"intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.",
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { safeHtml } from "@/lib/safe-html";
|
||||
import { formatTime as i18nFormatTime } from "@/lib/i18n-format";
|
||||
|
||||
/**
|
||||
* Convert an HH:mm:ss DB string to the same 12-hour locale-aware format
|
||||
* used by the on-screen schedule. Mirrors the helper in the main page so
|
||||
* the print/PDF view stays in sync (e.g. "3:15 PM" / "3:15 م"). Falls
|
||||
* back to the raw HH:mm slice if the string somehow fails to parse.
|
||||
*/
|
||||
function formatPrintTime(t: string, lang: "ar" | "en"): string {
|
||||
const hhmm = t.slice(0, 5);
|
||||
const parsed = new Date(`1970-01-01T${hhmm}:00`);
|
||||
if (Number.isNaN(parsed.getTime())) return hhmm;
|
||||
return i18nFormatTime(parsed, lang, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
type Attendee = {
|
||||
id: number;
|
||||
name: string;
|
||||
title: string | null;
|
||||
attendanceType: "internal" | "external" | "online";
|
||||
sortOrder: number;
|
||||
// Optional so older API snapshots without the column still parse;
|
||||
// missing values are treated as "person" downstream.
|
||||
kind?: "person" | "subheading";
|
||||
};
|
||||
|
||||
type Meeting = {
|
||||
id: number;
|
||||
dailyNumber: number;
|
||||
titleAr: string;
|
||||
titleEn: string | null;
|
||||
meetingDate: string;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
location: string | null;
|
||||
meetingUrl: string | null;
|
||||
platform: string | null;
|
||||
status: string;
|
||||
isHighlighted: number;
|
||||
notes: string | null;
|
||||
attendees?: Attendee[];
|
||||
};
|
||||
|
||||
type DayResponse = { date: string; meetings: Meeting[] };
|
||||
|
||||
function getQuery(): { date: string; lang: "ar" | "en" } {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const date =
|
||||
params.get("date") || new Date().toISOString().slice(0, 10);
|
||||
const lang = params.get("lang") === "en" ? "en" : "ar";
|
||||
return { date, lang };
|
||||
}
|
||||
|
||||
const PRINT_KEYS = {
|
||||
title: "executiveMeetings.print.title",
|
||||
no: "executiveMeetings.print.no",
|
||||
meeting: "executiveMeetings.print.meeting",
|
||||
attendees: "executiveMeetings.print.attendees",
|
||||
time: "executiveMeetings.print.time",
|
||||
location: "executiveMeetings.print.location",
|
||||
none: "executiveMeetings.print.none",
|
||||
print: "executiveMeetings.print.print",
|
||||
back: "executiveMeetings.print.back",
|
||||
loading: "executiveMeetings.print.loading",
|
||||
} as const;
|
||||
|
||||
export default function ExecutiveMeetingsPrintPage() {
|
||||
const { date, lang } = useMemo(getQuery, []);
|
||||
const [data, setData] = useState<DayResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isRtl = lang === "ar";
|
||||
const { t: i18nT, i18n } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||
}, [lang, i18n]);
|
||||
const t = {
|
||||
title: i18nT(PRINT_KEYS.title),
|
||||
no: i18nT(PRINT_KEYS.no),
|
||||
meeting: i18nT(PRINT_KEYS.meeting),
|
||||
attendees: i18nT(PRINT_KEYS.attendees),
|
||||
time: i18nT(PRINT_KEYS.time),
|
||||
location: i18nT(PRINT_KEYS.location),
|
||||
none: i18nT(PRINT_KEYS.none),
|
||||
print: i18nT(PRINT_KEYS.print),
|
||||
back: i18nT(PRINT_KEYS.back),
|
||||
loading: i18nT(PRINT_KEYS.loading),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = isRtl ? "rtl" : "ltr";
|
||||
document.documentElement.lang = lang;
|
||||
document.title = `${t.title} — ${date}`;
|
||||
}, [isRtl, lang, date, t.title]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/executive-meetings?date=${date}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as DayResponse;
|
||||
if (!cancelled) setData(json);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [date]);
|
||||
|
||||
const meetings = data?.meetings ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-white text-[#0B1E3F]"
|
||||
data-testid="em-print-page"
|
||||
>
|
||||
<style>{`
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { background: white; }
|
||||
@page { size: A4; margin: 14mm; }
|
||||
/* On A4 the table always fits the page; drop the screen-only
|
||||
scroll guard so the layout stays identical to today and the
|
||||
# column can shrink to its natural width. */
|
||||
.em-print-scroll { overflow: visible !important; }
|
||||
.em-print-table { min-width: 0 !important; }
|
||||
.em-print-table th:first-child { width: auto !important; }
|
||||
}
|
||||
/* Screen-only horizontal scroll guard — prevents the table from
|
||||
breaking the page layout on phones (<640px). */
|
||||
.em-print-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.em-print-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
}
|
||||
.em-print-table th, .em-print-table td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
/* Long Arabic names have few break opportunities; force wrap so
|
||||
the cell never overflows its border. */
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
.em-print-table th {
|
||||
background: #0B1E3F;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.em-print-table tr.hl td { background: #fecaca; }
|
||||
.em-print-attendees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.em-print-attendees .em-print-attendee-num {
|
||||
color: #6b7280;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
/* Subheading rows: bold separator labels between persons. */
|
||||
.em-print-attendees .em-print-subheading {
|
||||
font-weight: 700;
|
||||
color: #0B1E3F;
|
||||
margin-top: 4px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
.em-print-attendees .em-print-subheading:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="no-print flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div className="text-sm text-gray-600">
|
||||
{t.title} — {date}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-3 py-1.5 rounded-md bg-[#0B1E3F] text-white text-sm"
|
||||
data-testid="em-print-now"
|
||||
>
|
||||
{t.print}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="px-3 py-1.5 rounded-md border border-gray-300 text-sm"
|
||||
>
|
||||
{t.back}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-center mb-1">{t.title}</h1>
|
||||
<div className="text-center text-sm text-gray-600 mb-6">{date}</div>
|
||||
|
||||
{error ? (
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="text-gray-500 text-sm">{t.loading}</div>
|
||||
) : meetings.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm text-center">{t.none}</div>
|
||||
) : (
|
||||
<div className="em-print-scroll" data-testid="em-print-scroll">
|
||||
<table className="em-print-table" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Slightly rebalanced from 8/32/40/20: give attendees the
|
||||
most room since long Arabic names dominate the cell. */}
|
||||
<th style={{ width: "6%" }}>{t.no}</th>
|
||||
<th style={{ width: "30%" }}>{t.meeting}</th>
|
||||
<th style={{ width: "44%" }}>{t.attendees}</th>
|
||||
<th style={{ width: "20%" }}>{t.time}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meetings.map((m) => {
|
||||
const title = isRtl
|
||||
? m.titleAr
|
||||
: m.titleEn || m.titleAr;
|
||||
const attendees = m.attendees ?? [];
|
||||
const time =
|
||||
m.startTime && m.endTime
|
||||
? `${formatPrintTime(m.startTime, lang)} – ${formatPrintTime(m.endTime, lang)}`
|
||||
: m.startTime
|
||||
? formatPrintTime(m.startTime, lang)
|
||||
: "—";
|
||||
return (
|
||||
<tr key={m.id} className={m.isHighlighted ? "hl" : ""}>
|
||||
<td>{m.dailyNumber}</td>
|
||||
<td>
|
||||
{/* title comes from the API already sanitized by
|
||||
sanitizeRichText (allowlist of inline tags + a
|
||||
small set of safe styles). Render as HTML so
|
||||
in-cell formatting (bold, color, font…) carries
|
||||
into the printed page. */}
|
||||
<div
|
||||
style={{ fontWeight: 600 }}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml(title) }}
|
||||
/>
|
||||
{m.location ? (
|
||||
<div style={{ fontSize: 12, color: "#4b5563" }}>
|
||||
{m.location}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{attendees.length === 0 ? (
|
||||
"—"
|
||||
) : (
|
||||
<div
|
||||
className="em-print-attendees"
|
||||
data-testid={`em-print-attendees-${m.id}`}
|
||||
>
|
||||
{(() => {
|
||||
// Each subheading starts a fresh numbered
|
||||
// section. Mirrors AttendeeFlow on screen.
|
||||
let personIdx = 0;
|
||||
return attendees.map((a, idx) => {
|
||||
const isSub =
|
||||
(a.kind ?? "person") === "subheading";
|
||||
if (isSub) {
|
||||
// Reset the in-section counter so the
|
||||
// next person starts at 1.
|
||||
personIdx = 0;
|
||||
return (
|
||||
<div
|
||||
key={a.id ?? `sub-${idx}`}
|
||||
className="em-print-subheading"
|
||||
data-testid={`em-print-subheading-${m.id}-${idx}`}
|
||||
>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const myIdx = personIdx;
|
||||
personIdx += 1;
|
||||
return (
|
||||
<div key={a.id ?? idx}>
|
||||
<span className="em-print-attendee-num">
|
||||
{myIdx + 1}-
|
||||
</span>
|
||||
{/* a.name comes from the API already
|
||||
sanitized by sanitizeRichText.
|
||||
a.title is *not* rich text —
|
||||
render it as a plain text node so
|
||||
an attacker who smuggles HTML into
|
||||
the title field cannot escape into
|
||||
<script> tags on the print page. */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
{a.title ? <span> ({a.title})</span> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{time}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ToastAction } from "@/components/ui/toast";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
@@ -1312,6 +1313,48 @@ function ScheduleSection({
|
||||
return "some";
|
||||
}, [orderedMeetings, selectedMeetingIds]);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
// #199: undo via recreate. dailyNumber is omitted so a stolen slot
|
||||
// doesn't 409 the restore — the row gets the next available number.
|
||||
const restoreMeetingFromSnapshot = useCallback(
|
||||
async (snap: Meeting) => {
|
||||
const created = (await apiJson("/api/executive-meetings", {
|
||||
method: "POST",
|
||||
body: {
|
||||
titleAr: snap.titleAr,
|
||||
titleEn: snap.titleEn,
|
||||
meetingDate: snap.meetingDate,
|
||||
startTime: snap.startTime ?? undefined,
|
||||
endTime: snap.endTime ?? undefined,
|
||||
location: snap.location ?? undefined,
|
||||
meetingUrl: snap.meetingUrl ?? undefined,
|
||||
platform: snap.platform,
|
||||
status: snap.status,
|
||||
isHighlighted: snap.isHighlighted ? 1 : 0,
|
||||
notes: snap.notes ?? undefined,
|
||||
attendees: (snap.attendees ?? []).map((a, i) => ({
|
||||
name: a.name,
|
||||
title: a.title ?? null,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: a.sortOrder ?? i,
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
},
|
||||
})) as Meeting;
|
||||
if (snap.mergeStartColumn && snap.mergeEndColumn && snap.mergeText) {
|
||||
await apiJson(`/api/executive-meetings/${created.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
merge: {
|
||||
mergeStartColumn: snap.mergeStartColumn,
|
||||
mergeEndColumn: snap.mergeEndColumn,
|
||||
mergeText: snap.mergeText,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const deleteSelectedMeetings = useCallback(async () => {
|
||||
if (bulkDeleting) return;
|
||||
const ids = Array.from(selectedMeetingIds);
|
||||
@@ -1320,6 +1363,8 @@ function ScheduleSection({
|
||||
"executiveMeetings.schedule.bulkDeleteConfirm",
|
||||
).replace("{{n}}", String(ids.length));
|
||||
if (!window.confirm(message)) return;
|
||||
// Snapshot before deletion so undo can reconstruct the rows.
|
||||
const snapshots = meetings.filter((m) => selectedMeetingIds.has(m.id));
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
// Deliberately allSettled, not all: a single failed DELETE shouldn't
|
||||
@@ -1332,25 +1377,77 @@ function ScheduleSection({
|
||||
);
|
||||
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));
|
||||
// #199: guard against double-click — restore is non-idempotent.
|
||||
let undoFired = false;
|
||||
const undo = async () => {
|
||||
if (undoFired) return;
|
||||
undoFired = true;
|
||||
handle.dismiss();
|
||||
const undoResults = await Promise.allSettled(
|
||||
okSnapshots.map((s) => restoreMeetingFromSnapshot(s)),
|
||||
);
|
||||
const undoOk = undoResults.filter(
|
||||
(r) => r.status === "fulfilled",
|
||||
).length;
|
||||
const undoFailed = undoResults.length - undoOk;
|
||||
if (undoFailed === 0) {
|
||||
toast({
|
||||
title: t(
|
||||
"executiveMeetings.schedule.bulkDeleteUndone",
|
||||
).replace("{{n}}", String(undoOk)),
|
||||
});
|
||||
} else if (undoOk === 0) {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDeleteUndoFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("executiveMeetings.schedule.bulkDeleteUndoPartial")
|
||||
.replace("{{ok}}", String(undoOk))
|
||||
.replace("{{total}}", String(undoResults.length)),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
refreshDay();
|
||||
};
|
||||
const undoLabel = t("executiveMeetings.schedule.bulkDeleteUndo");
|
||||
const undoAction =
|
||||
okSnapshots.length > 0 ? (
|
||||
<ToastAction
|
||||
altText={undoLabel}
|
||||
onClick={() => {
|
||||
void undo();
|
||||
}}
|
||||
data-testid="em-bulk-delete-undo"
|
||||
>
|
||||
{undoLabel}
|
||||
</ToastAction>
|
||||
) : undefined;
|
||||
let handle: ReturnType<typeof toast>;
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
handle = toast({
|
||||
title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
|
||||
"{{n}}",
|
||||
String(ok),
|
||||
),
|
||||
action: undoAction,
|
||||
});
|
||||
} else if (ok === 0) {
|
||||
toast({
|
||||
handle = toast({
|
||||
title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
handle = toast({
|
||||
title: t("executiveMeetings.schedule.bulkDeletePartial")
|
||||
.replace("{{ok}}", String(ok))
|
||||
.replace("{{total}}", String(results.length))
|
||||
.replace("{{failed}}", String(failed)),
|
||||
variant: "destructive",
|
||||
action: undoAction,
|
||||
});
|
||||
}
|
||||
setSelectedMeetingIds(new Set());
|
||||
@@ -1358,7 +1455,15 @@ function ScheduleSection({
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
}, [bulkDeleting, selectedMeetingIds, t, toast, refreshDay]);
|
||||
}, [
|
||||
bulkDeleting,
|
||||
selectedMeetingIds,
|
||||
meetings,
|
||||
t,
|
||||
toast,
|
||||
refreshDay,
|
||||
restoreMeetingFromSnapshot,
|
||||
]);
|
||||
|
||||
const [creatingRow, setCreatingRow] = useState(false);
|
||||
// When the user clicks "Add row" we capture the new meeting id here so
|
||||
@@ -3210,6 +3315,7 @@ function AttendeesCell({
|
||||
onCommitAdd: commitAdd,
|
||||
onCancelAdd: cancelAdd,
|
||||
addLabel: t("executiveMeetings.schedule.addAttendee"),
|
||||
addPersonHereLabel: t("executiveMeetings.schedule.addPersonHere"),
|
||||
addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
|
||||
editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
|
||||
newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
|
||||
@@ -3404,6 +3510,7 @@ type AttendeeFlowSharedProps = {
|
||||
) => Promise<void>;
|
||||
onCancelAdd?: () => void;
|
||||
addLabel: string;
|
||||
addPersonHereLabel: string;
|
||||
addSubheadingLabel: string;
|
||||
editAriaLabel: string;
|
||||
newAriaLabel: string;
|
||||
@@ -3455,6 +3562,7 @@ function AttendeeFlow({
|
||||
onCommitAdd,
|
||||
onCancelAdd,
|
||||
addLabel,
|
||||
addPersonHereLabel,
|
||||
addSubheadingLabel,
|
||||
editAriaLabel,
|
||||
newAriaLabel,
|
||||
@@ -3760,20 +3868,36 @@ function AttendeeFlow({
|
||||
)}
|
||||
</li>,
|
||||
);
|
||||
// After-section "+ عنوان فرعي" chip — appears AFTER the last
|
||||
// person of each section that is followed by ANOTHER section
|
||||
// (i.e. the next item is a subheading). This lets the user
|
||||
// insert a new section heading at the boundary between two
|
||||
// existing sections. The trailing section is intentionally
|
||||
// skipped: the trailing "+ عنوان فرعي" button below already
|
||||
// covers that slot, so duplicating the chip would be noise.
|
||||
// The chip's insertAtIndex is the slot immediately after this
|
||||
// person's global attendees index, so the new subheading lands
|
||||
// BEFORE the existing next-section subheading.
|
||||
const nextItemForPerson = items[listIdx + 1];
|
||||
const nextIsSubheading =
|
||||
nextItemForPerson &&
|
||||
(nextItemForPerson.a.kind ?? "person") === "subheading";
|
||||
// #222: inter-person "+ شخص هنا" chip — appears in the gap
|
||||
// between two consecutive PERSON rows (not before a subheading,
|
||||
// since the after-section subheading chip already lives there).
|
||||
// insertAtIndex = i + 1 so the new person lands immediately
|
||||
// after the current row.
|
||||
if (showAdd && nextItemForPerson && !nextIsSubheading) {
|
||||
nodes.push(
|
||||
<li
|
||||
key={`add-person-after-${i}`}
|
||||
className="basis-full flex justify-center print:hidden"
|
||||
data-testid={`em-add-person-after-row-${i}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onStartAdd!(addType, "person", i + 1)
|
||||
}
|
||||
data-testid={`em-add-person-after-${i}`}
|
||||
aria-label={addPersonHereLabel}
|
||||
className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
{addPersonHereLabel}
|
||||
</button>
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
if (showAdd && nextIsSubheading) {
|
||||
nodes.push(
|
||||
<li
|
||||
|
||||
Reference in New Issue
Block a user