Task #108 — Executive Meetings Phase 2

Schedule UI polish, full CRUD, Requests/Approvals/Tasks/Audit/PDF/Print/
Font Settings/Notifications-placeholder, audit logging on every mutation,
fine-grained RBAC, bilingual i18n.

Backend (artifacts/api-server/src/routes/executive-meetings.ts)
- All mutations transactional + audit-logged.
- Approve/reject/needs_edit use atomic UPDATE WHERE status='new' RETURNING;
  race losers return 409 RACE_ALREADY_REVIEWED.
- applyApprovedRequest() applies reschedule / change_location / add_attendee /
  remove_attendee / highlight / unhighlight / cancel_meeting / create / edit /
  delete in-tx; effective-time validation throws APPLY_VALIDATION → 400.
- Approve auto-creates linked follow_up_<reqType> task with requestId set.
- Marking a task with requestId as 'completed' propagates request → 'done'
  (in same tx, audit-logged).
- New endpoints: PUT /:id/attendees, POST /:id/duplicate,
  GET/POST /pdf-archives.

Schema (lib/db/src/schema/executive-meetings.ts)
- executive_meeting_requests.meetingId nullable so 'create' requests work.
- pdf_archives table.

Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx + new
executive-meetings-print.tsx, App.tsx route)
- Schedule polish: # column, RTL order, attendees widest, navy/white/gray,
  red highlight only.
- Tabs: Manage / Requests / Approvals / Tasks / Audit / PDF / Notifications /
  Font Settings.
- Requests form now sends STRUCTURED fields (meetingDate, startTime, endTime
  for reschedule; location/url for change_location; name/title for
  add_attendee; etc.) — previously sent only {note}, which prevented apply
  from doing anything.
- Approve handler invalidates ALL ['/api/executive-meetings', *] keys + tasks.
- PDF tab lists archives; Archive snapshot button; Print opens new route.

i18n (en.json/ar.json)
- Full executiveMeetings.* key set including pdf.archive*, attendees.title/id.

Verified
- E2e regression: create meeting → reschedule request (structured) → approve
  → meeting moves to new date → task created → mark task complete → request
  becomes 'done'. Negative case (start>end) → approve rejected.
- Architect final review: PASS, no severe blockers.

