EM Notifications tab: remove header + intro + per-user prefs card (#345)
The user pointed out that the "بريد إلكتروني" channel they could see in
the per-user "تفضيلات التنبيهات الخاصة بي" card is not part of the
product (no email delivery wired up), so exposing the toggle was
misleading. They sent a screenshot and asked to delete everything in
it: the page heading "التنبيهات", the intro paragraph, and the entire
prefs card (event row + in-app/email switches + Save / Reset / Restore
defaults buttons).
Changes:
- artifacts/tx-os/src/pages/executive-meetings.tsx
- Removed the <h2> heading, the <p> intro, and the
<NotificationPrefsCard /> render call from the notifications page
component. The audit-log table below stays untouched.
- Deleted the NotificationPrefsCard component definition and the
NotificationPref / NotificationPrefsResponse types (only consumers
were inside the deleted card).
- artifacts/tx-os/src/locales/{ar,en}.json
- Removed executiveMeetings.notificationsPage.heading,
executiveMeetings.notificationsPage.intro, and the entire
executiveMeetings.notificationsPage.prefs.* subtree.
- Kept notificationsPage.col.*, type.*, status.*, empty — still used
by the audit-log table.
Out of scope (intentionally not touched):
- Backend /api/executive-meetings/notification-prefs routes and the
email channel in fan-out helpers. The backend defaults are left
intact so notification fan-out keeps working; only the UI surface
is removed.
- Tests: ripgrep showed no references to the removed test-ids
(em-notification-prefs, em-pref-inapp-*, em-pref-email-*,
em-pref-save, em-pref-reset, em-pref-restore-defaults) in the
Playwright/unit suites, so nothing to update.
Verification:
- pnpm -C artifacts/tx-os exec tsc --noEmit clean.
- Switch import retained: still used by row-color popover and another
call site in the same file.
- The api-server workflow and combined `test` workflow are red with
pre-existing upstream failures unrelated to this UI-only change.
This commit is contained in:
@@ -1419,8 +1419,6 @@
|
||||
"reset": "إعادة الافتراضي"
|
||||
},
|
||||
"notificationsPage": {
|
||||
"heading": "التنبيهات",
|
||||
"intro": "تنبيهات فورية لاجتماعات المكتب — تُسلَّم إلى لوحة التنبيهات داخل النظام عند وقوع الأحداث، ويُرسل بريد للمعتمدين عند وجود طلب بانتظار المراجعة.",
|
||||
"empty": "لا توجد تنبيهات بعد.",
|
||||
"col": {
|
||||
"meeting": "الاجتماع",
|
||||
@@ -1440,20 +1438,6 @@
|
||||
"sent": "أُرسل",
|
||||
"skipped": "متخطى",
|
||||
"failed": "فشل"
|
||||
},
|
||||
"prefs": {
|
||||
"heading": "تفضيلات التنبيهات الخاصة بي",
|
||||
"intro": "اختر الأحداث التي تريد تنبيهات داخل النظام لها وأيها يصلك عبر البريد. إيقاف المفتاح يكتم تلك القناة لذلك النوع من الأحداث فقط.",
|
||||
"col": {
|
||||
"event": "الحدث",
|
||||
"inApp": "داخل النظام",
|
||||
"email": "بريد إلكتروني"
|
||||
},
|
||||
"save": "حفظ التفضيلات",
|
||||
"reset": "إعادة",
|
||||
"saved": "تم حفظ التفضيلات",
|
||||
"restoreDefaults": "استعادة الإعدادات الافتراضية",
|
||||
"restored": "تمت استعادة الإعدادات الافتراضية"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1285,8 +1285,6 @@
|
||||
"reset": "Reset to default"
|
||||
},
|
||||
"notificationsPage": {
|
||||
"heading": "Notifications",
|
||||
"intro": "Real-time alerts for executive meetings — delivered to the in-app notifications panel as events happen, and emailed to approvers when a request is awaiting review.",
|
||||
"empty": "No notifications yet.",
|
||||
"col": {
|
||||
"meeting": "Meeting",
|
||||
@@ -1306,20 +1304,6 @@
|
||||
"sent": "Sent",
|
||||
"skipped": "Skipped",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"prefs": {
|
||||
"heading": "My notification preferences",
|
||||
"intro": "Choose which executive-meeting events ping you in-app and which ones email you. Turning a switch off mutes that channel for that event type only.",
|
||||
"col": {
|
||||
"event": "Event",
|
||||
"inApp": "In-app",
|
||||
"email": "Email"
|
||||
},
|
||||
"save": "Save preferences",
|
||||
"reset": "Reset",
|
||||
"saved": "Preferences saved",
|
||||
"restoreDefaults": "Restore defaults",
|
||||
"restored": "Preferences reset to defaults"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6881,15 +6881,6 @@ function NotificationsSection({
|
||||
const rows = data?.notifications ?? [];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.notificationsPage.heading")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 max-w-2xl">
|
||||
{t("executiveMeetings.notificationsPage.intro")}
|
||||
</p>
|
||||
|
||||
<NotificationPrefsCard isRtl={isRtl} t={t} />
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<thead className="bg-gray-50 text-[#0B1E3F]">
|
||||
@@ -6953,241 +6944,6 @@ function NotificationsSection({
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationPref = {
|
||||
notificationType: string;
|
||||
inApp: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
|
||||
type NotificationPrefsResponse = {
|
||||
types: string[];
|
||||
prefs: NotificationPref[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-user notification preferences card. Toggles in-app vs email
|
||||
* delivery for each event type the Executive Meetings module fans out.
|
||||
*
|
||||
* Rendering rules:
|
||||
* - One row per event type returned by the server (server is the
|
||||
* source of truth for "which event types exist").
|
||||
* - Both channels default ON; an explicit off toggle persists a
|
||||
* mute-row server-side so the fan-out helpers skip the user.
|
||||
* - Saves are batched: we accumulate edits in local state and flush
|
||||
* them together via "Save preferences".
|
||||
*/
|
||||
function NotificationPrefsCard({
|
||||
isRtl,
|
||||
t,
|
||||
}: {
|
||||
isRtl: boolean;
|
||||
t: (k: string) => string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery<NotificationPrefsResponse>({
|
||||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||||
queryFn: () =>
|
||||
apiJson<NotificationPrefsResponse>(
|
||||
"/api/executive-meetings/notification-prefs",
|
||||
),
|
||||
});
|
||||
const [draft, setDraft] = useState<Map<string, NotificationPref> | null>(
|
||||
null,
|
||||
);
|
||||
// When the server payload arrives (or refetches), seed local edit state.
|
||||
// We only seed when local state is null so that an in-flight edit isn't
|
||||
// clobbered by a background refetch.
|
||||
useEffect(() => {
|
||||
if (!data || draft) return;
|
||||
const m = new Map<string, NotificationPref>();
|
||||
for (const p of data.prefs) m.set(p.notificationType, { ...p });
|
||||
setDraft(m);
|
||||
}, [data, draft]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!data || !draft) return false;
|
||||
for (const p of data.prefs) {
|
||||
const cur = draft.get(p.notificationType);
|
||||
if (!cur) continue;
|
||||
if (cur.inApp !== p.inApp || cur.email !== p.email) return true;
|
||||
}
|
||||
return false;
|
||||
}, [data, draft]);
|
||||
|
||||
function update(type: string, channel: "inApp" | "email", v: boolean) {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = new Map(prev);
|
||||
const cur = next.get(type);
|
||||
if (!cur) return prev;
|
||||
next.set(type, { ...cur, [channel]: v });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
const prefs = Array.from(draft.values());
|
||||
try {
|
||||
await apiJson("/api/executive-meetings/notification-prefs", {
|
||||
method: "PUT",
|
||||
body: { prefs },
|
||||
});
|
||||
await qc.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||||
});
|
||||
// Clear local state so the next render reseeds from the freshly
|
||||
// refetched payload — keeps client and server in lockstep.
|
||||
setDraft(null);
|
||||
toast({ title: t("executiveMeetings.notificationsPage.prefs.saved") });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!data) return;
|
||||
const m = new Map<string, NotificationPref>();
|
||||
for (const p of data.prefs) m.set(p.notificationType, { ...p });
|
||||
setDraft(m);
|
||||
}
|
||||
|
||||
// #236: wipe every server-side pref row for the current user. The GET
|
||||
// handler then synthesizes the defaults (in_app=true, email=true) for
|
||||
// every event type, so a single round-trip restores the factory state
|
||||
// without the client having to PUT a full payload of `true` toggles.
|
||||
async function restoreDefaults() {
|
||||
try {
|
||||
await apiJson("/api/executive-meetings/notification-prefs", {
|
||||
method: "DELETE",
|
||||
});
|
||||
// #236: invalidate FIRST so the refetch lands before we drop the
|
||||
// local draft. If we cleared draft first, the reseed effect at
|
||||
// line ~6284 would fire against the still-cached pre-delete
|
||||
// payload, repopulating draft from stale data; the later refetch
|
||||
// would then no-op because draft is non-null again.
|
||||
await qc.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings/notification-prefs"],
|
||||
});
|
||||
setDraft(null);
|
||||
toast({ title: t("executiveMeetings.notificationsPage.prefs.restored") });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({ title: msg, variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
const types = data?.types ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-md p-4"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
data-testid="em-notification-prefs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[#0B1E3F]">
|
||||
{t("executiveMeetings.notificationsPage.prefs.heading")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
|
||||
{t("executiveMeetings.notificationsPage.prefs.intro")}
|
||||
</p>
|
||||
{isLoading || !draft ? (
|
||||
<div className="py-4 text-sm text-gray-500">{t("common.loading")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-start font-medium">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.event")}
|
||||
</th>
|
||||
<th className="px-2 py-1 text-center font-medium w-24">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.inApp")}
|
||||
</th>
|
||||
<th className="px-2 py-1 text-center font-medium w-24">
|
||||
{t("executiveMeetings.notificationsPage.prefs.col.email")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{types.map((type) => {
|
||||
const cur = draft.get(type);
|
||||
if (!cur) return null;
|
||||
return (
|
||||
<tr key={type} className="border-t border-gray-100">
|
||||
<td className="px-2 py-2 text-[#0B1E3F]">
|
||||
{tWithFallback(
|
||||
t,
|
||||
`executiveMeetings.notificationsPage.type.${type}`,
|
||||
type,
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Switch
|
||||
checked={cur.inApp}
|
||||
onCheckedChange={(v) => update(type, "inApp", !!v)}
|
||||
aria-label={t(
|
||||
"executiveMeetings.notificationsPage.prefs.col.inApp",
|
||||
)}
|
||||
data-testid={`em-pref-inapp-${type}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<Switch
|
||||
checked={cur.email}
|
||||
onCheckedChange={(v) => update(type, "email", !!v)}
|
||||
aria-label={t(
|
||||
"executiveMeetings.notificationsPage.prefs.col.email",
|
||||
)}
|
||||
data-testid={`em-pref-email-${type}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-pref-save"
|
||||
>
|
||||
{t("executiveMeetings.notificationsPage.prefs.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
disabled={!dirty}
|
||||
data-testid="em-pref-reset"
|
||||
>
|
||||
{t("executiveMeetings.notificationsPage.prefs.reset")}
|
||||
</Button>
|
||||
{/* #236: restore-defaults wipes server rows even when the
|
||||
local draft is clean, so it must NOT be gated on `dirty`. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={restoreDefaults}
|
||||
data-testid="em-pref-restore-defaults"
|
||||
>
|
||||
{t("executiveMeetings.notificationsPage.prefs.restoreDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ PDF / PRINT ============
|
||||
|
||||
|
||||
Reference in New Issue
Block a user