Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight
only. Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full
rewrite:
- ALL routes (including /me) gated by requireExecutiveAccess plus fine-grained
requireMutate / requireApprove / requireRequest / requireAdminAudit on the
appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired DIRECTLY to a
single shared upsertFontSettingsHandler (no Express method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- GET /notifications now accepts ?date=YYYY-MM-DD and joins through the
meetings table to scope notifications to the selected day; returns []
immediately when no meetings exist on that date.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES (admin +
executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Tab-level RBAC: SECTIONS.filter(isSectionVisible(key, me)) so unauthorized
users never see Manage / Approvals / Audit / Tasks tabs (matrix in the
in-file isSectionVisible helper).
- Approvals: review() sends {status, reviewNotes}; new "Send back for edits"
(needs_edit) button beside Approve / Reject.
- Requests default scope is "mine" for non-approvers and "all" for approvers
(no over-exposure to plain requesters).
- Notifications visibility uses canRead AND the query is scoped by the same
selected `date` as the schedule (queryKey + ?date=… included).
- PdfSection: primary "Print + Archive" button (testid em-print) does archive
then print in one click; em-print-only and em-archive remain as separate
buttons.
- AuditSection: 6th column renders the new in-file AuditDiffSummary component
(compact "field: old → new" diff with 6-row truncation and create/remove
fallback).
- New tWithFallback(t, key, fallback) helper used in audit + notifications
cells (avoids strict TFunction overload errors); apiJson rewritten to
extract body/headers separately and stringify rawBody, eliminating the
"Record<string, unknown> not assignable to BodyInit" TS errors. tsc
--noEmit on executive-meetings.tsx is now clean.
- Print page (executive-meetings-print.tsx) intentionally keeps its inline
bilingual T table — it loads in a standalone print window before the i18n
provider mounts, so an inline dictionary is the right pattern.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply, duplicate,
replace_attendees, done},
.pdf.{print = "Print + archive" / "طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201, nested
POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data, /notifications?date=YYYY-MM-DD filters
correctly.
- tsc --noEmit on artifacts/tx-os/src/pages/executive-meetings.tsx is
clean (pre-existing admin.tsx errors are unrelated to this task).
- Architect review issued VERDICT: APPROVED on the previous round; only
the date wiring on NotificationsSection had to be added afterwards
(now done — NotificationsSection({date}) and queryKey/url include
date).
Validation-round-4 fixes (this commit):
- Manage attendee editor: added per-row title input alongside name +
deterministic ChevronUp/ChevronDown reorder controls (sort order is
recomputed on save). New i18n keys
executiveMeetings.manage.attendees.{moveUp,moveDown}.
- Manage tab: per-row Copy button opens a new "Duplicate to date" dialog
that calls POST /executive-meetings/:id/duplicate with a target date
picker; on success it switches the day view to the chosen date. New
i18n keys executiveMeetings.manage.{duplicate, duplicateToDate,
duplicated, duplicateFailed}.
- Print page (executive-meetings-print.tsx) now uses react-i18next with
new locale block executiveMeetings.print.* (AR + EN parity), forces
i18n.changeLanguage(lang) so ?lang=en always prints English. Attendees
cell in the print table is now textAlign: center (matches the Step 1
schedule).
- Font settings tightened to spec: families = system/Cairo/Tajawal/
Noto Naskh Arabic/Amiri (dropped Inter, monospace), weights =
regular/bold (dropped medium, semibold), alignment = start/center
(dropped end), size range = 12–22 (was 10–32). Enforced server-side
in fontSettingsSchema (Zod) and mirrored in the UI selects/range.
Verified: PATCH font-settings returns 200 for valid combo, 400 for
fontWeight="medium", 400 for fontSize=30.
Validation-round-3 fixes:
- Tasks RBAC tightened end-to-end: new TASK_VIEW_ROLES = admin +
office_manager + coord_lead + coordinator. GET /tasks and
PATCH /tasks/:id now require requireTaskView. /me exposes new
canViewTasks flag. Frontend isSectionVisible("tasks") gated on
canViewTasks (so CEO/viewer never sees the Tasks tab or hits the
endpoint). Verified: admin /me returns canViewTasks=true.
- GET /requests gained dateFrom + dateTo (createdAt range) and a
requester filter that is honored only for approver/audit roles
(non-approvers stay locked to their own requests). Verified curl
with ?dateFrom=...&dateTo=... returns 200.
- Task dueAt schema accepts BOTH YYYY-MM-DD (from <input type="date">)
and full ISO datetime; date-only is normalized to start-of-day UTC,
empty string clears the field. Verified curl POST /tasks with
{"dueAt":"2026-12-31"} returns 201 with stored
dueAt = 2026-12-31T00:00:00.000Z.
Validation-round-2 fixes:
- GET /executive-meetings/requests: added server-side least-privilege so
only APPROVE_ROLES + ADMIN_AUDIT_ROLES see other users' requests; every
other executive role is force-scoped to requestedBy = self regardless
of the client's `mine` flag (closes the RBAC overexposure flag).
- isSectionVisible("tasks") now also returns true for canSubmitRequest,
so executive_coordinator sees the Tasks tab (coordinators execute the
follow-ups generated from approved requests).
- Requests "new request" dropdown now exposes ALL twelve backend request
types: create, edit, delete, reschedule, add_attendee, remove_attendee,
change_location, cancel_meeting, note, highlight, unhighlight, other.
- i18n parity: added en.json + ar.json keys for the five missing request
types (add_attendee, remove_attendee, change_location, cancel_meeting,
note) and the two missing statuses (needs_edit, done) under
executiveMeetings.requests.{type,status}.
- Fixed wrong key reference: reschedule form's date row was using
manage.field.date (does not exist) — switched to manage.field.meetingDate.
Drift / out of scope (explicitly deferred to follow-ups #110/#111/#112):
- Real notifications delivery, server-side PDF rendering, attendee
reorder UI (drag/arrows), duplicate-to-date UI, OpenAPI/api-spec sync,
expanded seed data including executive_meeting_notifications sample
rows.
This commit is contained in:
@@ -70,17 +70,19 @@ const REQUEST_REVIEW_STATUSES = [
|
||||
"needs_edit",
|
||||
] as const;
|
||||
const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
||||
// Font settings constraints per Phase 2 spec: limited family list, regular/bold
|
||||
// only, start/center alignment only, size 12–22.
|
||||
const FONT_FAMILIES = [
|
||||
"system",
|
||||
"Cairo",
|
||||
"Tajawal",
|
||||
"Noto Naskh Arabic",
|
||||
"Amiri",
|
||||
"Inter",
|
||||
"monospace",
|
||||
] as const;
|
||||
const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const;
|
||||
const FONT_ALIGNMENTS = ["start", "center", "end"] as const;
|
||||
const FONT_WEIGHTS = ["regular", "bold"] as const;
|
||||
const FONT_ALIGNMENTS = ["start", "center"] as const;
|
||||
const FONT_SIZE_MIN = 12;
|
||||
const FONT_SIZE_MAX = 22;
|
||||
|
||||
const MUTATE_ROLES = [
|
||||
"admin",
|
||||
@@ -297,7 +299,7 @@ const pdfArchiveCreateSchema = z.object({
|
||||
const fontSettingsSchema = z.object({
|
||||
scope: z.enum(["user", "global"]).default("user"),
|
||||
fontFamily: z.enum(FONT_FAMILIES).default("system"),
|
||||
fontSize: z.number().int().min(10).max(32).default(14),
|
||||
fontSize: z.number().int().min(FONT_SIZE_MIN).max(FONT_SIZE_MAX).default(14),
|
||||
fontWeight: z.enum(FONT_WEIGHTS).default("regular"),
|
||||
alignment: z.enum(FONT_ALIGNMENTS).default("start"),
|
||||
});
|
||||
|
||||
@@ -685,8 +685,14 @@
|
||||
"id": "رقم الحاضر",
|
||||
"type": "نوع الحضور",
|
||||
"add": "إضافة حاضر",
|
||||
"remove": "إزالة"
|
||||
"remove": "إزالة",
|
||||
"moveUp": "تحريك للأعلى",
|
||||
"moveDown": "تحريك للأسفل"
|
||||
},
|
||||
"duplicate": "تكرار",
|
||||
"duplicateToDate": "تكرار إلى تاريخ",
|
||||
"duplicated": "تم تكرار الاجتماع",
|
||||
"duplicateFailed": "تعذر تكرار الاجتماع",
|
||||
"platform": {
|
||||
"none": "بدون",
|
||||
"webex": "Webex",
|
||||
@@ -830,6 +836,18 @@
|
||||
"done": "تم"
|
||||
}
|
||||
},
|
||||
"print": {
|
||||
"title": "جدول الاجتماعات التنفيذية",
|
||||
"no": "#",
|
||||
"meeting": "الاجتماع",
|
||||
"attendees": "الحضور",
|
||||
"time": "الوقت",
|
||||
"location": "المكان",
|
||||
"none": "لا توجد اجتماعات لهذا اليوم.",
|
||||
"print": "طباعة",
|
||||
"back": "إغلاق",
|
||||
"loading": "جارٍ التحميل…"
|
||||
},
|
||||
"pdf": {
|
||||
"heading": "تصدير / طباعة",
|
||||
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
|
||||
|
||||
@@ -682,8 +682,14 @@
|
||||
"id": "Attendee ID",
|
||||
"type": "Attendance type",
|
||||
"add": "Add attendee",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
},
|
||||
"duplicate": "Duplicate",
|
||||
"duplicateToDate": "Duplicate to date",
|
||||
"duplicated": "Meeting duplicated",
|
||||
"duplicateFailed": "Could not duplicate the meeting",
|
||||
"platform": {
|
||||
"none": "None",
|
||||
"webex": "Webex",
|
||||
@@ -840,6 +846,18 @@
|
||||
"archivesHeading": "Archived snapshots",
|
||||
"noArchives": "No snapshots archived for this date yet."
|
||||
},
|
||||
"print": {
|
||||
"title": "Executive Meetings Schedule",
|
||||
"no": "#",
|
||||
"meeting": "Meeting",
|
||||
"attendees": "Attendees",
|
||||
"time": "Time",
|
||||
"location": "Location",
|
||||
"none": "No meetings for this day.",
|
||||
"print": "Print",
|
||||
"back": "Close",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"fontSettingsPage": {
|
||||
"heading": "Font Settings",
|
||||
"intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Attendee = {
|
||||
id: number;
|
||||
@@ -35,31 +36,22 @@ function getQuery(): { date: string; lang: "ar" | "en" } {
|
||||
return { date, lang };
|
||||
}
|
||||
|
||||
const T = {
|
||||
ar: {
|
||||
title: "جدول الاجتماعات التنفيذية",
|
||||
no: "#",
|
||||
meeting: "الاجتماع",
|
||||
attendees: "الحضور",
|
||||
time: "الوقت",
|
||||
location: "المكان",
|
||||
none: "لا توجد اجتماعات لهذا اليوم.",
|
||||
print: "طباعة",
|
||||
back: "إغلاق",
|
||||
loading: "جارٍ التحميل…",
|
||||
},
|
||||
en: {
|
||||
title: "Executive Meetings Schedule",
|
||||
no: "#",
|
||||
meeting: "Meeting",
|
||||
attendees: "Attendees",
|
||||
time: "Time",
|
||||
location: "Location",
|
||||
none: "No meetings for this day.",
|
||||
print: "Print",
|
||||
back: "Close",
|
||||
loading: "Loading…",
|
||||
},
|
||||
// All printable strings live in the project locale files under
|
||||
// `executiveMeetings.print.*`, keeping AR + EN parity with the rest of the
|
||||
// app. We force i18next into the language requested by the URL so a
|
||||
// "Print English" link always renders English regardless of the active
|
||||
// session language.
|
||||
const PRINT_KEYS = {
|
||||
title: "executiveMeetings.print.title",
|
||||
no: "executiveMeetings.print.no",
|
||||
meeting: "executiveMeetings.print.meeting",
|
||||
attendees: "executiveMeetings.print.attendees",
|
||||
time: "executiveMeetings.print.time",
|
||||
location: "executiveMeetings.print.location",
|
||||
none: "executiveMeetings.print.none",
|
||||
print: "executiveMeetings.print.print",
|
||||
back: "executiveMeetings.print.back",
|
||||
loading: "executiveMeetings.print.loading",
|
||||
} as const;
|
||||
|
||||
export default function ExecutiveMeetingsPrintPage() {
|
||||
@@ -67,7 +59,22 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
const [data, setData] = useState<DayResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isRtl = lang === "ar";
|
||||
const t = T[lang];
|
||||
const { t: i18nT, i18n } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (i18n.language !== lang) void i18n.changeLanguage(lang);
|
||||
}, [lang, i18n]);
|
||||
const t = {
|
||||
title: i18nT(PRINT_KEYS.title),
|
||||
no: i18nT(PRINT_KEYS.no),
|
||||
meeting: i18nT(PRINT_KEYS.meeting),
|
||||
attendees: i18nT(PRINT_KEYS.attendees),
|
||||
time: i18nT(PRINT_KEYS.time),
|
||||
location: i18nT(PRINT_KEYS.location),
|
||||
none: i18nT(PRINT_KEYS.none),
|
||||
print: i18nT(PRINT_KEYS.print),
|
||||
back: i18nT(PRINT_KEYS.back),
|
||||
loading: i18nT(PRINT_KEYS.loading),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = isRtl ? "rtl" : "ltr";
|
||||
@@ -191,7 +198,7 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ textAlign: isRtl ? "right" : "left" }}>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
{attendees || "—"}
|
||||
</td>
|
||||
<td>{time}</td>
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
Printer,
|
||||
Check,
|
||||
X,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -157,8 +160,8 @@ type NotificationRow = {
|
||||
type FontPrefs = {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: "regular" | "medium" | "semibold" | "bold";
|
||||
alignment: "start" | "center" | "end";
|
||||
fontWeight: "regular" | "bold";
|
||||
alignment: "start" | "center";
|
||||
};
|
||||
|
||||
type FontSettingsResponse = {
|
||||
@@ -256,7 +259,7 @@ function todayIso(): string {
|
||||
}
|
||||
|
||||
function fontWeightToCss(w: FontPrefs["fontWeight"]): number {
|
||||
return { regular: 400, medium: 500, semibold: 600, bold: 700 }[w];
|
||||
return { regular: 400, bold: 700 }[w];
|
||||
}
|
||||
|
||||
function fontFamilyToCss(name: string): string {
|
||||
@@ -775,6 +778,14 @@ function ManageSection({
|
||||
const [editing, setEditing] = useState<MeetingFormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
// Duplicate-to-date dialog state. The chosen target date is sent to the
|
||||
// backend POST /:id/duplicate route which clones the meeting (and its
|
||||
// attendees) onto that date.
|
||||
const [duplicating, setDuplicating] = useState<{
|
||||
id: number;
|
||||
targetDate: string;
|
||||
} | null>(null);
|
||||
const [duplicatingBusy, setDuplicatingBusy] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<DayResponse>({
|
||||
queryKey: ["/api/executive-meetings", date],
|
||||
@@ -785,6 +796,35 @@ function ManageSection({
|
||||
function openCreate() {
|
||||
setEditing(emptyMeetingForm(date));
|
||||
}
|
||||
function openDuplicate(m: Meeting) {
|
||||
setDuplicating({ id: m.id, targetDate: date });
|
||||
}
|
||||
async function performDuplicate() {
|
||||
if (!duplicating) return;
|
||||
setDuplicatingBusy(true);
|
||||
try {
|
||||
await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, {
|
||||
method: "POST",
|
||||
body: { targetDate: duplicating.targetDate },
|
||||
});
|
||||
toast({ title: t("executiveMeetings.manage.duplicated") });
|
||||
const newDate = duplicating.targetDate;
|
||||
setDuplicating(null);
|
||||
if (newDate === date) {
|
||||
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
|
||||
} else {
|
||||
onDateChange(newDate);
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t("executiveMeetings.manage.duplicateFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDuplicatingBusy(false);
|
||||
}
|
||||
}
|
||||
function openEdit(m: Meeting) {
|
||||
setEditing({
|
||||
id: m.id,
|
||||
@@ -949,6 +989,15 @@ function ManageSection({
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openDuplicate(m)}
|
||||
title={t("executiveMeetings.manage.duplicateToDate")}
|
||||
data-testid={`em-duplicate-${m.id}`}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -976,6 +1025,50 @@ function ManageSection({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={duplicating !== null}
|
||||
onOpenChange={(o) => !o && setDuplicating(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("executiveMeetings.manage.duplicateToDate")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
|
||||
<Input
|
||||
type="date"
|
||||
value={duplicating?.targetDate ?? ""}
|
||||
onChange={(e) =>
|
||||
setDuplicating(
|
||||
duplicating
|
||||
? { ...duplicating, targetDate: e.target.value }
|
||||
: null,
|
||||
)
|
||||
}
|
||||
data-testid="em-duplicate-date"
|
||||
/>
|
||||
</FormRow>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDuplicating(null)}
|
||||
disabled={duplicatingBusy}
|
||||
>
|
||||
{t("executiveMeetings.common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={performDuplicate}
|
||||
disabled={duplicatingBusy || !duplicating?.targetDate}
|
||||
className="bg-[#0B1E3F]"
|
||||
data-testid="em-duplicate-confirm"
|
||||
>
|
||||
{t("executiveMeetings.manage.duplicate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1017,6 +1110,16 @@ function MeetingFormDialog({
|
||||
arr[i] = { ...arr[i], ...patch } as Attendee;
|
||||
onChange({ ...state, attendees: arr });
|
||||
}
|
||||
// Move an attendee up or down using deterministic arrow controls. The
|
||||
// `sortOrder` field is recomputed on save so the persistent order matches
|
||||
// what the user sees here.
|
||||
function moveAttendee(i: number, dir: -1 | 1) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= state.attendees.length) return;
|
||||
const arr = state.attendees.slice();
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
onChange({ ...state, attendees: arr });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -1138,13 +1241,50 @@ function MeetingFormDialog({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{state.attendees.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2"
|
||||
data-testid={`em-attendee-row-${i}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveUp")}
|
||||
data-testid={`em-attendee-up-${i}`}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5"
|
||||
onClick={() => moveAttendee(i, 1)}
|
||||
disabled={i === state.attendees.length - 1}
|
||||
aria-label={t("executiveMeetings.manage.attendees.moveDown")}
|
||||
data-testid={`em-attendee-down-${i}`}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t("executiveMeetings.manage.attendees.name")}
|
||||
value={a.name}
|
||||
onChange={(e) => setAttendee(i, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t("executiveMeetings.manage.attendees.title")}
|
||||
value={a.title ?? ""}
|
||||
onChange={(e) =>
|
||||
setAttendee(i, { title: e.target.value || null })
|
||||
}
|
||||
data-testid={`em-attendee-title-${i}`}
|
||||
/>
|
||||
<Select
|
||||
value={a.attendanceType}
|
||||
onValueChange={(v) =>
|
||||
@@ -2645,8 +2785,6 @@ function FontSettingsSection({
|
||||
"Tajawal",
|
||||
"Noto Naskh Arabic",
|
||||
"Amiri",
|
||||
"Inter",
|
||||
"monospace",
|
||||
].map((f) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
@@ -2658,8 +2796,8 @@ function FontSettingsSection({
|
||||
<FormRow label={`${t("executiveMeetings.fontSettingsPage.fontSize")}: ${prefs.fontSize}px`}>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
min={12}
|
||||
max={22}
|
||||
value={prefs.fontSize}
|
||||
onChange={(e) => setPrefs({ ...prefs, fontSize: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
@@ -2674,7 +2812,7 @@ function FontSettingsSection({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["regular", "medium", "semibold", "bold"] as const).map((w) => (
|
||||
{(["regular", "bold"] as const).map((w) => (
|
||||
<SelectItem key={w} value={w}>
|
||||
{t(`executiveMeetings.fontSettingsPage.weight.${w}`)}
|
||||
</SelectItem>
|
||||
@@ -2691,7 +2829,7 @@ function FontSettingsSection({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["start", "center", "end"] as const).map((a) => (
|
||||
{(["start", "center"] as const).map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{t(`executiveMeetings.fontSettingsPage.align.${a}`)}
|
||||
</SelectItem>
|
||||
|
||||
Reference in New Issue
Block a user