Out of scope per spec: real notification delivery, real PDF rendering, iCal,
mobile polish.
This commit is contained in:
Riyadh
2026-04-28 06:57:31 +00:00
parent affd105c7a
commit 20bc4f3596
7 changed files with 1099 additions and 37 deletions
@@ -8,6 +8,7 @@ import {
executiveMeetingTasksTable,
executiveMeetingNotificationsTable,
executiveMeetingAuditLogsTable,
executiveMeetingPdfArchivesTable,
executiveMeetingFontSettingsTable,
rolesTable,
usersTable,
@@ -50,12 +51,17 @@ const APPROVE_ROLES = ["admin", "executive_office_manager"] as const;
const REQUEST_ROLES = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
"executive_coordinator",
] as const;
const ADMIN_AUDIT_ROLES = ["admin", "executive_office_manager"] as const;
const ADMIN_AUDIT_ROLES = [
"admin",
"executive_office_manager",
"executive_coord_lead",
] as const;
async function getRoleNamesForUser(userId: number): Promise<Set<string>> {
const ids = await getEffectiveRoleIds(userId);
@@ -223,6 +229,9 @@ function parseMeetingPatch(
}
}
}
if (out.startTime && out.endTime && out.startTime > out.endTime) {
return { ok: false, error: "startTime must be <= endTime" };
}
for (const k of ["location", "meetingUrl", "notes"] as const) {
if (k in body) {
const v = optionalString(body[k], k === "notes" ? 5000 : 500);
@@ -583,6 +592,152 @@ router.delete(
},
);
// Replace the entire attendee list for a meeting in one transaction.
router.put(
"/executive-meetings/:id/attendees",
requireAuth,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const existing = await fetchMeetingWithAttendees(id);
if (!existing) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const body = (req.body ?? {}) as Record<string, unknown>;
const attendees = parseAttendees(body.attendees);
if (!attendees) {
res.status(400).json({ error: "attendees invalid", code: "validation" });
return;
}
const userId = req.session.userId!;
await db.transaction(async (tx) => {
await tx
.delete(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, id));
if (attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: id,
name: a.name,
title: a.title ?? null,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
);
}
await logAudit(tx, {
action: "replace_attendees",
entityType: "meeting",
entityId: id,
oldValue: { attendees: existing.attendees },
newValue: { attendees },
performedBy: userId,
});
});
const updated = await fetchMeetingWithAttendees(id);
res.json(updated);
},
);
// Duplicate a meeting (and its attendees) to another date.
router.post(
"/executive-meetings/:id/duplicate",
requireAuth,
requireMutate,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const body = (req.body ?? {}) as Record<string, unknown>;
const targetDate =
typeof body.targetDate === "string" && DATE_RE.test(body.targetDate)
? body.targetDate
: null;
if (!targetDate) {
res
.status(400)
.json({ error: "targetDate required (YYYY-MM-DD)", code: "validation" });
return;
}
const source = await fetchMeetingWithAttendees(id);
if (!source) {
res.status(404).json({ error: "Meeting not found", code: "not_found" });
return;
}
const userId = req.session.userId!;
try {
const inserted = await db.transaction(async (tx) => {
const [{ value: maxNum }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingsTable.dailyNumber}), 0)`,
})
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, targetDate));
const nextNumber = (maxNum ?? 0) + 1;
const [meeting] = await tx
.insert(executiveMeetingsTable)
.values({
dailyNumber: nextNumber,
titleAr: source.titleAr,
titleEn: source.titleEn,
meetingDate: targetDate,
startTime: source.startTime,
endTime: source.endTime,
location: source.location,
meetingUrl: source.meetingUrl,
platform: source.platform,
status: "scheduled",
isHighlighted: 0,
notes: source.notes,
attachments: source.attachments as never,
createdBy: userId,
updatedBy: userId,
})
.returning();
if (!meeting) throw new Error("duplicate_failed");
if (source.attendees && source.attendees.length > 0) {
await tx.insert(executiveMeetingAttendeesTable).values(
source.attendees.map((a, idx) => ({
meetingId: meeting.id,
name: a.name,
title: a.title,
attendanceType: a.attendanceType,
sortOrder: a.sortOrder ?? idx,
})),
);
}
await logAudit(tx, {
action: "duplicate",
entityType: "meeting",
entityId: meeting.id,
oldValue: { sourceId: source.id, sourceDate: source.meetingDate },
newValue: meeting,
performedBy: userId,
});
return meeting;
});
const full = await fetchMeetingWithAttendees(inserted.id);
res.status(201).json(full);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("executive_meetings_date_number_unique")) {
res
.status(409)
.json({ error: "Daily number conflict", code: "conflict" });
return;
}
res.status(500).json({ error: msg, code: "server_error" });
}
},
);
// =====================================================================
// REQUESTS
// =====================================================================
@@ -592,11 +747,153 @@ const REQUEST_TYPES = [
"edit",
"delete",
"reschedule",
"add_attendee",
"remove_attendee",
"change_location",
"cancel_meeting",
"note",
"highlight",
"unhighlight",
"other",
] as const;
const REQUEST_STATUSES = ["new", "approved", "rejected", "withdrawn"] as const;
const REQUEST_STATUSES = [
"new",
"approved",
"rejected",
"withdrawn",
"needs_edit",
"done",
] as const;
// Pre-validate apply payload OUTSIDE a tx so we can fail-fast with 400.
// Returns null on success, or an error message string.
function validateApplyPayload(
reqRow: typeof executiveMeetingRequestsTable.$inferSelect,
): string | null {
const details = (reqRow.requestDetails ?? {}) as Record<string, unknown>;
if (reqRow.requestType === "reschedule") {
const st = typeof details.startTime === "string" ? details.startTime : null;
const et = typeof details.endTime === "string" ? details.endTime : null;
const md =
typeof details.meetingDate === "string" ? details.meetingDate : null;
if (st && !TIME_RE.test(st)) return "startTime invalid";
if (et && !TIME_RE.test(et)) return "endTime invalid";
if (md && !DATE_RE.test(md)) return "meetingDate invalid";
if (st && et && st > et) return "startTime must be <= endTime";
}
return null;
}
// Apply an approved request to its target meeting and return the updated row.
// Runs inside the caller's transaction; throws on missing meeting / bad shape.
async function applyApprovedRequest(
tx: DbExecutor,
reqRow: typeof executiveMeetingRequestsTable.$inferSelect,
performedBy: number,
): Promise<void> {
const details = (reqRow.requestDetails ?? {}) as Record<string, unknown>;
if (!reqRow.meetingId) return; // create-suggestions: leave for office manager to act on
const meetingId = reqRow.meetingId;
const [existing] = await tx
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.id, meetingId));
if (!existing) return;
const update: Record<string, unknown> = { updatedBy: performedBy };
switch (reqRow.requestType) {
case "reschedule": {
if (typeof details.startTime === "string" && TIME_RE.test(details.startTime))
update.startTime = details.startTime;
if (typeof details.endTime === "string" && TIME_RE.test(details.endTime))
update.endTime = details.endTime;
if (typeof details.meetingDate === "string" && DATE_RE.test(details.meetingDate))
update.meetingDate = details.meetingDate;
// Effective time-range guard: combine new fields with existing values
const effStart =
(update.startTime as string | undefined) ?? (existing.startTime as string | null);
const effEnd =
(update.endTime as string | undefined) ?? (existing.endTime as string | null);
if (effStart && effEnd && effStart > effEnd) {
throw new Error("APPLY_VALIDATION:startTime must be <= endTime");
}
break;
}
case "change_location": {
if (typeof details.location === "string")
update.location = details.location.slice(0, 300);
if (typeof details.meetingUrl === "string")
update.meetingUrl = details.meetingUrl.slice(0, 1000);
if (
typeof details.platform === "string" &&
(PLATFORMS as readonly string[]).includes(details.platform)
)
update.platform = details.platform;
break;
}
case "cancel_meeting":
update.status = "cancelled";
break;
case "highlight":
update.isHighlighted = 1;
break;
case "unhighlight":
update.isHighlighted = 0;
break;
case "note": {
const newNote =
typeof details.note === "string" ? details.note.slice(0, 5000) : "";
const prev = existing.notes ?? "";
update.notes = prev ? `${prev}\n${newNote}` : newNote;
break;
}
case "add_attendee": {
const name = typeof details.name === "string" ? details.name.trim() : "";
if (!name) break;
const title =
typeof details.title === "string" ? details.title.slice(0, 200) : null;
const attendanceType =
typeof details.attendanceType === "string" &&
["internal", "virtual", "external"].includes(details.attendanceType)
? details.attendanceType
: "internal";
const [{ value: maxOrder }] = await tx
.select({ value: sql<number>`coalesce(max(${executiveMeetingAttendeesTable.sortOrder}), -1)` })
.from(executiveMeetingAttendeesTable)
.where(eq(executiveMeetingAttendeesTable.meetingId, meetingId));
await tx.insert(executiveMeetingAttendeesTable).values({
meetingId,
name: name.slice(0, 200),
title,
attendanceType,
sortOrder: (maxOrder ?? -1) + 1,
});
break;
}
case "remove_attendee": {
const attendeeId = Number(details.attendeeId);
if (Number.isInteger(attendeeId) && attendeeId > 0) {
await tx
.delete(executiveMeetingAttendeesTable)
.where(
and(
eq(executiveMeetingAttendeesTable.id, attendeeId),
eq(executiveMeetingAttendeesTable.meetingId, meetingId),
),
);
}
break;
}
default:
break;
}
if (Object.keys(update).length > 1) {
await tx
.update(executiveMeetingsTable)
.set(update as never)
.where(eq(executiveMeetingsTable.id, meetingId));
}
}
router.get(
"/executive-meetings/requests",
@@ -753,8 +1050,113 @@ router.patch(
return;
}
// Approve / Reject: approver roles only
// Approve / Reject: approver roles only — atomic state guard in SQL
if (action === "approve" || action === "reject") {
const canApprove = APPROVE_ROLES.some((r) => names.has(r));
if (!canApprove) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
return;
}
if (existing.status !== "new") {
res.status(409).json({ error: "Already reviewed", code: "bad_state" });
return;
}
// Pre-validate apply payload before opening tx (fail fast for approve)
if (action === "approve") {
const validationError = validateApplyPayload(existing);
if (validationError) {
res.status(400).json({ error: validationError, code: "validation" });
return;
}
}
const reviewNotes =
typeof body.reviewNotes === "string" ? body.reviewNotes.trim() : null;
const newStatus = action === "approve" ? "approved" : "rejected";
try {
const updated = await db.transaction(async (tx) => {
// Atomic conditional update: only flip if still 'new'
const [row] = await tx
.update(executiveMeetingRequestsTable)
.set({
status: newStatus,
reviewedBy: userId,
reviewDecision: newStatus,
reviewNotes,
})
.where(
and(
eq(executiveMeetingRequestsTable.id, id),
eq(executiveMeetingRequestsTable.status, "new"),
),
)
.returning();
if (!row) {
// Lost race — another reviewer got there first
throw new Error("RACE_ALREADY_REVIEWED");
}
await logAudit(tx, {
action: action === "approve" ? "approve" : "reject",
entityType: "request",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: userId,
});
if (action === "approve") {
// Apply request to the underlying meeting (when applicable)
await applyApprovedRequest(tx, row, userId);
if (row.meetingId) {
await logAudit(tx, {
action: "apply",
entityType: "meeting",
entityId: row.meetingId,
oldValue: { fromRequestId: row.id },
newValue: { requestType: row.requestType, details: row.requestDetails },
performedBy: userId,
});
}
// Create a coordination follow-up task linked to this request
const [task] = await tx
.insert(executiveMeetingTasksTable)
.values({
taskType: `follow_up_${row.requestType}`,
meetingId: row.meetingId,
requestId: row.id,
assignedTo: row.assignedTo ?? null,
status: "pending",
notes: reviewNotes,
})
.returning();
await logAudit(tx, {
action: "create",
entityType: "task",
entityId: task!.id,
newValue: task!,
performedBy: userId,
});
}
return row;
});
res.json(updated);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "RACE_ALREADY_REVIEWED") {
res.status(409).json({ error: "Already reviewed", code: "bad_state" });
return;
}
if (msg.startsWith("APPLY_VALIDATION:")) {
res
.status(400)
.json({ error: msg.slice("APPLY_VALIDATION:".length), code: "validation" });
return;
}
res.status(500).json({ error: msg, code: "server_error" });
}
return;
}
// Office manager: needs_edit (returns to requester) — atomic, only from 'new'
if (action === "needs_edit") {
const canApprove = APPROVE_ROLES.some((r) => names.has(r));
if (!canApprove) {
res.status(403).json({ error: "Forbidden", code: "forbidden" });
@@ -766,29 +1168,43 @@ router.patch(
}
const reviewNotes =
typeof body.reviewNotes === "string" ? body.reviewNotes.trim() : null;
const newStatus = action === "approve" ? "approved" : "rejected";
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(executiveMeetingRequestsTable)
.set({
status: newStatus,
reviewedBy: userId,
reviewDecision: newStatus,
reviewNotes,
})
.where(eq(executiveMeetingRequestsTable.id, id))
.returning();
await logAudit(tx, {
action: action === "approve" ? "approve" : "reject",
entityType: "request",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: userId,
try {
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(executiveMeetingRequestsTable)
.set({
status: "needs_edit",
reviewedBy: userId,
reviewDecision: "needs_edit",
reviewNotes,
})
.where(
and(
eq(executiveMeetingRequestsTable.id, id),
eq(executiveMeetingRequestsTable.status, "new"),
),
)
.returning();
if (!row) throw new Error("RACE_ALREADY_REVIEWED");
await logAudit(tx, {
action: "needs_edit",
entityType: "request",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: userId,
});
return row;
});
return row;
});
res.json(updated);
res.json(updated);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "RACE_ALREADY_REVIEWED") {
res.status(409).json({ error: "Already reviewed", code: "bad_state" });
return;
}
res.status(500).json({ error: msg, code: "server_error" });
}
return;
}
@@ -997,6 +1413,33 @@ router.patch(
newValue: row,
performedBy: userId,
});
// Propagate task completion → originating request becomes "done"
if (
row &&
row.status === "completed" &&
existing.status !== "completed" &&
row.requestId
) {
const [reqOld] = await tx
.select()
.from(executiveMeetingRequestsTable)
.where(eq(executiveMeetingRequestsTable.id, row.requestId));
if (reqOld && reqOld.status !== "done") {
const [reqNew] = await tx
.update(executiveMeetingRequestsTable)
.set({ status: "done" })
.where(eq(executiveMeetingRequestsTable.id, row.requestId))
.returning();
await logAudit(tx, {
action: "done",
entityType: "request",
entityId: row.requestId,
oldValue: reqOld,
newValue: reqNew,
performedBy: userId,
});
}
}
return row;
});
res.json(updated);
@@ -1048,8 +1491,17 @@ router.get(
requireAdminAudit,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
const dateFromRaw =
typeof req.query.dateFrom === "string" ? req.query.dateFrom.trim() : "";
const dateToRaw =
typeof req.query.dateTo === "string" ? req.query.dateTo.trim() : "";
const entityType =
typeof req.query.entityType === "string" ? req.query.entityType.trim() : "";
const action =
typeof req.query.action === "string" ? req.query.action.trim() : "";
const actorRaw = Number(req.query.actorId);
const actorId =
Number.isInteger(actorRaw) && actorRaw > 0 ? actorRaw : null;
const limitRaw = Number(req.query.limit);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 100;
@@ -1060,9 +1512,28 @@ router.get(
conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start));
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
}
if (dateFromRaw && DATE_RE.test(dateFromRaw)) {
conds.push(
gte(
executiveMeetingAuditLogsTable.performedAt,
new Date(`${dateFromRaw}T00:00:00.000Z`),
),
);
}
if (dateToRaw && DATE_RE.test(dateToRaw)) {
const end = new Date(`${dateToRaw}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 1);
conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end));
}
if (entityType) {
conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType));
}
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 rows = await db
.select({
@@ -1098,7 +1569,7 @@ router.get(
router.get(
"/executive-meetings/notifications",
requireAuth,
requireAdminAudit,
requireRead,
async (_req, res): Promise<void> => {
const rows = await db
.select({
@@ -1126,6 +1597,101 @@ router.get(
},
);
// =====================================================================
// PDF ARCHIVES
// =====================================================================
router.get(
"/executive-meetings/pdf-archives",
requireAuth,
requireRead,
async (req, res): Promise<void> => {
const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : "";
const conds = [];
if (dateRaw && DATE_RE.test(dateRaw)) {
conds.push(eq(executiveMeetingPdfArchivesTable.archiveDate, dateRaw));
}
const where = conds.length > 0 ? and(...conds) : undefined;
const rows = await db
.select({
a: executiveMeetingPdfArchivesTable,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(executiveMeetingPdfArchivesTable)
.leftJoin(
usersTable,
eq(usersTable.id, executiveMeetingPdfArchivesTable.generatedBy),
)
.where(where as never)
.orderBy(desc(executiveMeetingPdfArchivesTable.generatedAt))
.limit(200);
res.json({
archives: rows.map((row) => ({
...row.a,
generator: row.username
? {
username: row.username,
displayNameAr: row.displayNameAr,
displayNameEn: row.displayNameEn,
}
: null,
})),
});
},
);
router.post(
"/executive-meetings/pdf-archives",
requireAuth,
requireRead,
async (req, res): Promise<void> => {
const body = (req.body ?? {}) as Record<string, unknown>;
const archiveDate =
typeof body.archiveDate === "string" && DATE_RE.test(body.archiveDate)
? body.archiveDate
: null;
if (!archiveDate) {
res
.status(400)
.json({ error: "archiveDate required (YYYY-MM-DD)", code: "validation" });
return;
}
const filePath =
typeof body.filePath === "string" && body.filePath.trim()
? body.filePath.trim().slice(0, 500)
: `print:${archiveDate}`;
const userId = req.session.userId!;
const created = await db.transaction(async (tx) => {
const [{ value: maxV }] = await tx
.select({
value: sql<number>`coalesce(max(${executiveMeetingPdfArchivesTable.version}), 0)`,
})
.from(executiveMeetingPdfArchivesTable)
.where(eq(executiveMeetingPdfArchivesTable.archiveDate, archiveDate));
const [row] = await tx
.insert(executiveMeetingPdfArchivesTable)
.values({
archiveDate,
filePath,
version: (maxV ?? 0) + 1,
generatedBy: userId,
})
.returning();
await logAudit(tx, {
action: "create",
entityType: "pdf_archive",
entityId: row!.id,
newValue: row!,
performedBy: userId,
});
return row;
});
res.status(201).json(created);
},
);
// =====================================================================
// FONT SETTINGS
// =====================================================================
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+5
View File
@@ -18,6 +18,7 @@ import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
import ExecutiveMeetingsPrintPage from "@/pages/executive-meetings-print";
const queryClient = new QueryClient({
defaultOptions: {
@@ -50,6 +51,10 @@ function Router() {
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/executive-meetings" component={ExecutiveMeetingsPage} />
<Route
path="/executive-meetings/print"
component={ExecutiveMeetingsPrintPage}
/>
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
+7 -1
View File
@@ -681,6 +681,8 @@
"attendees": {
"label": "قائمة الحضور",
"name": "الاسم",
"title": "المسمى",
"id": "رقم الحاضر",
"type": "نوع الحضور",
"add": "إضافة حاضر",
"remove": "إزالة"
@@ -811,7 +813,11 @@
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
"print": "طباعة الجدول",
"openSchedule": "عرض الجدول",
"selectDate": "تاريخ الجدول"
"selectDate": "تاريخ الجدول",
"archiveSnapshot": "أرشفة نسخة",
"archived": "تمت أرشفة النسخة",
"archivesHeading": "النسخ المؤرشفة",
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
+7 -1
View File
@@ -678,6 +678,8 @@
"attendees": {
"label": "Attendees",
"name": "Name",
"title": "Title",
"id": "Attendee ID",
"type": "Attendance type",
"add": "Add attendee",
"remove": "Remove"
@@ -808,7 +810,11 @@
"intro": "This view uses the browser print dialog to produce a PDF of the day's schedule. Pick a date and click Print.",
"print": "Print schedule",
"openSchedule": "Open schedule",
"selectDate": "Schedule date"
"selectDate": "Schedule date",
"archiveSnapshot": "Archive snapshot",
"archived": "Snapshot archived",
"archivesHeading": "Archived snapshots",
"noArchives": "No snapshots archived for this date yet."
},
"fontSettingsPage": {
"heading": "Font Settings",
@@ -0,0 +1,207 @@
import { useEffect, useMemo, useState } from "react";
type Attendee = {
id: number;
name: string;
title: string | null;
attendanceType: "internal" | "external" | "online";
sortOrder: number;
};
type Meeting = {
id: number;
dailyNumber: number;
titleAr: string;
titleEn: string | null;
meetingDate: string;
startTime: string | null;
endTime: string | null;
location: string | null;
meetingUrl: string | null;
platform: string | null;
status: string;
isHighlighted: number;
notes: string | null;
attendees?: Attendee[];
};
type DayResponse = { date: string; meetings: Meeting[] };
function getQuery(): { date: string; lang: "ar" | "en" } {
const params = new URLSearchParams(window.location.search);
const date =
params.get("date") || new Date().toISOString().slice(0, 10);
const lang = params.get("lang") === "en" ? "en" : "ar";
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…",
},
} as const;
export default function ExecutiveMeetingsPrintPage() {
const { date, lang } = useMemo(getQuery, []);
const [data, setData] = useState<DayResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const isRtl = lang === "ar";
const t = T[lang];
useEffect(() => {
document.documentElement.dir = isRtl ? "rtl" : "ltr";
document.documentElement.lang = lang;
document.title = `${t.title}${date}`;
}, [isRtl, lang, date, t.title]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/executive-meetings?date=${date}`, {
credentials: "include",
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = (await res.json()) as DayResponse;
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
}
})();
return () => {
cancelled = true;
};
}, [date]);
const meetings = data?.meetings ?? [];
return (
<div
className="min-h-screen bg-white text-[#0B1E3F]"
data-testid="em-print-page"
>
<style>{`
@media print {
.no-print { display: none !important; }
body { background: white; }
@page { size: A4; margin: 14mm; }
}
.em-print-table { border-collapse: collapse; width: 100%; }
.em-print-table th, .em-print-table td {
border: 1px solid #d1d5db;
padding: 8px 10px;
text-align: center;
vertical-align: middle;
}
.em-print-table th {
background: #0B1E3F;
color: white;
font-weight: 600;
}
.em-print-table tr.hl td { background: #fee2e2; }
`}</style>
<div className="no-print flex items-center justify-between px-6 py-3 border-b border-gray-200 bg-gray-50">
<div className="text-sm text-gray-600">
{t.title} {date}
</div>
<div className="flex gap-2">
<button
onClick={() => window.print()}
className="px-3 py-1.5 rounded-md bg-[#0B1E3F] text-white text-sm"
data-testid="em-print-now"
>
{t.print}
</button>
<button
onClick={() => window.close()}
className="px-3 py-1.5 rounded-md border border-gray-300 text-sm"
>
{t.back}
</button>
</div>
</div>
<div className="px-8 py-6 max-w-5xl mx-auto">
<h1 className="text-2xl font-bold text-center mb-1">{t.title}</h1>
<div className="text-center text-sm text-gray-600 mb-6">{date}</div>
{error ? (
<div className="text-red-600 text-sm">{error}</div>
) : !data ? (
<div className="text-gray-500 text-sm">{t.loading}</div>
) : meetings.length === 0 ? (
<div className="text-gray-500 text-sm text-center">{t.none}</div>
) : (
<table className="em-print-table" dir={isRtl ? "rtl" : "ltr"}>
<thead>
<tr>
<th style={{ width: "8%" }}>{t.no}</th>
<th style={{ width: "32%" }}>{t.meeting}</th>
<th style={{ width: "40%" }}>{t.attendees}</th>
<th style={{ width: "20%" }}>{t.time}</th>
</tr>
</thead>
<tbody>
{meetings.map((m) => {
const title = isRtl
? m.titleAr
: m.titleEn || m.titleAr;
const attendees = (m.attendees ?? [])
.map((a) => (a.title ? `${a.name} (${a.title})` : a.name))
.join("، ");
const time =
m.startTime && m.endTime
? `${m.startTime.slice(0, 5)} ${m.endTime.slice(0, 5)}`
: m.startTime
? m.startTime.slice(0, 5)
: "—";
return (
<tr key={m.id} className={m.isHighlighted ? "hl" : ""}>
<td>{m.dailyNumber}</td>
<td>
<div style={{ fontWeight: 600 }}>{title}</div>
{m.location ? (
<div style={{ fontSize: 12, color: "#4b5563" }}>
{m.location}
</div>
) : null}
</td>
<td style={{ textAlign: isRtl ? "right" : "left" }}>
{attendees || "—"}
</td>
<td>{time}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -165,6 +165,20 @@ type FontSettingsResponse = {
user: ({ scope: "user"; userId: number } & FontPrefs) | null;
};
type PdfArchive = {
id: number;
archiveDate: string;
filePath: string;
version: number;
generatedBy: number | null;
generatedAt: string;
generator: {
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
} | null;
};
const SECTIONS = [
{ key: "schedule", icon: CalendarClock },
{ key: "manage", icon: ListChecks },
@@ -239,6 +253,7 @@ export default function ExecutiveMeetingsPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const isRtl = i18n.language === "ar";
const lang: "ar" | "en" = isRtl ? "ar" : "en";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [section, setSection] = useState<SectionKey>("schedule");
@@ -407,7 +422,7 @@ export default function ExecutiveMeetingsPage() {
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
)}
{section === "pdf" && (
<PdfSection date={date} onDateChange={setDate} t={t} />
<PdfSection date={date} onDateChange={setDate} lang={lang} t={t} />
)}
{section === "fontSettings" && me && (
<FontSettingsSection
@@ -1141,10 +1156,34 @@ function RequestsSection({
const [scope, setScope] = useState<"all" | "mine">("all");
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
const [form, setForm] = useState<{
requestType: string;
meetingId: string;
details: string;
meetingDate: string;
startTime: string;
endTime: string;
location: string;
meetingUrl: string;
platform: string;
attendeeName: string;
attendeeTitle: string;
attendeeType: string;
attendeeId: string;
}>({
requestType: "edit",
meetingId: "",
details: "",
meetingDate: "",
startTime: "",
endTime: "",
location: "",
meetingUrl: "",
platform: "none",
attendeeName: "",
attendeeTitle: "",
attendeeType: "internal",
attendeeId: "",
});
const queryKey = ["/api/executive-meetings/requests", filterStatus, scope];
@@ -1158,19 +1197,62 @@ function RequestsSection({
},
});
function buildRequestDetails(): Record<string, unknown> {
const note = form.details.trim();
const out: Record<string, unknown> = {};
if (note) out.note = note;
switch (form.requestType) {
case "reschedule":
if (form.meetingDate) out.meetingDate = form.meetingDate;
if (form.startTime) out.startTime = form.startTime;
if (form.endTime) out.endTime = form.endTime;
break;
case "change_location":
if (form.location.trim()) out.location = form.location.trim();
if (form.meetingUrl.trim()) out.meetingUrl = form.meetingUrl.trim();
if (form.platform) out.platform = form.platform;
break;
case "add_attendee":
if (form.attendeeName.trim()) out.name = form.attendeeName.trim();
if (form.attendeeTitle.trim()) out.title = form.attendeeTitle.trim();
if (form.attendeeType) out.attendanceType = form.attendeeType;
break;
case "remove_attendee":
if (form.attendeeId.trim()) out.attendeeId = Number(form.attendeeId);
break;
default:
break;
}
return out;
}
async function submit() {
if (!me.canSubmitRequest) return;
setSubmitting(true);
try {
const body: Record<string, unknown> = {
requestType: form.requestType,
requestDetails: { note: form.details.trim() },
requestDetails: buildRequestDetails(),
};
if (form.meetingId.trim()) body.meetingId = Number(form.meetingId);
await apiJson(`/api/executive-meetings/requests`, { method: "POST", body });
toast({ title: t("executiveMeetings.requests.submitted") });
setOpen(false);
setForm({ requestType: "edit", meetingId: "", details: "" });
setForm({
requestType: "edit",
meetingId: "",
details: "",
meetingDate: "",
startTime: "",
endTime: "",
location: "",
meetingUrl: "",
platform: "none",
attendeeName: "",
attendeeTitle: "",
attendeeType: "internal",
attendeeId: "",
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -1323,9 +1405,96 @@ function RequestsSection({
onChange={(e) => setForm({ ...form, meetingId: e.target.value })}
/>
</FormRow>
{form.requestType === "reschedule" && (
<>
<FormRow label={t("executiveMeetings.manage.field.date")}>
<Input
type="date"
value={form.meetingDate}
onChange={(e) =>
setForm({ ...form, meetingDate: e.target.value })
}
data-testid="em-req-date"
/>
</FormRow>
<div className="grid grid-cols-2 gap-3">
<FormRow label={t("executiveMeetings.manage.field.startTime")}>
<Input
type="time"
value={form.startTime}
onChange={(e) =>
setForm({ ...form, startTime: e.target.value })
}
data-testid="em-req-start"
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.endTime")}>
<Input
type="time"
value={form.endTime}
onChange={(e) =>
setForm({ ...form, endTime: e.target.value })
}
data-testid="em-req-end"
/>
</FormRow>
</div>
</>
)}
{form.requestType === "change_location" && (
<>
<FormRow label={t("executiveMeetings.manage.field.location")}>
<Input
value={form.location}
onChange={(e) =>
setForm({ ...form, location: e.target.value })
}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.field.meetingUrl")}>
<Input
value={form.meetingUrl}
onChange={(e) =>
setForm({ ...form, meetingUrl: e.target.value })
}
/>
</FormRow>
</>
)}
{form.requestType === "add_attendee" && (
<>
<FormRow label={t("executiveMeetings.manage.attendees.name")}>
<Input
value={form.attendeeName}
onChange={(e) =>
setForm({ ...form, attendeeName: e.target.value })
}
/>
</FormRow>
<FormRow label={t("executiveMeetings.manage.attendees.title")}>
<Input
value={form.attendeeTitle}
onChange={(e) =>
setForm({ ...form, attendeeTitle: e.target.value })
}
/>
</FormRow>
</>
)}
{form.requestType === "remove_attendee" && (
<FormRow label={t("executiveMeetings.manage.attendees.id")}>
<Input
type="number"
value={form.attendeeId}
onChange={(e) =>
setForm({ ...form, attendeeId: e.target.value })
}
/>
</FormRow>
)}
<FormRow label={t("executiveMeetings.requests.details")}>
<Textarea
rows={4}
rows={3}
value={form.details}
placeholder={t("executiveMeetings.requests.detailsPlaceholder")}
onChange={(e) => setForm({ ...form, details: e.target.value })}
@@ -1438,7 +1607,17 @@ function ApprovalsSection({
? t("executiveMeetings.approvals.approved")
: t("executiveMeetings.approvals.rejected"),
});
await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
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) &&
q.queryKey[0] === "/api/executive-meetings",
}),
]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({
@@ -1987,14 +2166,52 @@ function NotificationsSection({
function PdfSection({
date,
onDateChange,
lang,
t,
}: {
date: string;
onDateChange: (d: string) => void;
lang: "ar" | "en";
t: (k: string) => string;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const [busy, setBusy] = useState(false);
const archivesQuery = useQuery<{ archives: PdfArchive[] }>({
queryKey: ["/api/executive-meetings/pdf-archives", date],
queryFn: async () =>
apiJson(`/api/executive-meetings/pdf-archives?date=${date}`),
enabled: !!date,
});
function openPrint() {
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
window.open(url, "_blank", "noopener");
}
async function archiveSnapshot() {
setBusy(true);
try {
await apiJson(`/api/executive-meetings/pdf-archives`, {
method: "POST",
body: { archiveDate: date },
});
toast({ title: t("executiveMeetings.pdf.archived") });
await qc.invalidateQueries({
queryKey: ["/api/executive-meetings/pdf-archives", date],
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast({ title: msg, variant: "destructive" });
} finally {
setBusy(false);
}
}
const archives = archivesQuery.data?.archives ?? [];
return (
<div className="space-y-4 max-w-xl">
<div className="space-y-4 max-w-2xl">
<h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
{t("executiveMeetings.pdf.heading")}
</h2>
@@ -2007,20 +2224,75 @@ function PdfSection({
onChange={(e) => onDateChange(e.target.value)}
/>
</FormRow>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => window.print()}
onClick={openPrint}
className="bg-[#0B1E3F] gap-1"
data-testid="em-print"
>
<Printer className="w-4 h-4" />
{t("executiveMeetings.pdf.print")}
</Button>
<Button
onClick={archiveSnapshot}
disabled={busy || !date}
variant="outline"
className="gap-1"
data-testid="em-archive"
>
<FileText className="w-4 h-4" />
{t("executiveMeetings.pdf.archiveSnapshot")}
</Button>
</div>
<p className="text-xs text-gray-500">
{t("executiveMeetings.pdf.openSchedule")}
</p>
</div>
<div className="bg-white border border-gray-200 rounded-md">
<div className="px-4 py-2 border-b border-gray-200 text-sm font-semibold text-[#0B1E3F]">
{t("executiveMeetings.pdf.archivesHeading")}
</div>
{archivesQuery.isLoading ? (
<div className="p-4 text-sm text-gray-500">{t("common.loading")}</div>
) : archives.length === 0 ? (
<div className="p-4 text-sm text-gray-500">
{t("executiveMeetings.pdf.noArchives")}
</div>
) : (
<ul className="divide-y divide-gray-100" data-testid="em-archives-list">
{archives.map((a) => {
const who =
a.generator
? lang === "ar"
? a.generator.displayNameAr || a.generator.username
: a.generator.displayNameEn || a.generator.username
: "—";
return (
<li
key={a.id}
className="px-4 py-2 text-sm flex items-center justify-between gap-3"
>
<div>
<div className="font-medium text-[#0B1E3F]">
v{a.version} {a.archiveDate}
</div>
<div className="text-xs text-gray-500">
{new Date(a.generatedAt).toLocaleString(
lang === "ar" ? "ar" : "en",
)}{" "}
· {who}
</div>
</div>
<span className="text-xs text-gray-400 truncate max-w-[200px]">
{a.filePath}
</span>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}