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:
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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": "إعدادات الخط",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user