#486 Executive Meetings: row-click quick actions popover (Move up / Move down / Postpone)

Clicking any meeting row on the schedule now opens a small popover with three
quick actions: Move up, Move down, and Postpone. Gated only on canMutate (NOT
editMode) per spec.

Backend
- New POST /executive-meetings/swap-times endpoint (artifacts/api-server/src/
  routes/executive-meetings.ts). FOR UPDATE locks both rows by ascending id to
  avoid deadlocks, optimistic-lock check via expectedUpdatedAt{A,B} (returns
  409 stale_meeting + conflict.lastActor — same shape PostponeDialog
  understands), guards different_dates and no_time_window, swaps only
  (startTime, endTime), audits each row as `meeting_swap_times`, calls
  renumberDayByStartTime so the # column matches the new chronological order,
  and broadcasts emitExecutiveMeetingsDayChanged.
- New zod body schema ExecutiveMeetingsSwapTimesBody in lib/api-zod/src/manual.ts.

Frontend
- New shared lib/api-json.ts (ApiError + apiJson) extracted from upcoming-
  meeting-alert.tsx so the page can reuse the same fetch/error contract.
- upcoming-meeting-alert.tsx exports PostponeDialog so the page can mount it
  for quick-action postpone without duplicating UI.
- artifacts/tx-os/src/pages/executive-meetings.tsx: Schedule wires
  swapTimes/quickMoveUp/quickMoveDown/postpone state, computes per-day
  neighbours via meetingNumbersById, and mounts a single page-level
  PostponeDialog. MeetingRow wraps `<tr>` in a Popover/PopoverAnchor; row
  onClick opens the popover with skip rules for buttons/inputs/contenteditable
  and ARIA roles (button/checkbox/switch/combobox/dialog) plus testid
  prefixes (em-row-grip/-actions/-select, em-edit-*, em-merge-edit-*, em-time-*)
  so the time cell's inline editor and other affordances don't collide.
- en/ar locales gain executiveMeetings.quickActions.{label,moveUp,moveDown,postpone}.

Tests
- artifacts/api-server/tests/executive-meetings-swap-times.test.mjs (new):
  happy path, 409 stale_meeting (with conflict actor), 400 different_dates,
  400 no_time_window. Each scenario uses a distinct far-future date to avoid
  daily_number races with seeded data.
- artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs (new):
  drives the date input, verifies row click → popover, Move up swap reflected
  in DB, and Postpone item opens the dialog.

Code review (architect) flagged an edit-mode conflict where the time cell
(role=button div) would also bubble to the row handler; fixed by adding ARIA
role and em-time-* skip rules. Architect also flagged keyboard-trigger gap
and edit-mode test gaps — proposed as follow-ups.

