diff --git a/.replit b/.replit index 55986d9b..03f8ea94 100644 --- a/.replit +++ b/.replit @@ -1,4 +1,4 @@ -modules = ["nodejs-24", "postgresql-16"] +modules = ["nodejs-24", "postgresql-16", "python-3.11"] [deployment] router = "application" diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 34e92c75..918df1bd 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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> { 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 => { + 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; + 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 => { + 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; + 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`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; + 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 { + const details = (reqRow.requestDetails ?? {}) as Record; + 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 = { 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`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 => { 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 => { 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 => { + 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 => { + const body = (req.body ?? {}) as Record; + 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`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 // ===================================================================== diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 326f2774..1830d67a 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index 1f5c5891..1d4092f2 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -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() { + diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 8e8b5dc6..725b78a2 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "إعدادات الخط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 2e351eb0..b9417c6f 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/executive-meetings-print.tsx b/artifacts/tx-os/src/pages/executive-meetings-print.tsx new file mode 100644 index 00000000..3e188f3b --- /dev/null +++ b/artifacts/tx-os/src/pages/executive-meetings-print.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+ + +
+
+ {t.title} — {date} +
+
+ + +
+
+ +
+

{t.title}

+
{date}
+ + {error ? ( +
{error}
+ ) : !data ? ( +
{t.loading}
+ ) : meetings.length === 0 ? ( +
{t.none}
+ ) : ( + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
{t.no}{t.meeting}{t.attendees}{t.time}
{m.dailyNumber} +
{title}
+ {m.location ? ( +
+ {m.location} +
+ ) : null} +
+ {attendees || "—"} + {time}
+ )} +
+
+ ); +} diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index f7ba7ce4..dfb7bb77 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -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("schedule"); @@ -407,7 +422,7 @@ export default function ExecutiveMeetingsPage() { )} {section === "pdf" && ( - + )} {section === "fontSettings" && me && ( ("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 { + const note = form.details.trim(); + const out: Record = {}; + 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 = { 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 })} /> + {form.requestType === "reschedule" && ( + <> + + + setForm({ ...form, meetingDate: e.target.value }) + } + data-testid="em-req-date" + /> + +
+ + + setForm({ ...form, startTime: e.target.value }) + } + data-testid="em-req-start" + /> + + + + setForm({ ...form, endTime: e.target.value }) + } + data-testid="em-req-end" + /> + +
+ + )} + {form.requestType === "change_location" && ( + <> + + + setForm({ ...form, location: e.target.value }) + } + /> + + + + setForm({ ...form, meetingUrl: e.target.value }) + } + /> + + + )} + {form.requestType === "add_attendee" && ( + <> + + + setForm({ ...form, attendeeName: e.target.value }) + } + /> + + + + setForm({ ...form, attendeeTitle: e.target.value }) + } + /> + + + )} + {form.requestType === "remove_attendee" && ( + + + setForm({ ...form, attendeeId: e.target.value }) + } + /> + + )}