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:
Riyadh
2026-05-03 12:43:02 +00:00
parent ab812fc5d9
commit 845fd9fe29
3 changed files with 0 additions and 276 deletions
-16
View File
@@ -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": "تمت استعادة الإعدادات الافتراضية"
}
}
}
-16
View File
@@ -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 ============