diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index eba82bac..e3d2bc44 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1515,7 +1515,28 @@ router.get( router.get( "/executive-meetings/notifications", requireExecutiveAccess, - async (_req, res): Promise => { + async (req, res): Promise => { + // Optional ?date=YYYY-MM-DD filter joins through the meetings table so + // the schedule's "selected date" can scope the notifications view, as + // required by Phase 2. + const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; + const conds: SQL[] = []; + let meetingIdsForDate: number[] | null = null; + if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { + const meetingsOnDate = await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, dateRaw)); + meetingIdsForDate = meetingsOnDate.map((m) => m.id); + if (meetingIdsForDate.length === 0) { + res.json({ notifications: [] }); + return; + } + conds.push( + inArray(executiveMeetingNotificationsTable.meetingId, meetingIdsForDate), + ); + } + const where = conds.length > 0 ? and(...conds) : undefined; const rows = await db .select({ n: executiveMeetingNotificationsTable, @@ -1525,6 +1546,7 @@ router.get( }) .from(executiveMeetingNotificationsTable) .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId)) + .where(where) .orderBy(desc(executiveMeetingNotificationsTable.createdAt)) .limit(200); res.json({ diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 3e56e0a3..f3961e1c 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -193,6 +193,47 @@ const SECTIONS = [ type SectionKey = (typeof SECTIONS)[number]["key"]; +type MeCapabilities = { + userId: number; + roles: string[]; + canRead: boolean; + canMutate: boolean; + canApprove: boolean; + canSubmitRequest: boolean; + canViewAudit: boolean; +}; + +// Decides whether a navigation tab should be visible at all for the current +// user. Hides admin-only / reviewer-only tabs from low-privilege users so they +// never see actions they can't perform — addresses the reviewer's RBAC/UI +// concern about overexposed navigation. +function isSectionVisible( + key: SectionKey, + me: MeCapabilities | null | undefined, +): boolean { + if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule. + switch (key) { + case "schedule": + case "pdf": + case "fontSettings": + return me.canRead; + case "manage": + return me.canMutate; + case "requests": + return me.canSubmitRequest || me.canApprove; + case "approvals": + return me.canApprove; + case "tasks": + return me.canMutate || me.canApprove; + case "notifications": + return me.canRead; + case "audit": + return me.canViewAudit; + default: + return false; + } +} + const DEFAULT_FONT: FontPrefs = { fontFamily: "system", fontSize: 14, @@ -228,17 +269,31 @@ function buildFontStyle(prefs: FontPrefs): CSSProperties { }; } +// Type-safe wrapper around i18next's `t` that returns the fallback when the +// key is missing instead of echoing the raw key. Avoids the strict TFunction +// overload errors when calling `t(key, { defaultValue })` directly. +function tWithFallback( + t: (key: string) => string, + key: string, + fallback: string, +): string { + const value = t(key); + return value === key ? fallback : value; +} + async function apiJson( url: string, - init?: RequestInit & { body?: unknown }, + init?: Omit & { body?: unknown }, ): Promise { + const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {}; const opts: RequestInit = { credentials: "include", - headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, - ...(init ?? {}), + ...rest, + headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) }, }; - if (init?.body !== undefined && typeof init.body !== "string") { - opts.body = JSON.stringify(init.body); + if (rawBody !== undefined) { + opts.body = + typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody); } const res = await fetch(url, opts); if (res.status === 204) return undefined as T; @@ -362,26 +417,28 @@ export default function ExecutiveMeetingsPage() { @@ -416,7 +473,12 @@ export default function ExecutiveMeetingsPage() { /> )} {section === "notifications" && me && ( - + )} {section === "audit" && me && ( @@ -1153,7 +1215,12 @@ function RequestsSection({ const qc = useQueryClient(); const { toast } = useToast(); const [filterStatus, setFilterStatus] = useState("all"); - const [scope, setScope] = useState<"all" | "mine">("all"); + // Default scope = "mine" for plain requesters; only reviewers/admins land + // on "all" so requests are not over-exposed to roles that should primarily + // see their own submissions. + const [scope, setScope] = useState<"all" | "mine">( + me.canApprove ? "all" : "mine", + ); const [open, setOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState<{ @@ -2186,12 +2253,18 @@ function AuditSection({ {actor} - {t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })} + {tWithFallback( + t, + `executiveMeetings.audit.action.${e.action}`, + e.action, + )} - {t(`executiveMeetings.audit.entity.${e.entityType}`, { - defaultValue: e.entityType, - })} + {tWithFallback( + t, + `executiveMeetings.audit.entity.${e.entityType}`, + e.entityType, + )} {e.entityId ?? "—"} @@ -2215,16 +2288,25 @@ function AuditSection({ function NotificationsSection({ canView, + date, isRtl, t, }: { canView: boolean; + date: string; isRtl: boolean; t: (k: string) => string; }) { + // Notifications are scoped by the same selected date as the schedule, so + // managers see only what is relevant to the day they are reviewing. const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({ - queryKey: ["/api/executive-meetings/notifications"], - queryFn: () => apiJson(`/api/executive-meetings/notifications`), + queryKey: ["/api/executive-meetings/notifications", date], + queryFn: () => + apiJson( + `/api/executive-meetings/notifications${ + date ? `?date=${encodeURIComponent(date)}` : "" + }`, + ), enabled: canView, }); if (!canView) return ; @@ -2274,17 +2356,21 @@ function NotificationsSection({ {n.meetingId ? `#${n.meetingId}` : "—"} {user} - {t(`executiveMeetings.notificationsPage.type.${n.notificationType}`, { - defaultValue: n.notificationType, - })} + {tWithFallback( + t, + `executiveMeetings.notificationsPage.type.${n.notificationType}`, + n.notificationType, + )} {n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"} - {t(`executiveMeetings.notificationsPage.status.${n.status}`, { - defaultValue: n.status, - })} + {tWithFallback( + t, + `executiveMeetings.notificationsPage.status.${n.status}`, + n.status, + )} );