#265: unify Executive Meetings header into single Settings tab

Header
- Removed the standalone Font Settings nav button and the bilingual
  language toggle. The header now exposes only Export PDF.
- Renamed the SECTIONS key `fontSettings` → `settings` (Settings icon
  retained) and updated visibility checks + render switch.

Settings tab
- New SettingsSection wraps the existing FontSettingsSection plus a
  new ColumnsCustomizerPanel (extracted from the old popover body) so
  column visibility / current-meeting highlight live with the rest of
  user prefs.
- Removed the old ColumnsCustomizer popover trigger from the schedule
  toolbar; the inline panel inside Settings is the single entry point.

State lifting
- columns/setColumns and highlightPrefs/setHighlightPrefs lifted from
  ScheduleSection up to ExecutiveMeetingsPage so both tabs read/write
  the same state. Storage keys (COLS_STORAGE_KEY, HIGHLIGHT_STORAGE_KEY)
  unchanged so existing user prefs continue to load.
- Per code-reviewer follow-up: moved the columns localStorage write
  effect up to the page as well so edits made in Settings persist even
  when ScheduleSection is unmounted.

Locales
- ar.json + en.json: renamed nav.fontSettings → nav.settings
  ("الإعدادات" / "Settings"); removed the now-unused
  executiveMeetings.fontSettings header label.
- Removed unused Languages and Type lucide imports.

Tests
- Updated executive-meetings-bulk-actions.spec.mjs and
  executive-meetings-schedule-features.spec.mjs to navigate via
  em-nav-settings instead of the removed em-customize-columns-trigger.
- All 226 sequential api tests pass; both updated playwright specs
  pass.
