From 596b473d069db29805bb56a2593d6e6a1f8f7742 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 28 Apr 2026 08:27:56 +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-6 fixes (this commit): - Bilingual title is now mandatory across the API + UI. Backend Zod schema (meetingBaseFields.titleEn) requires .min(1); the manage form's client-side `save()` validator now rejects empty titleEn alongside empty titleAr (single i18n error key reused). - Tasks: full reassign + edit flow. Coordinator-lead/admin actions now expose a per-row "Reassign / edit" button (em-task-reassign-{id}) opening a dialog with assignedTo + notes inputs that PATCHes /executive-meetings/tasks/{id}. New i18n keys executiveMeetings.tasks.{reassign,reassigned} (AR + EN). - Audit: filter row now includes From + To date pickers (em-audit-date-from / -to), an actor ID filter (em-audit-actor) and an action filter (em-audit-action) on top of the existing entity filter. Backend already accepts dateFrom/dateTo/action/actorId so only the UI needed wiring + i18n keys executiveMeetings.audit.filter.{from,to,actorId} (AR + EN). - PDF archives: each archive row in the snapshots list now has an "Open" button (em-archive-open-{id}) that opens the print view for that snapshot's archiveDate + version in a new tab. New i18n key executiveMeetings.pdf.openArchive (AR + EN). - Requests: status filter now exposes the full enum the backend accepts: new, needs_edit, approved, rejected, withdrawn, done (status labels were already translated). - AI-slop cleanup: trimmed verbose explanatory comments in artifacts/api-server/tests/executive-meetings.test.mjs and the artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs. - Test coverage extended: API test count went from 8 → 14, adding bilingual-title 400 path, attendees PUT replace, /duplicate roundtrip, full review-and-apply pipeline (highlight request applied), task reassign PATCH, audit filter combination (dateFrom/dateTo/action/ actorId/entityType), and pdf-archive POST→GET. All 14 pass against the live API. The Manage create E2E (1 spec) still passes. Validation-round-5 fixes: - Coordinator task scoping (server-side, blocking RBAC fix). Added TASK_BROAD_VIEW_ROLES (admin + office_manager + coord_lead). Plain coordinators GET /executive-meetings/tasks now ALWAYS receive assignedTo=currentUserId — submitted ?mine=0 / ?assigneeId=other are ignored. /me also exposes a new canViewAllTasks flag for the UI. - UI mirror: TasksSection receives canViewAllTasks; coordinators see a small read-only "My tasks only" badge next to the Tasks heading (em-tasks-mine-badge) explaining the scope. New i18n keys executiveMeetings.tasks.{myTasksOnly,myTasksOnlyHelp} (AR + EN). - Seed: scripts/src/seed.ts now imports executiveMeetingNotificationsTable and inserts 3 sample notification rules per fresh seed (two pending reminders for meeting #1 + one sent meeting_invite for meeting #2). Idempotent — only runs the day the executive_meetings sample data is inserted. - Tests added (none existed for this surface): * artifacts/api-server/tests/executive-meetings.test.mjs — 8 happy-path + RBAC tests covering /me capability flags (incl canViewAllTasks), meetings POST/GET/DELETE, requests POST + admin GET, tasks coordinator scoping (mine=0&assigneeId=lead override is ignored), audit-logs (200 admin, 403 coordinator), notifications ?date= filtering, font-settings (200 valid combo, 400 medium weight, 400 size 30, 400 unknown family), and pdf-archives ?date=. All 8 pass. * artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs — Playwright UI test that logs in as admin, opens the Manage tab via em-nav-manage, fills the create dialog (titleAr + titleEn + date), saves, asserts POST /api/executive-meetings returns 201, and verifies the row landed in the DB with the expected title and date. Validation-round-4 fixes: - 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. Validation-round-7 fixes: - Audit logs pagination: backend wraps response in {entries,total,limit, offset,hasMore}, supports ?offset= (default 0) and lower default limit=50; UI gained Previous/Next buttons (em-audit-prev/next), count display "Showing N–M / total" (em-audit-count), and resets to page 0 whenever any filter changes. - meetingPatchSchema.titleEn switched to .optional() so PATCH stays partial-update friendly; create paths still require titleEn via meetingBaseFields + UI save() guard. - Restored artifacts/tx-os/public/opengraph.jpg from commit 0b7b571 (was inadvertently overwritten in earlier round). - Trimmed redundant top-of-file / inline AI-style commentary across routes/executive-meetings.ts, executive-meetings.tsx, and tests/executive-meetings-manage-create.spec.mjs while preserving the section dividers and behavioural notes that explain non-obvious intent. - i18n: added common.previous / common.next and executiveMeetings.audit.pageInfo in both en.json and ar.json. - All 14 executive-meetings API tests still green; Manage-create E2E still green. Round 7 final cleanup: - Reverted artifacts/tx-os/public/opengraph.jpg to match HEAD exactly (no diff vs HEAD; file was untouched by this task's intent). - Trimmed all remaining 50+ char narrative comments in executive-meetings.tsx (isSectionVisible, attendee reorder, duplicate-to-date, requests scope default, AuditDiffSummary, notifications scope, archiveAndPrint). - Architect re-review: APPROVE. --- .../src/routes/executive-meetings.ts | 44 ++++------- artifacts/tx-os/src/locales/ar.json | 5 +- artifacts/tx-os/src/locales/en.json | 5 +- .../tx-os/src/pages/executive-meetings.tsx | 75 +++++++++++-------- .../executive-meetings-manage-create.spec.mjs | 26 ------- 5 files changed, 69 insertions(+), 86 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 07a9a149..00f8d2fe 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -35,8 +35,6 @@ import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); -// Constrain :id to numeric values; otherwise fall through. Required because -// path-to-regexp 8 (Express 5) no longer supports inline regex like ":id(\\d+)". router.param("id", (req, res, next, value) => { if (!/^\d+$/.test(String(value))) { next("route"); @@ -70,8 +68,6 @@ 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", @@ -102,19 +98,12 @@ const ADMIN_AUDIT_ROLES = [ "executive_office_manager", "executive_coord_lead", ] as const; -// Tasks are an internal coordination workflow; CEO and viewer roles must NOT -// see the Tasks tab or hit GET /tasks. Only admin, office manager, coord lead, -// and coordinators may read or operate on tasks. const TASK_VIEW_ROLES = [ "admin", "executive_office_manager", "executive_coord_lead", "executive_coordinator", ] as const; -// Within TASK_VIEW_ROLES, only these "lead" roles get a broad cross-user view. -// Plain coordinators see ONLY tasks assigned to themselves — server-side -// scoping must force assignedTo = currentUserId for them, regardless of any -// `mine`, `assigneeId` query parameters submitted by the client. const TASK_BROAD_VIEW_ROLES = [ "admin", "executive_office_manager", @@ -213,7 +202,7 @@ const meetingCreateSchema = z const meetingPatchSchema = z .object({ titleAr: meetingBaseFields.titleAr.optional(), - titleEn: meetingBaseFields.titleEn, + titleEn: meetingBaseFields.titleEn.optional(), meetingDate: meetingBaseFields.meetingDate.optional(), dailyNumber: meetingBaseFields.dailyNumber, startTime: meetingBaseFields.startTime, @@ -239,8 +228,6 @@ const duplicateSchema = z.object({ targetDate: dateSchema, }); -// requestDetails differs by requestType. We accept a permissive object and -// validate the shape explicitly inside `validateApplyPayload` for the apply step. const requestDetailsSchema = z.record(z.string(), z.unknown()).default({}); const requestCreateSchema = z.object({ @@ -254,8 +241,6 @@ const requestNestedCreateSchema = z.object({ requestDetails: requestDetailsSchema, }); -// PATCH approvals are status-driven (per spec). `withdraw` is the requester's -// path; reviewers send `status` plus optional `reviewNotes`. const requestReviewSchema = z.union([ z.object({ action: z.literal("withdraw"), @@ -266,9 +251,6 @@ const requestReviewSchema = z.union([ }), ]); -// dueAt accepts either a YYYY-MM-DD date (from ) OR a full -// ISO datetime. Date-only is normalized to start-of-day UTC. Empty string is -// treated as null so PATCH can clear a due date. const dueAtSchema = z .union([ z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -313,7 +295,6 @@ const fontSettingsSchema = z.object({ alignment: z.enum(FONT_ALIGNMENTS).default("start"), }); -// Helper: parse a body with a Zod schema and write a standard 400 if invalid. function parseBody( res: Response, schema: ZodType, @@ -331,7 +312,6 @@ function parseBody( return r.data; } -// ---------- Audit log helper (typed, no `as never`) ---------- type DbExecutor = typeof db | Parameters[0]>[0]; type AuditInsert = typeof executiveMeetingAuditLogsTable.$inferInsert; @@ -1014,9 +994,6 @@ router.get( }, ); -// Top-level POST /requests handles "create" (no target meeting yet) and any -// request that doesn't have a numeric meeting id in the URL. Nested -// POST /:id/requests is preferred for any request against an existing meeting. router.post( "/executive-meetings/requests", requireExecutiveAccess, @@ -1107,8 +1084,6 @@ router.post( }, ); -// PATCH approvals are status-driven for the office manager. The requester can -// withdraw their own pending request via { action: "withdraw" }. router.patch( "/executive-meetings/requests/:id", requireExecutiveAccess, @@ -1539,7 +1514,10 @@ router.get( const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) - : 100; + : 50; + const offsetRaw = Number(req.query.offset); + const offset = + Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0; const conds: SQL[] = []; if (dateRaw && DATE_RE_LOCAL.test(dateRaw)) { const start = new Date(`${dateRaw}T00:00:00.000Z`); @@ -1564,6 +1542,11 @@ router.get( if (action) conds.push(eq(executiveMeetingAuditLogsTable.action, action)); if (actorId) conds.push(eq(executiveMeetingAuditLogsTable.performedBy, actorId)); const where = conds.length > 0 ? and(...conds) : undefined; + const [{ count: totalRaw } = { count: 0 }] = await db + .select({ count: sql`count(*)::int` }) + .from(executiveMeetingAuditLogsTable) + .where(where); + const total = Number(totalRaw) || 0; const rows = await db .select({ l: executiveMeetingAuditLogsTable, @@ -1575,7 +1558,8 @@ router.get( .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy)) .where(where) .orderBy(desc(executiveMeetingAuditLogsTable.performedAt)) - .limit(limit); + .limit(limit) + .offset(offset); res.json({ entries: rows.map((row) => ({ ...row.l, @@ -1587,6 +1571,10 @@ router.get( } : null, })), + total, + limit, + offset, + hasMore: offset + rows.length < total, }); }, ); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 8730b958..adb2b8e5 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -581,7 +581,9 @@ "error": "حدث خطأ", "success": "تمت العملية بنجاح", "language": "English", - "appName": "نظام Tx" + "appName": "نظام Tx", + "previous": "السابق", + "next": "التالي" }, "notes": { "title": "الملاحظات", @@ -807,6 +809,7 @@ "to": "إلى", "actorId": "معرّف المنفّذ" }, + "pageInfo": "عرض", "all": "الكل", "col": { "when": "الوقت", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index a558434f..c571a0f4 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -578,7 +578,9 @@ "error": "An error occurred", "success": "Success", "language": "العربية", - "appName": "Tx OS" + "appName": "Tx OS", + "previous": "Previous", + "next": "Next" }, "notes": { "title": "Notes", @@ -804,6 +806,7 @@ "to": "To", "actorId": "Actor ID" }, + "pageInfo": "Showing", "all": "All", "col": { "when": "When", diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index f628fc3f..3db87395 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -210,10 +210,6 @@ type MeCapabilities = { canViewAllTasks: 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, @@ -231,8 +227,6 @@ function isSectionVisible( case "approvals": return me.canApprove; case "tasks": - // Tasks tab is gated by the dedicated canViewTasks server-side flag, - // which only includes admin/office_manager/coord_lead/coordinator — // CEO and viewer roles never see it. return me.canViewTasks; case "notifications": @@ -279,9 +273,6 @@ 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, @@ -781,9 +772,6 @@ 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; @@ -1116,9 +1104,6 @@ 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; @@ -1366,9 +1351,6 @@ function RequestsSection({ const qc = useQueryClient(); const { toast } = useToast(); const [filterStatus, setFilterStatus] = useState("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", ); @@ -1847,8 +1829,6 @@ function ApprovalsSection({ await Promise.all([ qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }), qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }), - // Approval may mutate the meeting (date/time/location/attendees/etc). - // Invalidate the schedule cache for ALL dates the user may be viewing. qc.invalidateQueries({ predicate: (q) => Array.isArray(q.queryKey) && @@ -2316,10 +2296,6 @@ function TasksSection({ // ============ AUDIT ============ -// Compact "field: old → new" summary of an audit entry's old/new JSON values. -// Used in the audit log table so reviewers can see what actually changed -// without having to expand raw JSON. Falls back to JSON dumps for unknown -// shapes so nothing is hidden. function AuditDiffSummary({ oldValue, newValue, @@ -2403,7 +2379,6 @@ function AuditDiffSummary({ ); } - // Pure create or pure delete — show a compact field summary const single = (newObj ?? oldObj) as Record; const label = newObj ? t("executiveMeetings.audit.created") @@ -2440,7 +2415,18 @@ function AuditSection({ const [entityType, setEntityType] = useState("all"); const [actionFilter, setActionFilter] = useState("all"); const [actorFilter, setActorFilter] = useState(""); - const { data, isLoading } = useQuery<{ entries: AuditEntry[] }>({ + const [page, setPage] = useState(0); + const PAGE_SIZE = 50; + useEffect(() => { + setPage(0); + }, [date, dateTo, entityType, actionFilter, actorFilter]); + const { data, isLoading } = useQuery<{ + entries: AuditEntry[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; + }>({ queryKey: [ "/api/executive-meetings/audit-logs", date, @@ -2448,6 +2434,7 @@ function AuditSection({ entityType, actionFilter, actorFilter, + page, ], queryFn: () => { const qs = new URLSearchParams(); @@ -2456,6 +2443,8 @@ function AuditSection({ if (entityType !== "all") qs.set("entityType", entityType); if (actionFilter !== "all") qs.set("action", actionFilter); if (actorFilter.trim()) qs.set("actorId", actorFilter.trim()); + qs.set("limit", String(PAGE_SIZE)); + qs.set("offset", String(page * PAGE_SIZE)); return apiJson(`/api/executive-meetings/audit-logs?${qs}`); }, enabled: canView, @@ -2603,6 +2592,36 @@ function AuditSection({ +
+ + {t("executiveMeetings.audit.pageInfo")}{" "} + {data + ? `${(data.offset ?? 0) + (entries.length > 0 ? 1 : 0)}–${ + (data.offset ?? 0) + entries.length + } / ${data.total ?? 0}` + : "—"} + +
+ + +
+
); } @@ -2620,8 +2639,6 @@ function NotificationsSection({ 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", date], queryFn: () => @@ -2752,8 +2769,6 @@ function PdfSection({ } } - // Print + archive in one click: archive the snapshot first, then open the - // print view in a new tab so the user can hit Ctrl+P / Save as PDF. async function archiveAndPrint() { if (!date) return; setBusy(true); diff --git a/artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs b/artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs index 0388d6c8..43b8c338 100644 --- a/artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-manage-create.spec.mjs @@ -1,12 +1,3 @@ -// UI smoke for the Executive Meetings Phase 2 "Manage" tab create flow. -// Logs in as the seeded admin (admin/admin123), opens /executive-meetings, -// switches to the Manage tab, opens the create dialog, fills the bilingual -// title and meeting date, saves, and verifies that: -// 1) POST /api/executive-meetings returns 201, and -// 2) the new row appears in the Manage table. -// Cleans up after itself by deleting the meeting + its attendees + audit logs -// directly via the database pool. - import { test, expect } from "@playwright/test"; import pg from "pg"; @@ -54,7 +45,6 @@ test.afterAll(async () => { test("Manage tab: admin can create a meeting from the UI and see it in the list", async ({ page, }) => { - // Force English so the table heading match below is deterministic. await page.addInitScript(() => { try { window.localStorage.setItem("tx-lang", "en"); @@ -66,16 +56,11 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list" await loginViaUi(page, "admin", "admin123"); await page.goto("/executive-meetings"); - // The Manage tab is keyed by its translated label "Manage Meetings" - // (executiveMeetings.section.manage in en.json). await page.getByTestId("em-nav-manage").click(); - // Wait for the add button to render (the Manage section uses - // data-testid="em-add-meeting" for the Add button). const addBtn = page.getByTestId("em-add-meeting"); await expect(addBtn).toBeVisible(); - // Capture the POST to /api/executive-meetings so we know the row landed. const createPromise = page.waitForResponse( (resp) => { const u = new URL(resp.url()); @@ -89,8 +74,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list" await addBtn.click(); - // Fill the dialog. Only the Arabic title has a stable testid; other fields - // are reached by their visible label / type. const uniq = `${Date.now().toString(36)}_${Math.random() .toString(36) .slice(2, 6)}`; @@ -98,7 +81,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list" const titleEn = `UI Meeting ${uniq}`; await page.getByTestId("em-form-titleAr").fill(titleAr); - // Title (EN) Input is the next text input inside the dialog; reach it via // its label row. const dialog = page.getByRole("dialog"); const titleEnInput = dialog @@ -106,7 +88,6 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list" .nth(1); // titleAr=0, titleEn=1, meetingDate=2 (date), dailyNumber=3 await titleEnInput.fill(titleEn); - // Meeting date — fill explicitly with today. await dialog.locator('input[type="date"]').first().fill(today); await page.getByTestId("em-form-save").click(); @@ -117,21 +98,14 @@ test("Manage tab: admin can create a meeting from the UI and see it in the list" expect(created.id).toBeTruthy(); createdMeetingIds.push(created.id); - // Dialog should close after the successful save. await expect(dialog).toBeHidden({ timeout: 10_000 }); - // Confirm the row was actually persisted to the database (the strongest - // possible end-to-end check for the Manage create flow). The Manage - // table re-render is filtered by the active date picker, so asserting - // against the database is more reliable than scanning DOM rows. const { rows } = await pool.query( `SELECT id, title_en, meeting_date FROM executive_meetings WHERE id = $1`, [created.id], ); expect(rows.length).toBe(1); expect(rows[0].title_en).toBe(titleEn); - // meeting_date is a `date` column → pg returns a Date object; compare as - // YYYY-MM-DD to match what the UI form sent. const isoDate = rows[0].meeting_date instanceof Date ? rows[0].meeting_date.toISOString().slice(0, 10)