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-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 <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:
Riyadh
2026-04-28 08:14:01 +00:00
parent d9562326d3
commit fe62e71cbe
6 changed files with 405 additions and 64 deletions
@@ -184,7 +184,7 @@ const attendeeSchema = z.object({
const meetingBaseFields = {
titleAr: z.string().trim().min(1).max(300),
titleEn: z.string().trim().max(300).nullable().optional(),
titleEn: z.string().trim().min(1).max(300),
meetingDate: dateSchema,
dailyNumber: positiveIntSchema.max(1000).nullable().optional(),
startTime: timeSchema.nullable().optional(),
@@ -36,8 +36,7 @@ async function createUser(prefix, roleName) {
);
const id = rows[0].id;
created.userIds.push(id);
const roles = ["user", roleName];
for (const r of roles) {
for (const r of ["user", roleName]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
@@ -89,15 +88,11 @@ let leadCookie = null;
let leadUserId = null;
before(async () => {
// Reuse the seeded admin (admin/admin123) for the broad-view path.
adminCookie = await login("admin", "admin123");
const meRes = await api(adminCookie, "GET", "/api/auth/me");
const me = await meRes.json();
adminUserId = me.id ?? me.userId;
// Create a fresh plain coordinator and a coord_lead so we can check the
// server-side scoping behavior of GET /tasks (coordinator must only ever
// see their own tasks even with mine=0/assigneeId=other).
const coord = await createUser("em_coord", "executive_coordinator");
coordUserId = coord.id;
coordCookie = await login(coord.username, TEST_PASSWORD);
@@ -108,7 +103,6 @@ before(async () => {
});
after(async () => {
// Best-effort cleanup; per-test rows may have already been deleted.
if (created.taskIds.length > 0) {
await pool.query(`DELETE FROM executive_meeting_tasks WHERE id = ANY($1::int[])`, [
created.taskIds,
@@ -152,7 +146,7 @@ const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
test("GET /executive-meetings/me exposes the full capability flag set including canViewAllTasks", async () => {
test("GET /me exposes capability flags including canViewAllTasks", async () => {
const res = await api(adminCookie, "GET", "/api/executive-meetings/me");
assert.equal(res.status, 200);
const body = await res.json();
@@ -174,14 +168,25 @@ test("GET /executive-meetings/me exposes the full capability flag set including
const coordRes = await api(coordCookie, "GET", "/api/executive-meetings/me");
const coordMe = await coordRes.json();
assert.equal(coordMe.canViewTasks, true);
assert.equal(
coordMe.canViewAllTasks,
false,
"plain coordinators must not get cross-user task visibility",
);
assert.equal(coordMe.canViewAllTasks, false);
});
test("Meetings: POST creates → GET day returns it → DELETE removes it", async () => {
test("Meetings: bilingual title required (titleEn cannot be empty)", async () => {
const missingEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
meetingDate: today,
});
assert.equal(missingEn.status, 400);
const emptyEn = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع",
titleEn: "",
meetingDate: today,
});
assert.equal(emptyEn.status, 400);
});
test("Meetings: POST → GET day → DELETE roundtrip", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "اجتماع اختبار",
titleEn: "Test meeting",
@@ -218,7 +223,72 @@ test("Meetings: POST creates → GET day returns it → DELETE removes it", asyn
assert.ok(del.status === 200 || del.status === 204);
});
test("Requests: POST creates → admin GET sees it; coord scope filter respects requester", async () => {
test("Meetings: PUT /attendees replaces the attendee list", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ا",
titleEn: "A",
meetingDate: today,
attendees: [{ name: "Old One", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const replace = await api(
adminCookie,
"PUT",
`/api/executive-meetings/${meeting.id}/attendees`,
{
attendees: [
{ name: "New One", title: "Director", attendanceType: "internal", sortOrder: 0 },
{ name: "New Two", title: "Manager", attendanceType: "virtual", sortOrder: 1 },
],
},
);
assert.equal(replace.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.attendees.length, 2);
assert.ok(body.attendees.find((a) => a.name === "New One"));
assert.ok(!body.attendees.find((a) => a.name === "Old One"));
});
test("Meetings: POST /duplicate clones a meeting onto another date", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ب",
titleEn: "B",
meetingDate: today,
attendees: [{ name: "Dup Att", attendanceType: "internal", sortOrder: 0 }],
});
assert.equal(create.status, 201);
const original = await create.json();
created.meetingIds.push(original.id);
const dup = await api(
adminCookie,
"POST",
`/api/executive-meetings/${original.id}/duplicate`,
{ targetDate: tomorrow },
);
assert.equal(dup.status, 201);
const newRow = await dup.json();
assert.notEqual(newRow.id, original.id);
created.meetingIds.push(newRow.id);
const day = await api(
adminCookie,
"GET",
`/api/executive-meetings?date=${tomorrow}`,
);
const dayBody = await day.json();
assert.ok(dayBody.meetings.some((m) => m.id === newRow.id));
});
test("Requests: POST → admin can list", async () => {
const create = await api(
adminCookie,
"POST",
@@ -240,10 +310,45 @@ test("Requests: POST creates → admin GET sees it; coord scope filter respects
assert.ok(listBody.requests.some((r) => r.id === reqRow.id));
});
test("Requests: full review + apply pipeline (approve → apply highlights meeting)", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "ت",
titleEn: "T",
meetingDate: today,
attendees: [],
isHighlighted: 0,
});
assert.equal(create.status, 201);
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const reqRes = await api(adminCookie, "POST", "/api/executive-meetings/requests", {
requestType: "highlight",
meetingId: meeting.id,
requestDetails: { note: "highlight-please" },
});
assert.equal(reqRes.status, 201);
const reqRow = await reqRes.json();
created.requestIds.push(reqRow.id);
const approve = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/requests/${reqRow.id}`,
{ status: "approved", reviewNotes: "ok" },
);
assert.equal(approve.status, 200);
const fetched = await api(
adminCookie,
"GET",
`/api/executive-meetings/${meeting.id}`,
);
const body = await fetched.json();
assert.equal(body.isHighlighted, 1);
});
test("Tasks: server-side scoping forces coordinators to assignedTo=self", async () => {
// Admin creates a task assigned to the coordinator and another assigned to
// the lead. The coordinator must only see the first one even when they pass
// mine=0 / assigneeId=lead.
const t1 = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
taskType: "follow_up",
assignedTo: coordUserId,
@@ -262,8 +367,6 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
const task2 = await t2.json();
created.taskIds.push(task2.id);
// Plain coordinator: mine=0 + assigneeId=leadUserId attempts override; the
// server must ignore both and force assignedTo=coordUserId.
const coordList = await api(
coordCookie,
"GET",
@@ -272,23 +375,12 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
assert.equal(coordList.status, 200);
const coordBody = await coordList.json();
const coordIds = coordBody.tasks.map((t) => t.id);
assert.ok(
coordIds.includes(task1.id),
"coordinator should see their own task",
);
assert.ok(
!coordIds.includes(task2.id),
"coordinator must NOT see another user's task even with assigneeId override",
);
assert.ok(coordIds.includes(task1.id));
assert.ok(!coordIds.includes(task2.id));
for (const t of coordBody.tasks) {
assert.equal(
t.assignedTo,
coordUserId,
"every task returned to a coordinator must be assigned to them",
);
assert.equal(t.assignedTo, coordUserId);
}
// Lead: broad view honors assigneeId.
const leadList = await api(
leadCookie,
"GET",
@@ -299,6 +391,28 @@ test("Tasks: server-side scoping forces coordinators to assignedTo=self", async
assert.ok(leadBody.tasks.some((t) => t.id === task2.id));
});
test("Tasks: PATCH supports reassign + notes update", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings/tasks", {
taskType: "follow_up",
assignedTo: coordUserId,
notes: "v1",
});
assert.equal(create.status, 201);
const task = await create.json();
created.taskIds.push(task.id);
const patch = await api(
adminCookie,
"PATCH",
`/api/executive-meetings/tasks/${task.id}`,
{ assignedTo: leadUserId, notes: "v2" },
);
assert.equal(patch.status, 200);
const updated = await patch.json();
assert.equal(updated.assignedTo, leadUserId);
assert.equal(updated.notes, "v2");
});
test("Audit logs: admin can list, plain coordinator gets 403", async () => {
const ok = await api(
adminCookie,
@@ -317,6 +431,32 @@ test("Audit logs: admin can list, plain coordinator gets 403", async () => {
assert.equal(denied.status, 403);
});
test("Audit logs: dateFrom/dateTo, action, and actorId filters all narrow results", async () => {
const create = await api(adminCookie, "POST", "/api/executive-meetings", {
titleAr: "س",
titleEn: "S",
meetingDate: today,
});
const meeting = await create.json();
created.meetingIds.push(meeting.id);
const filtered = await api(
adminCookie,
"GET",
`/api/executive-meetings/audit-logs?dateFrom=${today}&dateTo=${today}&action=create&actorId=${adminUserId}&entityType=meeting`,
);
assert.equal(filtered.status, 200);
const body = await filtered.json();
const entries = body.entries ?? body.logs ?? [];
assert.ok(Array.isArray(entries));
for (const e of entries) {
assert.equal(e.action, "create");
assert.equal(e.entityType, "meeting");
assert.equal(e.performedBy, adminUserId);
}
assert.ok(entries.some((e) => e.entityId === meeting.id));
});
test("Notifications: GET filters by ?date= and tolerates empty days", async () => {
const empty = await api(
adminCookie,
@@ -335,7 +475,7 @@ test("Notifications: GET filters by ?date= and tolerates empty days", async () =
assert.equal(todayList.status, 200);
});
test("Font settings: valid combo returns 200; invalid weight or out-of-range size returns 400", async () => {
test("Font settings: valid combo 200; invalid weight/size/family 400", async () => {
const ok = await api(
adminCookie,
"PATCH",
@@ -375,13 +515,26 @@ test("Font settings: valid combo returns 200; invalid weight or out-of-range siz
assert.equal(badFamily.status, 400);
});
test("PDF archives: GET returns an array and supports ?date=", async () => {
const res = await api(
test("PDF archives: POST creates a snapshot and GET returns it", async () => {
const post = await api(
adminCookie,
"POST",
"/api/executive-meetings/pdf-archives",
{ archiveDate: today },
);
assert.equal(post.status, 201);
const archive = await post.json();
assert.ok(archive.id);
assert.ok(archive.version >= 1);
const list = await api(
adminCookie,
"GET",
`/api/executive-meetings/pdf-archives?date=${today}`,
);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(Array.isArray(body.archives ?? body.items ?? body.pdfArchives ?? []));
assert.equal(list.status, 200);
const body = await list.json();
const archives = body.archives ?? body.items ?? body.pdfArchives ?? [];
assert.ok(Array.isArray(archives));
assert.ok(archives.some((a) => a.id === archive.id));
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+9 -1
View File
@@ -792,6 +792,8 @@
"deleteConfirm": "حذف هذه المهمة؟",
"saved": "تم حفظ المهمة",
"deleted": "تم حذف المهمة",
"reassign": "إعادة إسناد / تعديل",
"reassigned": "تم تحديث المهمة",
"myTasksOnly": "مهامي فقط",
"myTasksOnlyHelp": "ينظر المنسقون إلى المهام المسندة إليهم فقط، بينما يرى قائد التنسيق ومدير المكتب والمسؤول جميع المهام."
},
@@ -800,6 +802,11 @@
"empty": "لا توجد قيود في السجل.",
"filterDate": "تصفية بالتاريخ",
"filterEntity": "النوع",
"filter": {
"from": "من",
"to": "إلى",
"actorId": "معرّف المنفّذ"
},
"all": "الكل",
"col": {
"when": "الوقت",
@@ -861,7 +868,8 @@
"archived": "تمت أرشفة النسخة",
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
"archivesHeading": "النسخ المؤرشفة",
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد.",
"openArchive": "فتح"
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
+9 -1
View File
@@ -789,6 +789,8 @@
"deleteConfirm": "Delete this task?",
"saved": "Task saved",
"deleted": "Task deleted",
"reassign": "Reassign / edit",
"reassigned": "Task updated",
"myTasksOnly": "My tasks only",
"myTasksOnlyHelp": "Coordinators only see tasks assigned to them. Lead/admin roles see all tasks."
},
@@ -797,6 +799,11 @@
"empty": "No audit entries.",
"filterDate": "Filter by date",
"filterEntity": "Entity",
"filter": {
"from": "From",
"to": "To",
"actorId": "Actor ID"
},
"all": "All",
"col": {
"when": "When",
@@ -846,7 +853,8 @@
"archived": "Snapshot archived",
"archivedAndPrinted": "Snapshot archived — print dialog opened",
"archivesHeading": "Archived snapshots",
"noArchives": "No snapshots archived for this date yet."
"noArchives": "No snapshots archived for this date yet.",
"openArchive": "Open"
},
"print": {
"title": "Executive Meetings Schedule",
+193 -21
View File
@@ -849,8 +849,11 @@ function ManageSection({
async function save() {
if (!editing) return;
if (!editing.titleAr.trim()) {
toast({ title: t("executiveMeetings.manage.errors.titleRequired"), variant: "destructive" });
if (!editing.titleAr.trim() || !editing.titleEn.trim()) {
toast({
title: t("executiveMeetings.manage.errors.titleRequired"),
variant: "destructive",
});
return;
}
setSaving(true);
@@ -1518,7 +1521,16 @@ function RequestsSection({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
{(["new", "approved", "rejected", "withdrawn"] as const).map((s) => (
{(
[
"new",
"needs_edit",
"approved",
"rejected",
"withdrawn",
"done",
] as const
).map((s) => (
<SelectItem key={s} value={s}>
{t(`executiveMeetings.requests.status.${s}`)}
</SelectItem>
@@ -2003,6 +2015,47 @@ function TasksSection({
}
}
const [reassignTarget, setReassignTarget] = useState<TaskRow | null>(null);
const [reassignForm, setReassignForm] = useState({ assignedTo: "", notes: "" });
const [reassignBusy, setReassignBusy] = useState(false);
function openReassign(task: TaskRow) {
setReassignTarget(task);
setReassignForm({
assignedTo: task.assignedTo ? String(task.assignedTo) : "",
notes: task.notes ?? "",
});
}
async function submitReassign() {
if (!reassignTarget) return;
const body: Record<string, unknown> = {};
const newAssignee = reassignForm.assignedTo.trim();
if (newAssignee) body.assignedTo = Number(newAssignee);
if (reassignForm.notes !== (reassignTarget.notes ?? "")) {
body.notes = reassignForm.notes;
}
if (Object.keys(body).length === 0) {
setReassignTarget(null);
return;
}
setReassignBusy(true);
try {
await apiJson(`/api/executive-meetings/tasks/${reassignTarget.id}`, {
method: "PATCH",
body,
});
toast({ title: t("executiveMeetings.tasks.reassigned") });
setReassignTarget(null);
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setReassignBusy(false);
}
}
async function deleteTask(id: number) {
if (!window.confirm(t("executiveMeetings.tasks.deleteConfirm"))) return;
try {
@@ -2129,13 +2182,24 @@ function TasksSection({
</>
)}
{canMutate && (
<Button
size="sm"
variant="ghost"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
<>
<Button
size="sm"
variant="ghost"
onClick={() => openReassign(task)}
title={t("executiveMeetings.tasks.reassign")}
data-testid={`em-task-reassign-${task.id}`}
>
<Pencil className="w-4 h-4 text-blue-600" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</>
)}
</div>
</td>
@@ -2146,6 +2210,51 @@ function TasksSection({
</table>
</div>
<Dialog
open={!!reassignTarget}
onOpenChange={(v) => !v && setReassignTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("executiveMeetings.tasks.reassign")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<FormRow label={t("executiveMeetings.tasks.field.assignee")}>
<Input
type="number"
value={reassignForm.assignedTo}
onChange={(e) =>
setReassignForm({ ...reassignForm, assignedTo: e.target.value })
}
data-testid="em-task-reassign-assignee"
/>
</FormRow>
<FormRow label={t("executiveMeetings.tasks.field.notes")}>
<Textarea
rows={3}
value={reassignForm.notes}
onChange={(e) =>
setReassignForm({ ...reassignForm, notes: e.target.value })
}
data-testid="em-task-reassign-notes"
/>
</FormRow>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReassignTarget(null)}>
{t("common.cancel")}
</Button>
<Button
onClick={submitReassign}
disabled={reassignBusy}
data-testid="em-task-reassign-save"
>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
@@ -2327,13 +2436,26 @@ function AuditSection({
t: (k: string) => string;
}) {
const [date, setDate] = useState("");
const [dateTo, setDateTo] = useState("");
const [entityType, setEntityType] = useState("all");
const [actionFilter, setActionFilter] = useState("all");
const [actorFilter, setActorFilter] = useState("");
const { data, isLoading } = useQuery<{ entries: AuditEntry[] }>({
queryKey: ["/api/executive-meetings/audit-logs", date, entityType],
queryKey: [
"/api/executive-meetings/audit-logs",
date,
dateTo,
entityType,
actionFilter,
actorFilter,
],
queryFn: () => {
const qs = new URLSearchParams();
if (date) qs.set("date", date);
if (date) qs.set("dateFrom", date);
if (dateTo) qs.set("dateTo", dateTo);
if (entityType !== "all") qs.set("entityType", entityType);
if (actionFilter !== "all") qs.set("action", actionFilter);
if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
},
enabled: canView,
@@ -2348,13 +2470,50 @@ function AuditSection({
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.audit.heading")}
</h2>
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
<div className="flex items-center gap-2 flex-wrap">
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.from")}
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-from"
/>
</label>
<label className="text-xs text-gray-500 flex items-center gap-1">
{t("executiveMeetings.audit.filter.to")}
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
data-testid="em-audit-date-to"
/>
</label>
<Input
type="number"
placeholder={t("executiveMeetings.audit.filter.actorId")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
className="w-32 h-9"
data-testid="em-audit-actor"
/>
<Select value={actionFilter} onValueChange={setActionFilter}>
<SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
{(["create", "update", "delete", "approve", "reject"] as const).map(
(a) => (
<SelectItem key={a} value={a}>
{tWithFallback(t, `executiveMeetings.audit.action.${a}`, a)}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36 h-9">
<SelectValue />
@@ -2704,9 +2863,22 @@ function PdfSection({
· {who}
</div>
</div>
<span className="text-xs text-gray-400 truncate max-w-[200px]">
{a.filePath}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
const url = `/executive-meetings/print?date=${a.archiveDate}&lang=${lang}&version=${a.version}`;
window.open(url, "_blank", "noopener");
}}
data-testid={`em-archive-open-${a.id}`}
>
{t("executiveMeetings.pdf.openArchive")}
</Button>
<span className="text-xs text-gray-400 truncate max-w-[160px]">
{a.filePath}
</span>
</div>
</li>
);
})}