This commit is contained in:
riyadhafraa
2026-05-01 08:52:33 +00:00
parent 53d13a5939
commit 310baa41ab
6 changed files with 201 additions and 177 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+1 -2
View File
@@ -999,7 +999,6 @@
"scheduleHeading": "قائمة بأسماء حضور الاجتماعات",
"date": "التاريخ:",
"exportPdf": "تصدير PDF",
"fontSettings": "إعدادات الخط",
"noMeetings": "لا توجد اجتماعات في هذا اليوم",
"virtual": "اتصال مرئي",
"virtualAttendance": "الحضور عبر الاتصال المرئي",
@@ -1089,7 +1088,7 @@
"notifications": "التنبيهات",
"audit": "سجل التعديلات",
"pdf": "تصدير PDF",
"fontSettings": "إعدادات الخط"
"settings": "الإعدادات"
},
"common": {
"save": "حفظ",
+1 -2
View File
@@ -900,7 +900,6 @@
"scheduleHeading": "Meeting Attendance List",
"date": "Date:",
"exportPdf": "Export PDF",
"fontSettings": "Font Settings",
"noMeetings": "No meetings scheduled for this day",
"virtual": "Virtual",
"virtualAttendance": "Virtual Attendance",
@@ -990,7 +989,7 @@
"notifications": "Notifications",
"audit": "Audit Log",
"pdf": "PDF Export",
"fontSettings": "Font Settings"
"settings": "Settings"
},
"common": {
"save": "Save",
+189 -163
View File
@@ -5,7 +5,9 @@ import {
useRef,
useState,
type CSSProperties,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
@@ -15,8 +17,6 @@ import {
ArrowRight,
CalendarClock,
FileDown,
Type,
Languages,
ListChecks,
Bell,
ScrollText,
@@ -223,7 +223,7 @@ const SECTIONS = [
{ key: "notifications", icon: Bell },
{ key: "audit", icon: ScrollText },
{ key: "pdf", icon: FileText },
{ key: "fontSettings", icon: SettingsIcon },
{ key: "settings", icon: SettingsIcon },
] as const;
type SectionKey = (typeof SECTIONS)[number]["key"];
@@ -247,7 +247,7 @@ function isSectionVisible(
switch (key) {
case "schedule":
case "pdf":
case "fontSettings":
case "settings":
return me.canRead;
case "manage":
return me.canMutate;
@@ -498,6 +498,24 @@ export default function ExecutiveMeetingsPage() {
const [section, setSection] = useState<SectionKey>("schedule");
const [date, setDate] = useState<string>(todayIso());
// Lifted from ScheduleSection so the unified Settings tab can edit
// the same column visibility / highlight state the schedule consumes.
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
);
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
readJsonFromStorage<HighlightPrefs>(
HIGHLIGHT_STORAGE_KEY,
DEFAULT_HIGHLIGHT_PREFS,
),
);
useEffect(() => {
writeJsonToStorage(COLS_STORAGE_KEY, columns);
}, [columns]);
useEffect(() => {
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
const { data, isLoading } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", date],
queryFn: async () => {
@@ -578,24 +596,6 @@ export default function ExecutiveMeetingsPage() {
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("fontSettings")}
>
<Type className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.fontSettings")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => i18n.changeLanguage(isRtl ? "en" : "ar")}
>
<Languages className="w-4 h-4" />
<span className="text-xs">{isRtl ? "EN" : "ع"}</span>
</Button>
</div>
</div>
@@ -639,6 +639,9 @@ export default function ExecutiveMeetingsPage() {
font={effectiveFont}
canMutate={!!me?.canMutate}
userId={me?.userId ?? null}
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
/>
)}
{section === "manage" && me && (
@@ -658,10 +661,14 @@ export default function ExecutiveMeetingsPage() {
{section === "pdf" && (
<PdfSection date={date} onDateChange={setDate} lang={lang} t={t} />
)}
{section === "fontSettings" && me && (
<FontSettingsSection
{section === "settings" && me && (
<SettingsSection
font={effectiveFont}
canEditGlobal={me.canEditGlobalFontSettings}
canEditGlobalFont={me.canEditGlobalFontSettings}
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={setHighlightPrefs}
t={t}
/>
)}
@@ -682,6 +689,9 @@ function ScheduleSection({
font,
canMutate,
userId,
columns,
onColumnsChange,
highlightPrefs,
}: {
meetings: Meeting[];
date: string;
@@ -692,26 +702,17 @@ function ScheduleSection({
font: FontPrefs;
canMutate: boolean;
userId: number | null;
columns: ColumnSetting[];
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
highlightPrefs: HighlightPrefs;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const tableStyle = buildFontStyle(font);
const [columns, setColumns] = useState<ColumnSetting[]>(() =>
normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
);
const setColumns = onColumnsChange;
const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
readJsonFromStorage<Record<number, string>>(ROW_COLORS_STORAGE_KEY, {}),
);
const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
readJsonFromStorage<HighlightPrefs>(
HIGHLIGHT_STORAGE_KEY,
DEFAULT_HIGHLIGHT_PREFS,
),
);
useEffect(() => {
writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
}, [highlightPrefs]);
// Global "Edit / View" toggle for the schedule. The default is view mode
// (no edit affordances). When a user with edit permission flips it on,
@@ -1484,9 +1485,10 @@ function ScheduleSection({
[meetings, date, refreshDay, toast, t],
);
useEffect(() => {
writeJsonToStorage(COLS_STORAGE_KEY, columns);
}, [columns]);
// #265: columns persistence is owned by ExecutiveMeetingsPage now that
// both Schedule and Settings tabs can mutate the array. Keeping the
// write effect here would silently drop edits made from the Settings
// tab while ScheduleSection is unmounted.
useEffect(() => {
writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors);
@@ -1633,13 +1635,6 @@ function ScheduleSection({
</span>
</button>
)}
<ColumnsCustomizer
columns={columns}
onChange={setColumns}
t={t}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={setHighlightPrefs}
/>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
@@ -1939,7 +1934,7 @@ function ResizeHandle({
);
}
function ColumnsCustomizer({
function ColumnsCustomizerPanel({
columns,
onChange,
t,
@@ -1962,124 +1957,108 @@ function ColumnsCustomizer({
const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c })));
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
data-testid="em-customize-columns-trigger"
>
<Sliders className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.customize.title")}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="end">
<div className="text-sm font-semibold text-[#0B1E3F] mb-2">
{t("executiveMeetings.customize.title")}
</div>
<p className="text-[11px] text-muted-foreground mb-3 leading-relaxed">
{t("executiveMeetings.customize.hint")}
</p>
<ul className="space-y-1.5">
{columns.map((c) => (
<li
key={c.id}
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
data-testid={`em-customize-row-${c.id}`}
>
<Checkbox
id={`em-col-${c.id}`}
checked={c.visible}
onCheckedChange={(v) => toggle(c.id, !!v)}
data-testid={`em-customize-toggle-${c.id}`}
/>
<label
htmlFor={`em-col-${c.id}`}
className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
>
{t(`executiveMeetings.col.${c.id}`)}
</label>
{!c.visible ? (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
)}
</li>
))}
</ul>
<div className="mt-3 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs"
onClick={reset}
data-testid="em-customize-reset"
<div data-testid="em-customize-columns-panel">
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("executiveMeetings.customize.hint")}
</p>
<ul className="space-y-1.5">
{columns.map((c) => (
<li
key={c.id}
className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
data-testid={`em-customize-row-${c.id}`}
>
<RotateCcw className="w-3.5 h-3.5" />
{t("executiveMeetings.customize.reset")}
</Button>
<span className="text-[11px] text-muted-foreground">
{t("executiveMeetings.customize.dragHint")}
</span>
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label
htmlFor="em-highlight-toggle"
className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
>
{t("executiveMeetings.highlight.title")}
</label>
<Switch
id="em-highlight-toggle"
checked={highlightPrefs.enabled}
onCheckedChange={(v) =>
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
}
data-testid="em-highlight-toggle"
<Checkbox
id={`em-col-${c.id}`}
checked={c.visible}
onCheckedChange={(v) => toggle(c.id, !!v)}
data-testid={`em-customize-toggle-${c.id}`}
/>
</div>
<p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
{t("executiveMeetings.highlight.hint")}
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{HIGHLIGHT_COLOR_SWATCHES.map((c) => {
const selected = highlightPrefs.color === c;
return (
<button
key={c}
type="button"
onClick={() =>
onHighlightPrefsChange({ ...highlightPrefs, color: c })
}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{ backgroundColor: c }}
aria-label={`highlight color ${c}`}
data-testid={`em-highlight-color-${c}`}
disabled={!highlightPrefs.enabled}
/>
);
})}
<button
type="button"
onClick={() =>
onHighlightPrefsChange({ ...DEFAULT_HIGHLIGHT_PREFS })
}
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline disabled:opacity-50"
data-testid="em-highlight-reset"
<label
htmlFor={`em-col-${c.id}`}
className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
>
{t("executiveMeetings.highlight.reset")}
</button>
</div>
{t(`executiveMeetings.col.${c.id}`)}
</label>
{!c.visible ? (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
)}
</li>
))}
</ul>
<div className="mt-3 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-xs"
onClick={reset}
data-testid="em-customize-reset"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("executiveMeetings.customize.reset")}
</Button>
<span className="text-[11px] text-muted-foreground">
{t("executiveMeetings.customize.dragHint")}
</span>
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between mb-2">
<label
htmlFor="em-highlight-toggle"
className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
>
{t("executiveMeetings.highlight.title")}
</label>
<Switch
id="em-highlight-toggle"
checked={highlightPrefs.enabled}
onCheckedChange={(v) =>
onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
}
data-testid="em-highlight-toggle"
/>
</div>
</PopoverContent>
</Popover>
<p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
{t("executiveMeetings.highlight.hint")}
</p>
<div className="flex items-center gap-1.5 flex-wrap">
{HIGHLIGHT_COLOR_SWATCHES.map((c) => {
const selected = highlightPrefs.color === c;
return (
<button
key={c}
type="button"
onClick={() =>
onHighlightPrefsChange({ ...highlightPrefs, color: c })
}
className={
"w-6 h-6 rounded-full border-2 transition-transform " +
(selected
? "border-[#0B1E3F] scale-110"
: "border-gray-300 hover:scale-110")
}
style={{ backgroundColor: c }}
aria-label={`highlight color ${c}`}
data-testid={`em-highlight-color-${c}`}
disabled={!highlightPrefs.enabled}
/>
);
})}
<button
type="button"
onClick={() =>
onHighlightPrefsChange({ ...DEFAULT_HIGHLIGHT_PREFS })
}
className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline disabled:opacity-50"
data-testid="em-highlight-reset"
>
{t("executiveMeetings.highlight.reset")}
</button>
</div>
</div>
</div>
);
}
@@ -5629,6 +5608,53 @@ function PdfSection({
);
}
// ============ SETTINGS ============
// #265: Unified settings tab — wraps font preferences plus the column /
// highlight customizer that used to live in a popover above the schedule.
function SettingsSection({
font,
canEditGlobalFont,
columns,
onColumnsChange,
highlightPrefs,
onHighlightPrefsChange,
t,
}: {
font: FontPrefs;
canEditGlobalFont: boolean;
columns: ColumnSetting[];
onColumnsChange: (next: ColumnSetting[]) => void;
highlightPrefs: HighlightPrefs;
onHighlightPrefsChange: (next: HighlightPrefs) => void;
t: (k: string) => string;
}) {
return (
<div className="space-y-6">
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<h2 className="text-base font-semibold text-[#0B1E3F] mb-3 flex items-center gap-2">
<Sliders className="w-4 h-4" />
{t("executiveMeetings.customize.title")}
</h2>
<ColumnsCustomizerPanel
columns={columns}
onChange={onColumnsChange}
t={t}
highlightPrefs={highlightPrefs}
onHighlightPrefsChange={onHighlightPrefsChange}
/>
</section>
<section className="bg-white border border-gray-200 rounded-lg p-4 sm:p-6">
<FontSettingsSection
font={font}
canEditGlobal={canEditGlobalFont}
t={t}
/>
</section>
</div>
);
}
// ============ FONT SETTINGS ============
function FontSettingsSection({
@@ -316,11 +316,11 @@ test("Schedule: per-row select checkbox stays usable even when the # column is h
});
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
// Hide the # column via the customize-columns popover.
await page.getByTestId("em-customize-columns-trigger").click();
// #265: Customize-columns now lives in the Settings tab.
// Hop over, toggle # off, hop back to the schedule.
await page.getByTestId("em-nav-settings").click();
await page.getByTestId("em-customize-toggle-number").click();
// Close the popover by clicking the table area.
await page.keyboard.press("Escape");
await page.getByTestId("em-nav-schedule").click();
// Enter edit mode. Per-row checkboxes must still appear (now as
// overlays in the first visible cell), and selecting them must
@@ -341,9 +341,9 @@ test("Schedule: per-row select checkbox stays usable even when the # column is h
// Restore the # column so we leave the persisted UI prefs in a
// sane state for the next test.
await page.getByTestId("em-customize-columns-trigger").click();
await page.getByTestId("em-nav-settings").click();
await page.getByTestId("em-customize-toggle-number").click();
await page.keyboard.press("Escape");
await page.getByTestId("em-nav-schedule").click();
});
// Regression guard for merged rows. Earlier the per-row bulk-select
@@ -452,8 +452,9 @@ test("Schedule: choosing a custom highlight color paints the current meeting's b
// Computed style normalizes hex to rgb; #16a34a → rgb(22, 163, 74).
expect(initialShadow).toMatch(/22,\s*163,\s*74/);
// Now open the customize popover and pick the red swatch (#dc2626).
await page.getByTestId("em-customize-columns-trigger").click();
// #265: Highlight controls now live in the unified Settings tab.
// Hop over, flip the swatch, hop back to the schedule.
await page.getByTestId("em-nav-settings").click();
const highlightToggle = page.getByTestId("em-highlight-toggle");
await expect(highlightToggle).toBeVisible();
// Make sure the highlight is enabled (the default is on, but assert
@@ -465,8 +466,7 @@ test("Schedule: choosing a custom highlight color paints the current meeting's b
}
await page.getByTestId("em-highlight-color-#dc2626").click();
// Close the popover so it doesn't overlap further assertions.
await page.keyboard.press("Escape");
await page.getByTestId("em-nav-schedule").click();
// The ring should now reflect the new color: #dc2626 → rgb(220, 38, 38).
await expect