Other test failures in the repo (executive-meetings reorder, font-settings,
notes-share, service-orders) pre-date this change and are unrelated.
This commit is contained in:
riyadhafraa
2026-05-11 10:55:34 +00:00
parent 2e364a102e
commit b1b77395d0
9 changed files with 1051 additions and 45 deletions
@@ -42,6 +42,11 @@ import {
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
import { notificationPlayer } from "@/lib/notification-sounds";
// #486: ApiError + apiJson moved to a shared module so the new
// schedule row quick-actions popover can reuse the PostponeDialog
// (exported below) and inspect the same structured 409 stale_meeting
// payload without duplicating the fetch wrapper.
import { ApiError, apiJson } from "@/lib/api-json";
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
const ALERT_LEAD_MINUTES = 5;
@@ -123,40 +128,9 @@ function formatTime(t: string | null, lang: string): string {
});
}
// #283: Custom error so callers can inspect the structured response
// body — specifically the `code` and the `conflict` payload returned
// for 409 stale_meeting — instead of only the human-readable message.
class ApiError extends Error {
status: number;
code: string | undefined;
body: Record<string, unknown>;
constructor(message: string, status: number, body: Record<string, unknown>) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
const code = (body as { code?: unknown }).code;
this.code = typeof code === "string" ? code : undefined;
}
}
async function apiJson<T>(input: string, init?: RequestInit): Promise<T> {
const res = await fetch(input, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? (JSON.parse(text) as unknown) : ({} as unknown);
if (!res.ok) {
const body = (data ?? {}) as Record<string, unknown>;
const message =
(body.error as string | undefined) || `HTTP ${res.status}`;
throw new ApiError(message, res.status, body);
}
return data as T;
}
// #283/#486: ApiError + apiJson are imported above from
// `@/lib/api-json` so this file and the schedule row quick-actions
// popover share one fetch wrapper and one error class.
type DragPosition = { x: number; y: number };
function loadPosition(): DragPosition | null {
@@ -1058,6 +1032,13 @@ type PostponeDialogProps = {
type PostponeTab = "minutes" | "reschedule" | "cancel";
// #486: Exported so the Executive Meetings schedule page can mount the
// same dialog from its row quick-actions popover. The Meeting type
// (also exported as PostponeDialogMeeting) is the structural minimum
// the dialog reads from — any caller passing a row with these fields
// satisfies it.
export type PostponeDialogMeeting = Meeting;
export { PostponeDialog };
function PostponeDialog({
open,
onOpenChange,
+40
View File
@@ -0,0 +1,40 @@
// #486: Shared apiJson + ApiError so the upcoming-meeting-alert
// PostponeDialog and the new schedule row quick-actions popover both
// inspect the same structured 409 stale_meeting payload (used by the
// postpone-minutes and swap-times endpoints) without duplicating the
// fetch wrapper.
export class ApiError extends Error {
status: number;
code: string | undefined;
body: Record<string, unknown>;
constructor(message: string, status: number, body: Record<string, unknown>) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
const code = (body as { code?: unknown }).code;
this.code = typeof code === "string" ? code : undefined;
}
}
export async function apiJson<T>(
input: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(input, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
const data = text ? (JSON.parse(text) as unknown) : ({} as unknown);
if (!res.ok) {
const body = (data ?? {}) as Record<string, unknown>;
const message =
(body.error as string | undefined) || `HTTP ${res.status}`;
throw new ApiError(message, res.status, body);
}
return data as T;
}
+6
View File
@@ -1427,6 +1427,12 @@
"label": "إجراءات الصف",
"back": "رجوع"
},
"quickActions": {
"label": "إجراءات سريعة",
"moveUp": "نقل لأعلى",
"moveDown": "نقل لأسفل",
"postpone": "تأجيل"
},
"merge": {
"label": "دمج الخلايا",
"editLabel": "تعديل الخلية المدموجة",
+6
View File
@@ -1141,6 +1141,12 @@
"label": "Row actions",
"back": "Back"
},
"quickActions": {
"label": "Quick actions",
"moveUp": "Move up",
"moveDown": "Move down",
"postpone": "Postpone"
},
"merge": {
"label": "Merge cells",
"editLabel": "Edit merged cell",
+249 -10
View File
@@ -80,6 +80,7 @@ import {
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
@@ -109,6 +110,9 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
// #486: reuse the same dialog the upcoming-alert pops, so postponing
// from the schedule row quick-actions menu shares one implementation.
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
import { safeHtml, htmlToPlainText, wrapAsParagraph } from "@/lib/safe-html";
import {
ALERT_COLOR_PRESETS,
@@ -171,6 +175,10 @@ type Meeting = {
// is the same for every viewer. NULL/missing = "default" (no tint).
// Allowed values come from ROW_COLOR_OPTIONS below.
rowColor?: string | null;
// #486: optimistic-lock token used by /executive-meetings/swap-times
// (the schedule row quick-actions Move up / Move down popover) and
// by the existing PostponeDialog when reused from the schedule.
updatedAt: string;
};
type DayResponse = { date: string; meetings: Meeting[] };
@@ -2147,6 +2155,82 @@ function ScheduleSection({
[orderedMeetings, date, refreshDay, toast, t],
);
// #486: Quick-actions popover (Move up / Move down / Postpone) on
// every schedule row, gated only on the raw `canMutate` permission
// (NOT effectiveCanMutate / editMode) so users can reorder + postpone
// without flipping into edit mode. Move up / Move down swap just the
// (startTime, endTime) tuple between the clicked meeting and its
// chronological neighbour on the same date — see swap-times route.
// The Time column therefore stays visually anchored to its row;
// each row keeps its slot and the meetings rotate through the slots.
const swapTimes = useCallback(
async (a: Meeting, b: Meeting) => {
if (reorderingRef.current) return;
reorderingRef.current = true;
setReordering(true);
try {
await apiJson(`/api/executive-meetings/swap-times`, {
method: "POST",
body: {
aId: a.id,
bId: b.id,
expectedUpdatedAtA: a.updatedAt,
expectedUpdatedAtB: b.updatedAt,
},
});
refreshDay();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
title: t("common.error"),
description: msg,
variant: "destructive",
});
} finally {
reorderingRef.current = false;
setReordering(false);
}
},
[refreshDay, toast, t],
);
const quickMoveUp = useCallback(
(meetingId: number) => {
const idx = orderedMeetings.findIndex((m) => m.id === meetingId);
if (idx <= 0) return;
const a = orderedMeetings[idx]!;
const b = orderedMeetings[idx - 1]!;
void swapTimes(a, b);
},
[orderedMeetings, swapTimes],
);
const quickMoveDown = useCallback(
(meetingId: number) => {
const idx = orderedMeetings.findIndex((m) => m.id === meetingId);
if (idx < 0 || idx >= orderedMeetings.length - 1) return;
const a = orderedMeetings[idx]!;
const b = orderedMeetings[idx + 1]!;
void swapTimes(a, b);
},
[orderedMeetings, swapTimes],
);
const [postponeMeetingId, setPostponeMeetingId] = useState<number | null>(
null,
);
const postponeMeeting = useMemo(() => {
if (postponeMeetingId == null) return null;
return meetings.find((m) => m.id === postponeMeetingId) ?? null;
}, [meetings, postponeMeetingId]);
// PostponeDialog renders cascade-prompt rows by daily number, so it
// needs the same id→# map the upcoming-alert builds. Mirror that
// logic from `meetings` directly (#486 reuse).
const meetingNumbersById = useMemo(() => {
const m = new Map<number, number>();
for (const x of meetings) m.set(x.id, x.dailyNumber);
return m;
}, [meetings]);
// #265: columns persistence is owned by ExecutiveMeetingsPage now that
// both Schedule and Settings tabs can mutate the array. Keeping the
// write effect here would silently drop edits made from the Settings
@@ -2820,6 +2904,12 @@ function ScheduleSection({
bulkSelectable={effectiveCanMutate}
bulkSelected={selectedMeetingIds.has(m.id)}
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
quickActionsCanMutate={canMutate}
canQuickMoveUp={idx > 0}
canQuickMoveDown={idx < displayedMeetings.length - 1}
onQuickMoveUp={() => quickMoveUp(m.id)}
onQuickMoveDown={() => quickMoveDown(m.id)}
onQuickPostpone={() => setPostponeMeetingId(m.id)}
/>
))}
</SortableContext>
@@ -2855,6 +2945,28 @@ function ScheduleSection({
</table>
</div>
{/* #486: Page-level PostponeDialog mount, opened by clicking the
Postpone item in any row's quick-actions popover. Reuses the
same component the upcoming-meeting alert renders so the
postpone-minutes / reschedule / cancel flows + the 409
stale_meeting handling stay in one place. */}
{postponeMeeting && canMutate ? (
<PostponeDialog
open={postponeMeetingId !== null}
onOpenChange={(o) => {
if (!o) setPostponeMeetingId(null);
}}
meeting={postponeMeeting}
isRtl={isRtl}
meetingNumbersById={meetingNumbersById}
accent={highlightPrefs.color}
onSaved={async () => {
setPostponeMeetingId(null);
refreshDay();
}}
/>
) : null}
{/* #330: Bulk-duplicate-to-date dialog. Mirrors the per-row
duplicate dialog in ManageSection (single date picker, same
POST endpoint per id) but operates on the visible-selected
@@ -3590,6 +3702,12 @@ function MeetingRow({
bulkSelected,
onBulkSelectChange,
displayNumber,
quickActionsCanMutate,
canQuickMoveUp,
canQuickMoveDown,
onQuickMoveUp,
onQuickMoveDown,
onQuickPostpone,
}: {
meeting: Meeting;
displayNumber?: number;
@@ -3599,6 +3717,16 @@ function MeetingRow({
rowColorKey: string;
onPickRowColor: (key: string) => void;
canMutate: boolean;
// #486: raw (un-gated by editMode) mutate permission — clicking a
// row anywhere outside an interactive control opens the quick-
// actions popover. The popover itself respects the same MUTATE_ROLES
// server-side gating as the swap-times + postpone-minutes endpoints.
quickActionsCanMutate?: boolean;
canQuickMoveUp?: boolean;
canQuickMoveDown?: boolean;
onQuickMoveUp?: () => void;
onQuickMoveDown?: () => void;
onQuickPostpone?: () => void;
onSaveTitle: (html: string) => Promise<void>;
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
onSaveTimes: (
@@ -4045,17 +4173,128 @@ function MeetingRow({
boxShadow: composedShadow || undefined,
};
// #486: Quick-actions popover. Clicking anywhere on the row that is
// NOT an interactive control (button, link, input, contenteditable
// cell, drag handle, row-actions menu, edit affordance, bulk-select
// checkbox, etc.) opens the popover. Anchored to the row itself so
// the menu floats next to the clicked row regardless of scroll.
// Gated on raw `quickActionsCanMutate` (not editMode) per spec.
const [quickOpen, setQuickOpen] = useState(false);
const handleRowClick = useCallback(
(e: React.MouseEvent<HTMLTableRowElement>) => {
if (!quickActionsCanMutate) return;
const target = e.target as HTMLElement | null;
if (!target) return;
// Skip clicks that landed on an interactive child — leave them to
// their own handlers. Includes ARIA roles because TimeRangeCell
// and a few other affordances render as `div role="button"`
// (keyboard-activatable) rather than native `<button>`s, so a
// pure tag-name filter would let those clicks fall through to
// the row handler and pop the quick-actions menu underneath the
// inline editor the user just opened.
if (
target.closest(
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
)
) {
return;
}
// Skip clicks on row affordances by data-testid prefix so we
// don't fight with drag, row-actions, merge-edit, inline-edit,
// bulk-select, or the time cell's edit-on-click surface
// (em-time-*). The role='button' rule above catches the time
// cell's display state too, but the explicit testid guard keeps
// the intent obvious and survives any future refactor that
// drops the role attribute.
if (
target.closest(
[
`[data-testid="em-row-grip-${meeting.id}"]`,
`[data-testid="em-row-actions-${meeting.id}"]`,
`[data-testid="em-row-select-${meeting.id}"]`,
`[data-testid^="em-edit-"]`,
`[data-testid^="em-merge-edit-"]`,
`[data-testid^="em-time-"]`,
].join(", "),
)
) {
return;
}
// Don't re-trigger when a click bubbles up from the popover
// content itself.
if (target.closest(`[data-testid="em-row-quick-${meeting.id}"]`)) return;
setQuickOpen(true);
},
[meeting.id, quickActionsCanMutate],
);
return (
<tr
ref={setNodeRef}
className="group"
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
>
{renderCells()}
</tr>
<Popover open={quickOpen} onOpenChange={setQuickOpen}>
<PopoverAnchor asChild>
<tr
ref={setNodeRef}
className="group"
style={trStyle}
data-testid={`em-row-${meeting.id}`}
data-current-meeting={isCurrent ? "true" : undefined}
data-bulk-selected={bulkSelected ? "true" : undefined}
onClick={quickActionsCanMutate ? handleRowClick : undefined}
>
{renderCells()}
</tr>
</PopoverAnchor>
{quickActionsCanMutate ? (
<PopoverContent
className="w-auto p-1"
align={isRtl ? "end" : "start"}
side="bottom"
sideOffset={4}
data-testid={`em-row-quick-${meeting.id}`}
onClick={(e) => e.stopPropagation()}
onInteractOutside={() => setQuickOpen(false)}
>
<div className="flex flex-col min-w-[10rem]">
<button
type="button"
disabled={!canQuickMoveUp}
onClick={() => {
setQuickOpen(false);
onQuickMoveUp?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start disabled:opacity-50 disabled:hover:bg-transparent"
data-testid={`em-row-quick-up-${meeting.id}`}
>
<ChevronUp className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.moveUp")}</span>
</button>
<button
type="button"
disabled={!canQuickMoveDown}
onClick={() => {
setQuickOpen(false);
onQuickMoveDown?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start disabled:opacity-50 disabled:hover:bg-transparent"
data-testid={`em-row-quick-down-${meeting.id}`}
>
<ChevronDown className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.moveDown")}</span>
</button>
<button
type="button"
onClick={() => {
setQuickOpen(false);
onQuickPostpone?.();
}}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent text-start"
data-testid={`em-row-quick-postpone-${meeting.id}`}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span className="flex-1">{t("executiveMeetings.quickActions.postpone")}</span>
</button>
</div>
</PopoverContent>
) : null}
</Popover>
);
}
@@ -0,0 +1,180 @@
// #486: e2e for the Executive Meetings schedule row quick-actions
// popover. Clicking any row (gated only on canMutate, NOT editMode)
// opens a small menu with Move up / Move down / Postpone. Move up/
// down swap the (startTime, endTime) tuple between the clicked
// meeting and its chronological neighbour on the same date — the
// Time column stays visually anchored to its row position.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run the row-quick-actions test",
);
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdMeetingIds = [];
async function loginViaUi(page, username, password) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
function pickFutureDate() {
// Pick a date well in the future so it can't collide with the
// upcoming-meeting alert, recurring seeded data, or other suite fixtures.
const d = new Date(Date.now() + 30 * 86_400_000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const TEST_DATE = pickFutureDate();
async function gotoTestDate(page) {
await page.goto("/executive-meetings");
// The schedule's date is internal React state, not a URL param, so
// drive it through the actual `<input type="date">` in the toolbar
// (same control the user clicks). It's the only date input on the
// page until the user opens a side panel.
const dateInput = page.locator('input[type="date"]').first();
await dateInput.waitFor({ state: "visible", timeout: 10_000 });
await dateInput.fill(TEST_DATE);
await dateInput.dispatchEvent("change");
}
async function nextDailyNumberForDate(date) {
const { rows } = await pool.query(
`SELECT COALESCE(MAX(daily_number), 0) + 1 AS n
FROM executive_meetings
WHERE meeting_date = $1`,
[date],
);
return rows[0].n;
}
async function insertMeeting({ titleEn, startTime, endTime }) {
const dn = await nextDailyNumberForDate(TEST_DATE);
const { rows } = await pool.query(
`INSERT INTO executive_meetings
(daily_number, title_ar, title_en, meeting_date, start_time, end_time, status)
VALUES ($1, $2, $2, $3, $4, $5, 'scheduled')
RETURNING id`,
[dn, titleEn, TEST_DATE, startTime, endTime],
);
const id = rows[0].id;
createdMeetingIds.push(id);
return id;
}
async function readMeeting(id) {
const { rows } = await pool.query(
`SELECT start_time, end_time, daily_number FROM executive_meetings WHERE id = $1`,
[id],
);
return rows[0];
}
test.afterAll(async () => {
if (createdMeetingIds.length > 0) {
await pool.query(
`DELETE FROM executive_meeting_audit_logs WHERE entity_type = 'meeting' AND entity_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`,
[createdMeetingIds],
);
await pool.query(
`DELETE FROM executive_meetings WHERE id = ANY($1::int[])`,
[createdMeetingIds],
);
}
await pool.end();
});
test("row click opens quick-actions popover, Move up swaps times with neighbour", async ({
page,
}) => {
const aId = await insertMeeting({
titleEn: "QA Row Alpha",
startTime: "09:00:00",
endTime: "09:30:00",
});
const bId = await insertMeeting({
titleEn: "QA Row Bravo",
startTime: "10:00:00",
endTime: "10:30:00",
});
// admin/admin123 is the canonical seeded credential the rest of the
// executive-meetings e2e suite uses, and it has the mutate role
// (canMutate=true) without needing to flip on edit mode.
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page);
// Wait for both rows to render.
await expect(page.locator(`[data-testid="em-row-${aId}"]`)).toBeVisible({
timeout: 10_000,
});
await expect(page.locator(`[data-testid="em-row-${bId}"]`)).toBeVisible();
// Click the SECOND row (Bravo, 10:00) — it should expose Move up.
await page.locator(`[data-testid="em-row-${bId}"]`).click();
const popover = page.locator(`[data-testid="em-row-quick-${bId}"]`);
await expect(popover).toBeVisible();
await expect(
popover.locator(`[data-testid="em-row-quick-up-${bId}"]`),
).toBeEnabled();
await expect(
popover.locator(`[data-testid="em-row-quick-postpone-${bId}"]`),
).toBeVisible();
await popover.locator(`[data-testid="em-row-quick-up-${bId}"]`).click();
// Wait for the swap to land in the DB (refetch picks it up via the
// day-changed socket but we assert the DB directly for determinism).
await expect.poll(async () => (await readMeeting(bId)).start_time).toBe(
"09:00:00",
);
const aAfter = await readMeeting(aId);
const bAfter = await readMeeting(bId);
// Times swapped, daily numbers re-sorted by start time.
expect(aAfter.start_time).toBe("10:00:00");
expect(aAfter.end_time).toBe("10:30:00");
expect(bAfter.start_time).toBe("09:00:00");
expect(bAfter.end_time).toBe("09:30:00");
});
test("Postpone item from quick-actions popover opens the postpone dialog", async ({
page,
}) => {
const id = await insertMeeting({
titleEn: "QA Postpone Target",
startTime: "13:00:00",
endTime: "13:30:00",
});
await loginViaUi(page, "admin", "admin123");
await gotoTestDate(page);
await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({
timeout: 10_000,
});
await page.locator(`[data-testid="em-row-${id}"]`).click();
const popover = page.locator(`[data-testid="em-row-quick-${id}"]`);
await expect(popover).toBeVisible();
await popover.locator(`[data-testid="em-row-quick-postpone-${id}"]`).click();
// PostponeDialog is the same one upcoming-meeting-alert uses, so we
// assert by role=dialog presence rather than coupling to the
// dialog's internal markup.
await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5_000 });
});