#572 president-only focused view for executive meetings
User asked that when the President (`executive_ceo` role) logs into
the Meetings page he sees a clean "presentation" layout: just the
schedule table, an analog clock plus the weekday/date of the
schedule, and nothing else. The Manage / Notifications / Audit /
PDF tabs and the PDF-export shortcut button are noisy for him and
must disappear — but his backend permissions stay intact.
Changes (frontend only — backend role gates untouched):
- New component `components/executive-meetings/analog-clock.tsx`:
small SVG analog clock (hour/minute/second hands, navy + gold),
ticks every second, ~44px default.
- `pages/executive-meetings.tsx`:
- Derived `isPresidentView` from the `/me` roles: true iff
`executive_ceo` is present AND the user is NOT also `admin`
or `executive_office_manager` (admins keep full UI).
- Effect forces `section = "schedule"` whenever
`isPresidentView` becomes true, so the president can never
land on a now-hidden tab.
- Hides the header PDF-shortcut button and the entire SECTIONS
tab list when `isPresidentView`. The `#em-toolbar-portal` is
kept so the date picker still mounts.
- `ScheduleSection` accepts two new optional props:
`isPresidentView` and `lang`. When `isPresidentView` is on,
the toolbar renders the AnalogClock next to a label showing
`formatWeekday(date, lang, "long")` and `formatDate(date, lang)`
(e.g. "Sunday · May 17, 2026" / "الأحد · 17 مايو 2026"). The
label updates immediately when the date input changes.
- Edit-mode toggle stays gated on `canMutate`, so the president
(who has canMutate) still gets the inline-edit pencil if he
wants it.
Out of scope: backend role changes, applying the focused view to
other roles, manual presentation-mode toggle. Verified via tsc
(only the pre-existing `use-push-subscription` error remains).
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type AnalogClockProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AnalogClock({ size = 44, className = "" }: AnalogClockProps) {
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const hours = now.getHours() % 12;
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const hourAngle = (hours + minutes / 60) * 30;
|
||||
const minuteAngle = (minutes + seconds / 60) * 6;
|
||||
const secondAngle = seconds * 6;
|
||||
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const navy = "#0B1E3F";
|
||||
const gold = "#C9A04C";
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="analog clock"
|
||||
data-testid="em-analog-clock"
|
||||
>
|
||||
<circle cx={cx} cy={cy} r="46" fill="#ffffff" stroke={navy} strokeWidth="3" />
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const angle = (i * 30 * Math.PI) / 180;
|
||||
const x1 = cx + Math.sin(angle) * 40;
|
||||
const y1 = cy - Math.cos(angle) * 40;
|
||||
const x2 = cx + Math.sin(angle) * 44;
|
||||
const y2 = cy - Math.cos(angle) * 44;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={navy}
|
||||
strokeWidth={i % 3 === 0 ? 2.5 : 1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<g transform={`rotate(${hourAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 6} x2={cx} y2={cy - 24} stroke={navy} strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
<g transform={`rotate(${minuteAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 8} x2={cx} y2={cy - 34} stroke={navy} strokeWidth="2.75" strokeLinecap="round" />
|
||||
</g>
|
||||
<g transform={`rotate(${secondAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 10} x2={cx} y2={cy - 38} stroke={gold} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
<circle cx={cx} cy={cy} r="3" fill={navy} />
|
||||
<circle cx={cx} cy={cy} r="1.25" fill={gold} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,8 @@ 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 { AnalogClock } from "@/components/executive-meetings/analog-clock";
|
||||
import { formatDate, formatWeekday } from "@/lib/i18n-format";
|
||||
import { safeHtml, htmlToPlainText, wrapAsParagraph } from "@/lib/safe-html";
|
||||
import {
|
||||
ALERT_COLOR_PRESETS,
|
||||
@@ -850,6 +852,27 @@ function ExecutiveMeetingsPageInner() {
|
||||
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
|
||||
});
|
||||
|
||||
// #572: "President view" — a focused, presentation-style layout for users
|
||||
// whose primary role is the CEO/president (`executive_ceo`) and who are
|
||||
// NOT also wearing an admin hat (admin / office_manager). They get the
|
||||
// schedule table only — no sub-nav tabs, no PDF shortcut, no audit log —
|
||||
// plus an analog clock + weekday/date label in the toolbar. Their
|
||||
// permissions on the backend are untouched; this is purely a UI gate.
|
||||
const isPresidentView = useMemo(() => {
|
||||
const roles = me?.roles ?? [];
|
||||
if (!roles.includes("executive_ceo")) return false;
|
||||
return !roles.some((r) => r === "admin" || r === "executive_office_manager");
|
||||
}, [me]);
|
||||
|
||||
// Force the schedule section when the president lands on the page or
|
||||
// their role flips on mid-session, so they never get stuck on a now-
|
||||
// hidden tab.
|
||||
useEffect(() => {
|
||||
if (isPresidentView && section !== "schedule") {
|
||||
setSection("schedule");
|
||||
}
|
||||
}, [isPresidentView, section]);
|
||||
|
||||
const { data: fontResp } = useQuery<FontSettingsResponse>({
|
||||
queryKey: ["/api/executive-meetings/font-settings"],
|
||||
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
|
||||
@@ -939,23 +962,25 @@ function ExecutiveMeetingsPageInner() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setSection("pdf")}
|
||||
data-testid="em-export-shortcut"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
||||
</Button>
|
||||
{!isPresidentView && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setSection("pdf")}
|
||||
data-testid="em-export-shortcut"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
|
||||
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex items-center gap-1 sm:gap-2">
|
||||
<div className="flex gap-1 sm:gap-2 min-w-max">
|
||||
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
|
||||
{!isPresidentView && SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
|
||||
({ key, icon: Icon }) => {
|
||||
const active = section === key;
|
||||
return (
|
||||
@@ -999,6 +1024,8 @@ function ExecutiveMeetingsPageInner() {
|
||||
columns={columns}
|
||||
onColumnsChange={setColumns}
|
||||
highlightPrefs={highlightPrefs}
|
||||
isPresidentView={isPresidentView}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
{section === "manage" && me && (
|
||||
@@ -1057,6 +1084,8 @@ function ScheduleSection({
|
||||
columns,
|
||||
onColumnsChange,
|
||||
highlightPrefs,
|
||||
isPresidentView = false,
|
||||
lang = "en",
|
||||
}: {
|
||||
meetings: Meeting[];
|
||||
date: string;
|
||||
@@ -1070,6 +1099,8 @@ function ScheduleSection({
|
||||
columns: ColumnSetting[];
|
||||
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
|
||||
highlightPrefs: HighlightPrefs;
|
||||
isPresidentView?: boolean;
|
||||
lang?: "ar" | "en";
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
@@ -2645,6 +2676,22 @@ function ScheduleSection({
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
|
||||
/>
|
||||
</label>
|
||||
{isPresidentView && (
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 ps-2 sm:ps-3 ms-1 border-s border-gray-200"
|
||||
data-testid="em-president-clock-bar"
|
||||
>
|
||||
<AnalogClock size={40} />
|
||||
<div className="leading-tight">
|
||||
<div className="text-[#0B1E3F] font-semibold text-sm sm:text-base">
|
||||
{formatWeekday(date, lang, "long")}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums">
|
||||
{formatDate(date, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user