#576: diagnose push enable + open full edit page from quick actions
Three connected fixes on the Executive Meetings schedule.
1) Push enable diagnostics (use-push-subscription.ts):
- `enable()` now returns { ok, reason } instead of a bare boolean.
- Reasons: unsupported, unsupported_ios_safari, permission_denied,
vapid_unavailable, subscribe_failed, server_rejected.
- Detects iPhone/iPad Safari not running as a PWA (checks
navigator.standalone + display-mode media query, with iPadOS
MacIntel + maxTouchPoints fallback) and short-circuits with
unsupported_ios_safari so the user gets a concrete
"open from Home Screen first" message instead of a silent fail.
- Wrapped VAPID fetch and pushManager.subscribe in their own
try/catches so each failure mode maps to its own reason.
2) Specific failure toasts (push-enable-prompt.tsx,
notification-settings.tsx):
- Both call sites now look up
`notifSettings.push.errors.<reason>` and fall back to the
legacy generic copy if the key is missing.
- Added enableSuccess toast on the toggle path too — previously
only the prompt-card path showed feedback on success.
- New AR + EN strings under notifSettings.push.errors.*.
3) Quick-actions row sheet (executive-meetings.tsx):
- Edit + Postpone buttons now sit SIDE BY SIDE on every viewport
(flex + flex-1 + min-w-0 instead of flex-wrap + min-w-[11rem]),
same height, equal width — fixes the stacked / over-sized Edit
button shown in the user's iPhone screenshot.
- "Edit" no longer opens the small Meeting edit Dialog. It now
flips the whole schedule into edit mode (same as the toolbar
pencil) and paints a transient accent ring on the originating
row (~1.6s) + smooth-scrolls it into view. Matches image 3 in
#576 where the user expects to see the full editable list.
- Added `highlightEdit` prop on MeetingRow that composes an
extra inset+outer ring on top of the existing quickOpen /
selection / current / press shadows, plus a soft background
tint, and clears automatically when ScheduleSection clears
highlightEditRowId.
Out of scope (left for follow-up tasks): server-side push delivery
(payloads/sounds), the edit-mode UI itself.
This commit is contained in:
@@ -364,9 +364,21 @@ function PushToggleRow() {
|
||||
if (subscribed) {
|
||||
await disable();
|
||||
} else {
|
||||
const ok = await enable();
|
||||
if (!ok) {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
const res = await enable();
|
||||
if (res.ok) {
|
||||
toast({ title: t("notifSettings.push.enableSuccess") });
|
||||
} else {
|
||||
// #576: show the specific failure reason (iOS-needs-PWA,
|
||||
// permission denied, VAPID unreachable, subscribe failed,
|
||||
// server rejected) instead of a generic toast.
|
||||
const key = `notifSettings.push.errors.${res.reason}`;
|
||||
const msg = t(key);
|
||||
toast({
|
||||
title:
|
||||
msg && msg !== key
|
||||
? msg
|
||||
: t("notifSettings.push.enableFailed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -92,11 +92,22 @@ export function PushEnablePrompt() {
|
||||
disabled={busy}
|
||||
data-testid="push-enable-prompt-enable"
|
||||
onClick={async () => {
|
||||
const ok = await enable();
|
||||
if (ok) {
|
||||
const res = await enable();
|
||||
if (res.ok) {
|
||||
toast({ title: t("notifSettings.push.enableSuccess") });
|
||||
dismiss();
|
||||
} else {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
// #576: surface the SPECIFIC reason instead of a
|
||||
// generic "could not enable" toast. Falls back to
|
||||
// the legacy generic copy if the key is missing.
|
||||
const key = `notifSettings.push.errors.${res.reason}`;
|
||||
const msg = t(key);
|
||||
toast({
|
||||
title:
|
||||
msg && msg !== key
|
||||
? msg
|
||||
: t("notifSettings.push.enableFailed"),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
|
||||
@@ -8,6 +8,31 @@ type PushStatus =
|
||||
| "default"
|
||||
| "subscribed";
|
||||
|
||||
/**
|
||||
* Granular reason returned by `enable()` so the calling UI can show a
|
||||
* specific toast instead of a generic "could not enable" message.
|
||||
*
|
||||
* - `unsupported` — browser lacks ServiceWorker / PushManager / Notification.
|
||||
* - `unsupported_ios_safari` — iPhone/iPad Safari that isn't installed as a PWA.
|
||||
* iOS only delivers Web Push when the page runs
|
||||
* from the Home Screen (`navigator.standalone === true`).
|
||||
* - `permission_denied` — user (or OS) declined the system permission prompt.
|
||||
* - `vapid_unavailable` — backend `/api/push/vapid-public-key` is missing/down.
|
||||
* - `subscribe_failed` — `pushManager.subscribe()` threw (e.g. AbortError).
|
||||
* - `server_rejected` — backend `/api/push/subscribe` returned non-2xx.
|
||||
*/
|
||||
export type EnableReason =
|
||||
| "unsupported"
|
||||
| "unsupported_ios_safari"
|
||||
| "permission_denied"
|
||||
| "vapid_unavailable"
|
||||
| "subscribe_failed"
|
||||
| "server_rejected";
|
||||
|
||||
export type EnableResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: EnableReason };
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
@@ -17,6 +42,36 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the page is running on iPhone/iPad Safari that hasn't been
|
||||
* installed to the Home Screen. iOS Safari exposes the Push APIs even
|
||||
* in normal browsing, but `subscribe()` only succeeds in standalone
|
||||
* PWA mode — calling it from a regular tab fails silently in some
|
||||
* iOS builds, which is what was producing the generic
|
||||
* "تعذّر تفعيل الإشعارات" toast.
|
||||
*/
|
||||
function isIosSafariNonStandalone(): boolean {
|
||||
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const ua = navigator.userAgent || "";
|
||||
// iPhone / iPod / iPad. Modern iPadOS reports MacIntel + touch
|
||||
// support instead of "iPad", so we check for that too.
|
||||
const isIos =
|
||||
/iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.platform === "MacIntel" &&
|
||||
typeof (navigator as Navigator & { maxTouchPoints?: number })
|
||||
.maxTouchPoints === "number" &&
|
||||
(navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints >
|
||||
1);
|
||||
if (!isIos) return false;
|
||||
const standalone =
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true ||
|
||||
(typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(display-mode: standalone)").matches);
|
||||
return !standalone;
|
||||
}
|
||||
|
||||
function isSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -41,6 +96,12 @@ async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
* Web Push on iOS Safari only works when the page is installed as a
|
||||
* PWA (Add to Home Screen). The hook surfaces "unsupported" in that
|
||||
* case so the UI can prompt the user to install first.
|
||||
*
|
||||
* #576: `enable()` now returns `{ ok, reason }` instead of a bare
|
||||
* boolean so the calling UI can render a specific toast (iOS-needs-PWA,
|
||||
* permission denied, VAPID missing, subscribe failed, server rejected)
|
||||
* instead of a generic "could not enable" message that left the user
|
||||
* unable to diagnose why notifications wouldn't turn on.
|
||||
*/
|
||||
export function usePushSubscription() {
|
||||
const [status, setStatus] = useState<PushStatus>(() =>
|
||||
@@ -71,23 +132,40 @@ export function usePushSubscription() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const enable = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported()) return false;
|
||||
const enable = useCallback(async (): Promise<EnableResult> => {
|
||||
if (!isSupported()) {
|
||||
setStatus("unsupported");
|
||||
return { ok: false, reason: "unsupported" };
|
||||
}
|
||||
// #576: iOS Safari outside Home Screen — subscribe() will fail with
|
||||
// a confusing AbortError. Surface the real cause so the user knows
|
||||
// to install the PWA first.
|
||||
if (isIosSafariNonStandalone()) {
|
||||
return { ok: false, reason: "unsupported_ios_safari" };
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
setStatus(perm === "denied" ? "denied" : "default");
|
||||
return false;
|
||||
return { ok: false, reason: "permission_denied" };
|
||||
}
|
||||
const reg = await getRegistration();
|
||||
if (!reg) return false;
|
||||
if (!reg) return { ok: false, reason: "subscribe_failed" };
|
||||
|
||||
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!keyRes.ok) return false;
|
||||
const { publicKey } = (await keyRes.json()) as { publicKey: string };
|
||||
let publicKey: string;
|
||||
try {
|
||||
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!keyRes.ok) {
|
||||
return { ok: false, reason: "vapid_unavailable" };
|
||||
}
|
||||
publicKey = ((await keyRes.json()) as { publicKey: string }).publicKey;
|
||||
if (!publicKey) return { ok: false, reason: "vapid_unavailable" };
|
||||
} catch {
|
||||
return { ok: false, reason: "vapid_unavailable" };
|
||||
}
|
||||
|
||||
// If a stale subscription exists from a previous VAPID key,
|
||||
// unsubscribe locally first so applicationServerKey isn't
|
||||
@@ -101,10 +179,16 @@ export function usePushSubscription() {
|
||||
}
|
||||
}
|
||||
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
let sub: PushSubscription;
|
||||
try {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||
.buffer as ArrayBuffer,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, reason: "subscribe_failed" };
|
||||
}
|
||||
const json = sub.toJSON();
|
||||
const subRes = await fetch(`${BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
@@ -121,12 +205,12 @@ export function usePushSubscription() {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
return { ok: false, reason: "server_rejected" };
|
||||
}
|
||||
setStatus("subscribed");
|
||||
return true;
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return false;
|
||||
return { ok: false, reason: "subscribe_failed" };
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -234,7 +234,16 @@
|
||||
"enabled": "مفعّل",
|
||||
"denied": "محظور — فعّله من إعدادات الجهاز",
|
||||
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
|
||||
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى."
|
||||
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى.",
|
||||
"enableSuccess": "تم تفعيل الإشعارات",
|
||||
"errors": {
|
||||
"unsupported": "هذا المتصفح لا يدعم إشعارات شاشة القفل.",
|
||||
"unsupported_ios_safari": "على الآيفون/الآيباد، افتح Tx من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.",
|
||||
"permission_denied": "أذِن المتصفح للإشعارات مرفوض. افتح إعدادات الجهاز ← Tx وفعّل الإشعارات يدوياً.",
|
||||
"vapid_unavailable": "تعذّر الاتصال بخادم الإشعارات. تأكّد من اتصالك ثم حاول مرة أخرى.",
|
||||
"subscribe_failed": "تعذّر تسجيل الجهاز لاستقبال الإشعارات. حاول مرة أخرى.",
|
||||
"server_rejected": "رفض الخادم تسجيل هذا الجهاز. حاول مرة أخرى أو أعد تسجيل الدخول."
|
||||
}
|
||||
},
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
|
||||
@@ -240,7 +240,16 @@
|
||||
"enabled": "Enabled",
|
||||
"denied": "Blocked — enable from device settings",
|
||||
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
|
||||
"enableFailed": "Could not enable notifications. Try again."
|
||||
"enableFailed": "Could not enable notifications. Try again.",
|
||||
"enableSuccess": "Notifications enabled",
|
||||
"errors": {
|
||||
"unsupported": "This browser does not support lock-screen notifications.",
|
||||
"unsupported_ios_safari": "On iPhone/iPad, open Tx OS from its Home Screen icon first (Share → Add to Home Screen), then enable notifications from inside the app.",
|
||||
"permission_denied": "Notification permission is blocked. Open Settings → Tx OS on your device and enable notifications manually.",
|
||||
"vapid_unavailable": "Could not reach the notifications server. Check your connection and try again.",
|
||||
"subscribe_failed": "Could not register this device for notifications. Try again.",
|
||||
"server_rejected": "The server refused to register this device. Try again or sign in again."
|
||||
}
|
||||
},
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
|
||||
@@ -2185,6 +2185,40 @@ function ScheduleSection({
|
||||
setAutoEditTitleId(null);
|
||||
}, []);
|
||||
|
||||
// #576: When the user picks "Edit" from a row's quick-actions
|
||||
// dialog we no longer open a small Meeting edit Dialog — instead we
|
||||
// flip the WHOLE schedule into edit mode (same surface as the
|
||||
// toolbar pencil button) and gently scroll the originating row into
|
||||
// view with a temporary accent ring so the user immediately sees
|
||||
// which meeting their tap targeted. The ring auto-clears after
|
||||
// ~1.6s — long enough to register, short enough to not linger as
|
||||
// visual noise while the user starts editing other rows.
|
||||
const [highlightEditRowId, setHighlightEditRowId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (highlightEditRowId == null) return;
|
||||
if (!meetings.some((m) => m.id === highlightEditRowId)) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
const row = document.querySelector(
|
||||
`[data-testid="em-row-${highlightEditRowId}"]`,
|
||||
);
|
||||
if (row && "scrollIntoView" in row) {
|
||||
(row as HTMLElement).scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
const clear = window.setTimeout(() => {
|
||||
setHighlightEditRowId(null);
|
||||
}, 1600);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(clear);
|
||||
};
|
||||
}, [highlightEditRowId, meetings]);
|
||||
|
||||
// #489: Drag-from-anywhere on the row rotates meeting CONTENT through
|
||||
// the day's fixed time slots. The (startTime, endTime, dailyNumber)
|
||||
// tuple of each chronological slot stays anchored to its physical
|
||||
@@ -3020,33 +3054,23 @@ function ScheduleSection({
|
||||
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
|
||||
quickActionsCanMutate={canMutate && !editMode}
|
||||
onQuickPostpone={() => setPostponeMeetingId(m.id)}
|
||||
// #576: "Edit" from the quick-actions sheet now
|
||||
// turns the FULL schedule into edit mode (same as
|
||||
// the toolbar pencil) and asks the page to scroll
|
||||
// the originating row into view with a brief
|
||||
// accent ring, instead of opening a small Meeting
|
||||
// edit Dialog. Matches the behaviour the user
|
||||
// requested (image 3 in #576) where they see the
|
||||
// whole editable list, not a popup form.
|
||||
onQuickEdit={
|
||||
canMutate && !editMode
|
||||
? () =>
|
||||
setEditingMeeting({
|
||||
id: m.id,
|
||||
titleAr: htmlToPlainText(m.titleAr),
|
||||
titleEn: htmlToPlainText(m.titleEn ?? ""),
|
||||
meetingDate: m.meetingDate,
|
||||
dailyNumber: String(m.dailyNumber),
|
||||
startTime: m.startTime
|
||||
? m.startTime.slice(0, 5)
|
||||
: "",
|
||||
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
|
||||
location: m.location ?? "",
|
||||
meetingUrl: m.meetingUrl ?? "",
|
||||
platform: m.platform,
|
||||
status: m.status,
|
||||
isHighlighted: m.isHighlighted === 1,
|
||||
notes: m.notes ?? "",
|
||||
attendees: m.attendees.map((a) => ({
|
||||
...a,
|
||||
name: htmlToPlainText(a.name),
|
||||
_sid: genAttendeeSid(),
|
||||
})),
|
||||
})
|
||||
? () => {
|
||||
setEditMode(true);
|
||||
setHighlightEditRowId(m.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
highlightEdit={highlightEditRowId === m.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -3919,6 +3943,7 @@ function MeetingRow({
|
||||
quickActionsCanMutate,
|
||||
onQuickPostpone,
|
||||
onQuickEdit,
|
||||
highlightEdit = false,
|
||||
}: {
|
||||
meeting: Meeting;
|
||||
displayNumber?: number;
|
||||
@@ -3950,6 +3975,12 @@ function MeetingRow({
|
||||
// the Schedule tab can host the edit dialog without switching the
|
||||
// user to the Manage tab.
|
||||
onQuickEdit?: () => void;
|
||||
// #576: Transient visual cue applied when the user entered edit
|
||||
// mode via this row's quick-actions "Edit" button. ScheduleSection
|
||||
// sets it for ~1.6s on the originating row only so the user can
|
||||
// visually confirm which meeting their tap targeted now that the
|
||||
// whole schedule is editable.
|
||||
highlightEdit?: boolean;
|
||||
onSaveTitle: (html: string) => Promise<void>;
|
||||
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
|
||||
onSaveTimes: (
|
||||
@@ -4516,7 +4547,19 @@ function MeetingRow({
|
||||
const pressShadow = isPressing
|
||||
? `inset 0 0 0 2px ${hexToRgba(highlightColor, 0.45)}`
|
||||
: null;
|
||||
// #576: Brief accent ring on the row the user just opened from the
|
||||
// quick-actions "Edit" button. Mirrors quickOpenShadow's "outer
|
||||
// ring + inset" composition so it reads as clearly as the open
|
||||
// quick-actions surface did, but is purely transient — ScheduleSection
|
||||
// clears `highlightEdit` after ~1.6s.
|
||||
const editHighlightShadow = highlightEdit
|
||||
? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
|
||||
: null;
|
||||
const editHighlightBg = highlightEdit
|
||||
? hexToRgba(highlightColor, 0.08)
|
||||
: null;
|
||||
const composedShadow = [
|
||||
editHighlightShadow,
|
||||
quickOpenShadow,
|
||||
selectionShadow,
|
||||
currentShadow,
|
||||
@@ -4525,7 +4568,8 @@ function MeetingRow({
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const trStyle: CSSProperties = {
|
||||
backgroundColor: quickOpenBg ?? selectionBg ?? baseBg,
|
||||
backgroundColor:
|
||||
editHighlightBg ?? quickOpenBg ?? selectionBg ?? baseBg,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
|
||||
@@ -4658,7 +4702,14 @@ function MeetingRow({
|
||||
very narrow viewports. Edit button only renders when a
|
||||
handler is wired (defensive — quickActionsCanMutate
|
||||
already gates the surface itself). */}
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{/* #576: side-by-side, equal-width buttons (was flex-wrap +
|
||||
min-w-[11rem], which forced the buttons to STACK on the
|
||||
iPhone 6.1" dialog width and gave the Edit button a
|
||||
much larger visual weight than Postpone). `flex-1` +
|
||||
`min-w-0` lets both buttons share the available row
|
||||
width on every viewport (iPhone, iPad, desktop) with
|
||||
no wrapping. */}
|
||||
<div className="flex justify-center gap-2 pt-2">
|
||||
{onQuickEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -4668,11 +4719,11 @@ function MeetingRow({
|
||||
setQuickOpen(false);
|
||||
onQuickEdit();
|
||||
}}
|
||||
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
data-testid={`em-row-quick-edit-${meeting.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t("executiveMeetings.quickActions.edit")}</span>
|
||||
<Pencil className="w-4 h-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{t("executiveMeetings.quickActions.edit")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -4683,11 +4734,11 @@ function MeetingRow({
|
||||
setQuickOpen(false);
|
||||
onQuickPostpone?.();
|
||||
}}
|
||||
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
className={`flex-1 min-w-0 gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
data-testid={`em-row-quick-postpone-${meeting.id}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t("executiveMeetings.quickActions.postpone")}</span>
|
||||
<Clock className="w-4 h-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{t("executiveMeetings.quickActions.postpone")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user