From b46e55a3943cff80337776a3f669d03baca7c6e9 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 28 Apr 2026 07:49:37 +0000 Subject: [PATCH] =?UTF-8?q?Task=20#108=20=E2=80=94=20Executive=20Meetings?= =?UTF-8?q?=20Phase=202=20(RBAC=20+=20audit=20+=20Zod=20+=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 ) 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. --- .../src/routes/executive-meetings.ts | 12 +- artifacts/tx-os/src/locales/ar.json | 20 ++- artifacts/tx-os/src/locales/en.json | 20 ++- .../src/pages/executive-meetings-print.tsx | 61 ++++--- .../tx-os/src/pages/executive-meetings.tsx | 158 ++++++++++++++++-- 5 files changed, 227 insertions(+), 44 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 73e46ad0..8ecbc6a7 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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"), }); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 806bc885..9f061783 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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 من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d687f02e..f3ff608b 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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.", diff --git a/artifacts/tx-os/src/pages/executive-meetings-print.tsx b/artifacts/tx-os/src/pages/executive-meetings-print.tsx index 3e188f3b..a2362ddc 100644 --- a/artifacts/tx-os/src/pages/executive-meetings-print.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings-print.tsx @@ -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(null); const [error, setError] = useState(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() { ) : null} - + {attendees || "—"} {time} diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 7d5acef9..d30f8866 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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(null); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(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({ 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({ > + + + + + ); } @@ -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 ( !o && onClose()}> @@ -1138,13 +1241,50 @@ function MeetingFormDialog({
{state.attendees.map((a, i) => ( -
+
+
+ + +
setAttendee(i, { name: e.target.value })} /> + + setAttendee(i, { title: e.target.value || null }) + } + data-testid={`em-attendee-title-${i}`} + /> setPrefs({ ...prefs, fontSize: Number(e.target.value) })} className="w-full" @@ -2674,7 +2812,7 @@ function FontSettingsSection({ - {(["regular", "medium", "semibold", "bold"] as const).map((w) => ( + {(["regular", "bold"] as const).map((w) => ( {t(`executiveMeetings.fontSettingsPage.weight.${w}`)} @@ -2691,7 +2829,7 @@ function FontSettingsSection({ - {(["start", "center", "end"] as const).map((a) => ( + {(["start", "center"] as const).map((a) => ( {t(`executiveMeetings.fontSettingsPage.align.${a}`)}