From b4d47e1012e7aba5decff8d090b0e7cbb46a9e96 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 28 Apr 2026 06:18:08 +0000 Subject: [PATCH] Executive Meetings Phase 2: full module + atomic audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original task (#108): polish the daily-schedule UI and ship the remaining 8 sections of the Executive Meetings module (Manage, Requests, Approvals, Tasks, Notifications, Audit, PDF, Font Settings) with bilingual i18n and fine-grained RBAC. What changed: - Schedule UI: centered cells, '#' header (not 'م'), attendees widest, RTL column order locked, navy/white/gray + red highlighting only. - Schema: made executive_meeting_requests.meetingId nullable so 'create-meeting' suggestions don't need a target row. - Backend rewrite of artifacts/api-server/src/routes/executive-meetings.ts: GET /me (now returns userId), full CRUD for meetings (transactional attendee replace), requests CRUD + approve/reject/withdraw, tasks CRUD with assignee status updates, audit-logs GET (admin), notifications GET, font-settings GET/PATCH (per-user + global). RBAC via 5 role sets and a makeRequireRoles middleware factory. - Audit-in-transaction: every mutation (meeting/request/task/font CRUD) wraps the DB write AND the audit-log insert in the same db.transaction so audit entries cannot drift from state if either insert fails. This was the main finding from the architect review and is now fixed. - Path-to-regexp 8 fix: replaced inline ':id(\\d+)' with router.param('id') + next('route') guard, plus NaN guards in handlers. - Frontend rewrite of artifacts/tx-os/src/pages/executive-meetings.tsx (~2200 lines): 8 new section components, RBAC-aware UI via /me, per-task assignee check now shown for status buttons. - i18n: full executiveMeetings.* key set added in ar.json + en.json. Verified: full e2e (admin login -> all 9 tabs -> create meeting -> submit request -> approve -> audit shows full chain -> font save) plus a smoke regression after the audit-in-tx refactor (atomic audit row created in lockstep with mutation). Out of scope (proposed as follow-ups #110-#112): real notification delivery, real PDF generation (currently window.print), and automated integration tests for the new endpoints. The pre-existing 'apps-open.test.mjs' syntax error in the test workflow is unrelated to this task. --- .../src/routes/executive-meetings.ts | 1223 ++++++++++- artifacts/tx-os/public/opengraph.jpg | Bin 24619 -> 25930 bytes artifacts/tx-os/src/locales/ar.json | 227 +- artifacts/tx-os/src/locales/en.json | 225 ++ .../tx-os/src/pages/executive-meetings.tsx | 1876 ++++++++++++++++- lib/db/src/schema/executive-meetings.ts | 7 +- replit.md | 2 +- 7 files changed, 3471 insertions(+), 89 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 16a4a0d6..34e92c75 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1,19 +1,37 @@ import { Router, type IRouter } from "express"; -import { eq, asc, inArray } from "drizzle-orm"; +import { eq, asc, desc, inArray, and, sql, gte, lt, isNull, or } from "drizzle-orm"; import { db } from "@workspace/db"; import { executiveMeetingsTable, executiveMeetingAttendeesTable, + executiveMeetingRequestsTable, + executiveMeetingTasksTable, + executiveMeetingNotificationsTable, + executiveMeetingAuditLogsTable, + executiveMeetingFontSettingsTable, rolesTable, + usersTable, } from "@workspace/db"; import { requireAuth, getEffectiveRoleIds } from "../middlewares/auth"; import type { Request, Response, NextFunction } from "express"; const router: IRouter = Router(); -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// Constrain the :id placeholder to numeric values; otherwise fall through +// to subsequent routes. Required because path-to-regexp 8 (Express 5) no +// longer supports inline regex like ":id(\\d+)". +router.param("id", (req, res, next, value) => { + if (!/^\d+$/.test(String(value))) { + next("route"); + return; + } + next(); +}); -const EXECUTIVE_ROLE_NAMES = [ +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const TIME_RE = /^\d{2}:\d{2}(:\d{2})?$/; + +const READ_ROLES = [ "admin", "executive_ceo", "executive_office_manager", @@ -22,41 +40,250 @@ const EXECUTIVE_ROLE_NAMES = [ "executive_viewer", ] as const; -async function requireExecutiveAccess( - req: Request, - res: Response, - next: NextFunction, -): Promise { - if (!req.session.userId) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - const effectiveIds = await getEffectiveRoleIds(req.session.userId); - if (effectiveIds.length === 0) { - res.status(403).json({ error: "Forbidden" }); - return; - } - const allowed = await db - .select({ id: rolesTable.id }) +const MUTATE_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", +] as const; + +const APPROVE_ROLES = ["admin", "executive_office_manager"] as const; + +const REQUEST_ROLES = [ + "admin", + "executive_office_manager", + "executive_coord_lead", + "executive_coordinator", +] as const; + +const ADMIN_AUDIT_ROLES = ["admin", "executive_office_manager"] as const; + +async function getRoleNamesForUser(userId: number): Promise> { + const ids = await getEffectiveRoleIds(userId); + if (ids.length === 0) return new Set(); + const rows = await db + .select({ name: rolesTable.name }) .from(rolesTable) - .where(inArray(rolesTable.name, EXECUTIVE_ROLE_NAMES as unknown as string[])); - const allowedIds = new Set(allowed.map((r) => r.id)); - const ok = effectiveIds.some((id) => allowedIds.has(id)); - if (!ok) { - res.status(403).json({ error: "Forbidden" }); - return; - } - next(); + .where(inArray(rolesTable.id, ids)); + return new Set(rows.map((r) => r.name)); } +function makeRequireRoles(allowed: ReadonlyArray) { + return async function ( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const names = await getRoleNamesForUser(req.session.userId); + const ok = allowed.some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + next(); + }; +} + +const requireRead = makeRequireRoles(READ_ROLES); +const requireMutate = makeRequireRoles(MUTATE_ROLES); +const requireApprove = makeRequireRoles(APPROVE_ROLES); +const requireRequest = makeRequireRoles(REQUEST_ROLES); +const requireAdminAudit = makeRequireRoles(ADMIN_AUDIT_ROLES); + +type DbExecutor = typeof db | Parameters[0]>[0]; + +async function logAudit( + executor: DbExecutor, + opts: { + action: string; + entityType: string; + entityId: number | null; + oldValue?: unknown; + newValue?: unknown; + performedBy: number | null; + }, +): Promise { + await executor.insert(executiveMeetingAuditLogsTable).values({ + action: opts.action, + entityType: opts.entityType, + entityId: opts.entityId ?? null, + oldValue: (opts.oldValue ?? null) as never, + newValue: (opts.newValue ?? null) as never, + performedBy: opts.performedBy, + }); +} + +// ---------- Validation helpers ---------- + +type AttendeeInput = { + name: string; + title?: string | null; + attendanceType: "internal" | "virtual" | "external"; + sortOrder?: number; +}; + +const PLATFORMS = ["none", "webex", "teams", "zoom", "other"] as const; +const STATUSES = ["scheduled", "cancelled", "completed", "postponed"] as const; +const ATTENDANCE_TYPES = ["internal", "virtual", "external"] as const; + +function validString(v: unknown, max: number): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + if (t.length === 0 || t.length > max) return null; + return t; +} + +function optionalString(v: unknown, max: number): string | null | undefined { + if (v === undefined) return undefined; + if (v === null || v === "") return null; + if (typeof v !== "string") return null; + const t = v.trim(); + if (t.length === 0) return null; + if (t.length > max) return null; + return t; +} + +function parseAttendees(raw: unknown): AttendeeInput[] | null { + if (!Array.isArray(raw)) return null; + const out: AttendeeInput[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") return null; + const r = item as Record; + const name = validString(r.name, 200); + if (!name) return null; + const at = typeof r.attendanceType === "string" ? r.attendanceType : "internal"; + if (!ATTENDANCE_TYPES.includes(at as (typeof ATTENDANCE_TYPES)[number])) return null; + out.push({ + name, + title: optionalString(r.title, 200) ?? null, + attendanceType: at as AttendeeInput["attendanceType"], + sortOrder: typeof r.sortOrder === "number" ? r.sortOrder : 0, + }); + } + return out; +} + +type MeetingPatch = { + titleAr?: string; + titleEn?: string | null; + meetingDate?: string; + dailyNumber?: number; + startTime?: string | null; + endTime?: string | null; + location?: string | null; + meetingUrl?: string | null; + platform?: (typeof PLATFORMS)[number]; + status?: (typeof STATUSES)[number]; + isHighlighted?: number; + notes?: string | null; +}; + +function parseMeetingPatch( + body: Record, + requireBaseFields: boolean, +): { ok: true; data: MeetingPatch } | { ok: false; error: string } { + const out: MeetingPatch = {}; + if ("titleAr" in body) { + const v = validString(body.titleAr, 300); + if (!v) return { ok: false, error: "titleAr is required" }; + out.titleAr = v; + } else if (requireBaseFields) { + return { ok: false, error: "titleAr is required" }; + } + if ("titleEn" in body) { + const v = optionalString(body.titleEn, 300); + out.titleEn = v === undefined ? "" : v; + } + if ("meetingDate" in body) { + if (typeof body.meetingDate !== "string" || !DATE_RE.test(body.meetingDate)) { + return { ok: false, error: "meetingDate must be YYYY-MM-DD" }; + } + out.meetingDate = body.meetingDate; + } else if (requireBaseFields) { + return { ok: false, error: "meetingDate is required" }; + } + if ("dailyNumber" in body && body.dailyNumber !== null && body.dailyNumber !== undefined) { + const n = Number(body.dailyNumber); + if (!Number.isInteger(n) || n <= 0 || n > 1000) { + return { ok: false, error: "dailyNumber must be a positive integer" }; + } + out.dailyNumber = n; + } + for (const k of ["startTime", "endTime"] as const) { + if (k in body) { + if (body[k] === null || body[k] === "") { + out[k] = null; + } else if (typeof body[k] === "string" && TIME_RE.test(body[k] as string)) { + out[k] = body[k] as string; + } else { + return { ok: false, error: `${k} must be HH:MM or null` }; + } + } + } + for (const k of ["location", "meetingUrl", "notes"] as const) { + if (k in body) { + const v = optionalString(body[k], k === "notes" ? 5000 : 500); + out[k] = v === undefined ? null : v; + } + } + if ("platform" in body) { + if (!PLATFORMS.includes(body.platform as (typeof PLATFORMS)[number])) { + return { ok: false, error: "platform invalid" }; + } + out.platform = body.platform as (typeof PLATFORMS)[number]; + } + if ("status" in body) { + if (!STATUSES.includes(body.status as (typeof STATUSES)[number])) { + return { ok: false, error: "status invalid" }; + } + out.status = body.status as (typeof STATUSES)[number]; + } + if ("isHighlighted" in body) { + const n = Number(body.isHighlighted); + out.isHighlighted = n ? 1 : 0; + } + return { ok: true, data: out }; +} + +async function nextDailyNumber(date: string): Promise { + const [row] = await db + .select({ max: sql`max(${executiveMeetingsTable.dailyNumber})` }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, date)); + return (row?.max ?? 0) + 1; +} + +async function fetchMeetingWithAttendees(id: number) { + const [meeting] = await db + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)); + if (!meeting) return null; + const attendees = await db + .select() + .from(executiveMeetingAttendeesTable) + .where(eq(executiveMeetingAttendeesTable.meetingId, id)) + .orderBy(asc(executiveMeetingAttendeesTable.sortOrder)); + return { ...meeting, attendees }; +} + +// ===================================================================== +// MEETINGS +// ===================================================================== + router.get( "/executive-meetings", requireAuth, - requireExecutiveAccess, + requireRead, async (req, res): Promise => { const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; if (dateRaw && !DATE_RE.test(dateRaw)) { - res.status(400).json({ error: "Invalid 'date' (expected YYYY-MM-DD)" }); + res + .status(400) + .json({ error: "Invalid 'date' (expected YYYY-MM-DD)", code: "bad_date" }); return; } const targetDate = dateRaw || new Date().toISOString().slice(0, 10); @@ -71,37 +298,32 @@ router.get( res.json({ date: targetDate, meetings: [] }); return; } - - const meetingIds = meetings.map((m) => m.id); + const ids = meetings.map((m) => m.id); const attendees = await db .select() .from(executiveMeetingAttendeesTable) - .where(inArray(executiveMeetingAttendeesTable.meetingId, meetingIds)) + .where(inArray(executiveMeetingAttendeesTable.meetingId, ids)) .orderBy( asc(executiveMeetingAttendeesTable.meetingId), asc(executiveMeetingAttendeesTable.sortOrder), ); - const byMeeting = new Map(); for (const a of attendees) { const arr = byMeeting.get(a.meetingId) ?? []; arr.push(a); byMeeting.set(a.meetingId, arr); } - - const result = meetings.map((m) => ({ - ...m, - attendees: byMeeting.get(m.id) ?? [], - })); - - res.json({ date: targetDate, meetings: result }); + res.json({ + date: targetDate, + meetings: meetings.map((m) => ({ ...m, attendees: byMeeting.get(m.id) ?? [] })), + }); }, ); router.get( "/executive-meetings/dates", requireAuth, - requireExecutiveAccess, + requireRead, async (_req, res): Promise => { const rows = await db .selectDistinct({ d: executiveMeetingsTable.meetingDate }) @@ -111,4 +333,923 @@ router.get( }, ); +router.get( + "/executive-meetings/me", + requireAuth, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const names = await getRoleNamesForUser(req.session.userId); + res.json({ + userId: req.session.userId, + roles: Array.from(names), + canRead: READ_ROLES.some((r) => names.has(r)), + canMutate: MUTATE_ROLES.some((r) => names.has(r)), + canApprove: APPROVE_ROLES.some((r) => names.has(r)), + canSubmitRequest: REQUEST_ROLES.some((r) => names.has(r)), + canViewAudit: ADMIN_AUDIT_ROLES.some((r) => names.has(r)), + }); + }, +); + +router.get( + "/executive-meetings/:id", + requireAuth, + requireRead, + 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 m = await fetchMeetingWithAttendees(id); + if (!m) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + res.json(m); + }, +); + +router.post( + "/executive-meetings", + requireAuth, + requireMutate, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const parsed = parseMeetingPatch(body, true); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error, code: "validation" }); + return; + } + const data = parsed.data; + let attendees: AttendeeInput[] = []; + if ("attendees" in body) { + const a = parseAttendees(body.attendees); + if (!a) { + res.status(400).json({ error: "Invalid attendees", code: "validation" }); + return; + } + attendees = a; + } + + const date = data.meetingDate!; + const userId = req.session.userId!; + try { + const inserted = await db.transaction(async (tx) => { + const dailyNumber = data.dailyNumber ?? (await nextDailyNumber(date)); + const [meeting] = await tx + .insert(executiveMeetingsTable) + .values({ + titleAr: data.titleAr!, + titleEn: data.titleEn ?? "", + meetingDate: date, + dailyNumber, + startTime: data.startTime ?? null, + endTime: data.endTime ?? null, + location: data.location ?? null, + meetingUrl: data.meetingUrl ?? null, + platform: data.platform ?? "none", + status: data.status ?? "scheduled", + isHighlighted: data.isHighlighted ?? 0, + notes: data.notes ?? null, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + if (!meeting) throw new Error("insert_failed"); + if (attendees.length > 0) { + await tx.insert(executiveMeetingAttendeesTable).values( + attendees.map((a, idx) => ({ + meetingId: meeting.id, + name: a.name, + title: a.title ?? null, + attendanceType: a.attendanceType, + sortOrder: a.sortOrder ?? idx, + })), + ); + } + await logAudit(tx, { + action: "create", + entityType: "meeting", + entityId: meeting.id, + newValue: { ...meeting, attendees }, + 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 already used for this date", code: "duplicate_number" }); + return; + } + res.status(500).json({ error: "Could not create meeting", code: "create_failed" }); + } + }, +); + +router.patch( + "/executive-meetings/:id", + 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 parsed = parseMeetingPatch(body, false); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error, code: "validation" }); + return; + } + const data = parsed.data; + let attendees: AttendeeInput[] | null = null; + if ("attendees" in body) { + const a = parseAttendees(body.attendees); + if (!a) { + res.status(400).json({ error: "Invalid attendees", code: "validation" }); + return; + } + attendees = a; + } + const userId = req.session.userId!; + + try { + await db.transaction(async (tx) => { + const updateValues: Record = { updatedBy: userId }; + for (const k of [ + "titleAr", + "titleEn", + "meetingDate", + "dailyNumber", + "startTime", + "endTime", + "location", + "meetingUrl", + "platform", + "status", + "isHighlighted", + "notes", + ] as const) { + if (k in data) updateValues[k] = (data as Record)[k]; + } + if (Object.keys(updateValues).length > 1) { + await tx + .update(executiveMeetingsTable) + .set(updateValues as never) + .where(eq(executiveMeetingsTable.id, id)); + } + if (attendees !== null) { + 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: "update", + entityType: "meeting", + entityId: id, + oldValue: existing, + newValue: { ...existing, ...updateValues, attendees: attendees ?? existing.attendees }, + performedBy: userId, + }); + }); + const updated = await fetchMeetingWithAttendees(id); + res.json(updated); + } 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 already used for this date", code: "duplicate_number" }); + return; + } + res.status(500).json({ error: "Could not update meeting", code: "update_failed" }); + } + }, +); + +router.delete( + "/executive-meetings/:id", + 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 userId = req.session.userId!; + await db.transaction(async (tx) => { + await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "delete", + entityType: "meeting", + entityId: id, + oldValue: existing, + performedBy: userId, + }); + }); + res.status(204).end(); + }, +); + +// ===================================================================== +// REQUESTS +// ===================================================================== + +const REQUEST_TYPES = [ + "create", + "edit", + "delete", + "reschedule", + "highlight", + "unhighlight", + "other", +] as const; +const REQUEST_STATUSES = ["new", "approved", "rejected", "withdrawn"] as const; + +router.get( + "/executive-meetings/requests", + requireAuth, + requireRead, + async (req, res): Promise => { + const status = typeof req.query.status === "string" ? req.query.status : ""; + const mine = req.query.mine === "true" || req.query.mine === "1"; + const userId = req.session.userId!; + + const conds = []; + if (status && (REQUEST_STATUSES as readonly string[]).includes(status)) { + conds.push(eq(executiveMeetingRequestsTable.status, status)); + } + if (mine) { + conds.push(eq(executiveMeetingRequestsTable.requestedBy, userId)); + } + const where = conds.length > 0 ? and(...conds) : undefined; + + const rows = await db + .select({ + r: executiveMeetingRequestsTable, + requesterUsername: usersTable.username, + requesterDisplayNameAr: usersTable.displayNameAr, + requesterDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingRequestsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingRequestsTable.requestedBy)) + .where(where as never) + .orderBy(desc(executiveMeetingRequestsTable.createdAt)) + .limit(200); + + res.json({ + requests: rows.map((row) => ({ + ...row.r, + requester: row.requesterUsername + ? { + username: row.requesterUsername, + displayNameAr: row.requesterDisplayNameAr, + displayNameEn: row.requesterDisplayNameEn, + } + : null, + })), + }); + }, +); + +router.post( + "/executive-meetings/requests", + requireAuth, + requireRequest, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const requestType = body.requestType; + if ( + typeof requestType !== "string" || + !(REQUEST_TYPES as readonly string[]).includes(requestType) + ) { + res.status(400).json({ error: "requestType invalid", code: "validation" }); + return; + } + let meetingId: number | null = null; + if (body.meetingId !== undefined && body.meetingId !== null) { + const n = Number(body.meetingId); + if (!Number.isInteger(n) || n <= 0) { + res.status(400).json({ error: "meetingId invalid", code: "validation" }); + return; + } + const [m] = await db + .select({ id: executiveMeetingsTable.id }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, n)); + if (!m) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + meetingId = n; + } + const requestDetails = (body.requestDetails ?? {}) as Record; + const userId = req.session.userId!; + const created = await db.transaction(async (tx) => { + const [row] = await tx + .insert(executiveMeetingRequestsTable) + .values({ + meetingId, + requestedBy: userId, + requestType, + requestDetails: requestDetails as never, + status: "new", + }) + .returning(); + await logAudit(tx, { + action: "submit", + entityType: "request", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + return row; + }); + res.status(201).json(created); + }, +); + +router.patch( + "/executive-meetings/requests/:id", + requireAuth, + 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 userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingRequestsTable) + .where(eq(executiveMeetingRequestsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Request not found", code: "not_found" }); + return; + } + const body = (req.body ?? {}) as Record; + const action = body.action; + const names = await getRoleNamesForUser(userId); + + // Withdraw: requester only, only when 'new' + if (action === "withdraw") { + if (existing.requestedBy !== userId) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + if (existing.status !== "new") { + res.status(409).json({ error: "Cannot withdraw", code: "bad_state" }); + return; + } + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(executiveMeetingRequestsTable) + .set({ status: "withdrawn" }) + .where(eq(executiveMeetingRequestsTable.id, id)) + .returning(); + await logAudit(tx, { + action: "update", + entityType: "request", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: userId, + }); + return row; + }); + res.json(updated); + return; + } + + // Approve / Reject: approver roles only + 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; + } + 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, + }); + return row; + }); + res.json(updated); + return; + } + + res.status(400).json({ error: "Invalid action", code: "validation" }); + }, +); + +// ===================================================================== +// TASKS +// ===================================================================== + +const TASK_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const; + +router.get( + "/executive-meetings/tasks", + requireAuth, + requireRead, + async (req, res): Promise => { + const status = typeof req.query.status === "string" ? req.query.status : ""; + const mine = req.query.mine === "true" || req.query.mine === "1"; + const meetingIdRaw = typeof req.query.meetingId === "string" ? req.query.meetingId : ""; + const userId = req.session.userId!; + const conds = []; + if (status && (TASK_STATUSES as readonly string[]).includes(status)) { + conds.push(eq(executiveMeetingTasksTable.status, status)); + } + if (mine) { + conds.push(eq(executiveMeetingTasksTable.assignedTo, userId)); + } + if (meetingIdRaw) { + const n = Number(meetingIdRaw); + if (Number.isInteger(n) && n > 0) { + conds.push(eq(executiveMeetingTasksTable.meetingId, n)); + } + } + const where = conds.length > 0 ? and(...conds) : undefined; + const rows = await db + .select({ + t: executiveMeetingTasksTable, + assigneeUsername: usersTable.username, + assigneeDisplayNameAr: usersTable.displayNameAr, + assigneeDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingTasksTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingTasksTable.assignedTo)) + .where(where as never) + .orderBy( + asc(executiveMeetingTasksTable.status), + desc(executiveMeetingTasksTable.createdAt), + ) + .limit(200); + res.json({ + tasks: rows.map((row) => ({ + ...row.t, + assignee: row.assigneeUsername + ? { + username: row.assigneeUsername, + displayNameAr: row.assigneeDisplayNameAr, + displayNameEn: row.assigneeDisplayNameEn, + } + : null, + })), + }); + }, +); + +router.post( + "/executive-meetings/tasks", + requireAuth, + requireMutate, + async (req, res): Promise => { + const body = (req.body ?? {}) as Record; + const taskType = validString(body.taskType, 64); + if (!taskType) { + res.status(400).json({ error: "taskType required", code: "validation" }); + return; + } + let meetingId: number | null = null; + if (body.meetingId !== undefined && body.meetingId !== null) { + const n = Number(body.meetingId); + if (Number.isInteger(n) && n > 0) meetingId = n; + } + let assignedTo: number | null = null; + if (body.assignedTo !== undefined && body.assignedTo !== null) { + const n = Number(body.assignedTo); + if (Number.isInteger(n) && n > 0) assignedTo = n; + } + let dueAt: Date | null = null; + if (typeof body.dueAt === "string" && body.dueAt) { + const d = new Date(body.dueAt); + if (!Number.isNaN(d.getTime())) dueAt = d; + } + const notes = optionalString(body.notes, 5000) ?? null; + const userId = req.session.userId!; + const created = await db.transaction(async (tx) => { + const [row] = await tx + .insert(executiveMeetingTasksTable) + .values({ + taskType, + meetingId, + assignedTo, + dueAt, + notes, + status: "pending", + }) + .returning(); + await logAudit(tx, { + action: "create", + entityType: "task", + entityId: row!.id, + newValue: row!, + performedBy: userId, + }); + return row; + }); + res.status(201).json(created); + }, +); + +router.patch( + "/executive-meetings/tasks/:id", + requireAuth, + 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 userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Task not found", code: "not_found" }); + return; + } + const names = await getRoleNamesForUser(userId); + const isMutator = MUTATE_ROLES.some((r) => names.has(r)); + const isAssignee = existing.assignedTo === userId; + if (!isMutator && !isAssignee) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + const body = (req.body ?? {}) as Record; + const update: Record = {}; + if ("status" in body) { + if (!(TASK_STATUSES as readonly string[]).includes(body.status as string)) { + res.status(400).json({ error: "status invalid", code: "validation" }); + return; + } + update.status = body.status; + if (body.status === "completed") update.completedAt = new Date(); + else if (existing.status === "completed") update.completedAt = null; + } + // Only mutators can change assignment / due / type / notes + if (isMutator) { + if ("taskType" in body) { + const tt = validString(body.taskType, 64); + if (!tt) { + res.status(400).json({ error: "taskType invalid", code: "validation" }); + return; + } + update.taskType = tt; + } + if ("assignedTo" in body) { + if (body.assignedTo === null) update.assignedTo = null; + else { + const n = Number(body.assignedTo); + if (Number.isInteger(n) && n > 0) update.assignedTo = n; + } + } + if ("dueAt" in body) { + if (body.dueAt === null || body.dueAt === "") update.dueAt = null; + else if (typeof body.dueAt === "string") { + const d = new Date(body.dueAt); + if (!Number.isNaN(d.getTime())) update.dueAt = d; + } + } + if ("notes" in body) { + update.notes = optionalString(body.notes, 5000) ?? null; + } + if ("meetingId" in body) { + if (body.meetingId === null) update.meetingId = null; + else { + const n = Number(body.meetingId); + if (Number.isInteger(n) && n > 0) update.meetingId = n; + } + } + } + if (Object.keys(update).length === 0) { + res.json(existing); + return; + } + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(executiveMeetingTasksTable) + .set(update as never) + .where(eq(executiveMeetingTasksTable.id, id)) + .returning(); + await logAudit(tx, { + action: "update", + entityType: "task", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: userId, + }); + return row; + }); + res.json(updated); + }, +); + +router.delete( + "/executive-meetings/tasks/:id", + 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 userId = req.session.userId!; + const [existing] = await db + .select() + .from(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Task not found", code: "not_found" }); + return; + } + await db.transaction(async (tx) => { + await tx + .delete(executiveMeetingTasksTable) + .where(eq(executiveMeetingTasksTable.id, id)); + await logAudit(tx, { + action: "delete", + entityType: "task", + entityId: id, + oldValue: existing, + performedBy: userId, + }); + }); + res.status(204).end(); + }, +); + +// ===================================================================== +// AUDIT LOGS +// ===================================================================== + +router.get( + "/executive-meetings/audit-logs", + requireAuth, + requireAdminAudit, + async (req, res): Promise => { + const dateRaw = typeof req.query.date === "string" ? req.query.date.trim() : ""; + const entityType = + typeof req.query.entityType === "string" ? req.query.entityType.trim() : ""; + const limitRaw = Number(req.query.limit); + const limit = + Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 200) : 100; + const conds = []; + if (dateRaw && DATE_RE.test(dateRaw)) { + const start = new Date(`${dateRaw}T00:00:00.000Z`); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + conds.push(gte(executiveMeetingAuditLogsTable.performedAt, start)); + conds.push(lt(executiveMeetingAuditLogsTable.performedAt, end)); + } + if (entityType) { + conds.push(eq(executiveMeetingAuditLogsTable.entityType, entityType)); + } + const where = conds.length > 0 ? and(...conds) : undefined; + const rows = await db + .select({ + l: executiveMeetingAuditLogsTable, + actorUsername: usersTable.username, + actorDisplayNameAr: usersTable.displayNameAr, + actorDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingAuditLogsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingAuditLogsTable.performedBy)) + .where(where as never) + .orderBy(desc(executiveMeetingAuditLogsTable.performedAt)) + .limit(limit); + res.json({ + entries: rows.map((row) => ({ + ...row.l, + actor: row.actorUsername + ? { + username: row.actorUsername, + displayNameAr: row.actorDisplayNameAr, + displayNameEn: row.actorDisplayNameEn, + } + : null, + })), + }); + }, +); + +// ===================================================================== +// NOTIFICATIONS (placeholder list) +// ===================================================================== + +router.get( + "/executive-meetings/notifications", + requireAuth, + requireAdminAudit, + async (_req, res): Promise => { + const rows = await db + .select({ + n: executiveMeetingNotificationsTable, + userUsername: usersTable.username, + userDisplayNameAr: usersTable.displayNameAr, + userDisplayNameEn: usersTable.displayNameEn, + }) + .from(executiveMeetingNotificationsTable) + .leftJoin(usersTable, eq(usersTable.id, executiveMeetingNotificationsTable.userId)) + .orderBy(desc(executiveMeetingNotificationsTable.createdAt)) + .limit(200); + res.json({ + notifications: rows.map((row) => ({ + ...row.n, + user: row.userUsername + ? { + username: row.userUsername, + displayNameAr: row.userDisplayNameAr, + displayNameEn: row.userDisplayNameEn, + } + : null, + })), + }); + }, +); + +// ===================================================================== +// FONT SETTINGS +// ===================================================================== + +const FONT_FAMILIES = [ + "system", + "Cairo", + "Tajawal", + "Noto Naskh Arabic", + "Amiri", + "Inter", + "monospace", +] as const; +const FONT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; +const FONT_ALIGNMENTS = ["start", "center", "end"] as const; + +router.get( + "/executive-meetings/font-settings", + requireAuth, + requireRead, + async (req, res): Promise => { + const userId = req.session.userId!; + const rows = await db + .select() + .from(executiveMeetingFontSettingsTable) + .where( + or( + eq(executiveMeetingFontSettingsTable.scope, "global"), + and( + eq(executiveMeetingFontSettingsTable.scope, "user"), + eq(executiveMeetingFontSettingsTable.userId, userId), + ), + ), + ); + const global = rows.find((r) => r.scope === "global") ?? null; + const user = rows.find((r) => r.scope === "user" && r.userId === userId) ?? null; + res.json({ global, user }); + }, +); + +router.patch( + "/executive-meetings/font-settings", + requireAuth, + requireRead, + async (req, res): Promise => { + const userId = req.session.userId!; + const body = (req.body ?? {}) as Record; + const scope = body.scope === "global" ? "global" : "user"; + if (scope === "global") { + const names = await getRoleNamesForUser(userId); + const ok = ["admin", "executive_office_manager"].some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + } + const fontFamily = + typeof body.fontFamily === "string" && + (FONT_FAMILIES as readonly string[]).includes(body.fontFamily) + ? body.fontFamily + : "system"; + const fontSizeRaw = Number(body.fontSize); + const fontSize = + Number.isInteger(fontSizeRaw) && fontSizeRaw >= 10 && fontSizeRaw <= 32 + ? fontSizeRaw + : 14; + const fontWeight = + typeof body.fontWeight === "string" && + (FONT_WEIGHTS as readonly string[]).includes(body.fontWeight) + ? body.fontWeight + : "regular"; + const alignment = + typeof body.alignment === "string" && + (FONT_ALIGNMENTS as readonly string[]).includes(body.alignment) + ? body.alignment + : "start"; + + const targetUserId = scope === "global" ? null : userId; + const result = await db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(executiveMeetingFontSettingsTable) + .where( + and( + eq(executiveMeetingFontSettingsTable.scope, scope), + targetUserId === null + ? isNull(executiveMeetingFontSettingsTable.userId) + : eq(executiveMeetingFontSettingsTable.userId, targetUserId), + ), + ); + let row; + if (existing) { + const [updated] = await tx + .update(executiveMeetingFontSettingsTable) + .set({ fontFamily, fontSize, fontWeight, alignment }) + .where(eq(executiveMeetingFontSettingsTable.id, existing.id)) + .returning(); + row = updated; + } else { + const [inserted] = await tx + .insert(executiveMeetingFontSettingsTable) + .values({ + scope, + userId: targetUserId, + fontFamily, + fontSize, + fontWeight, + alignment, + }) + .returning(); + row = inserted; + } + await logAudit(tx, { + action: existing ? "update" : "create", + entityType: "font_settings", + entityId: row?.id ?? null, + oldValue: existing ?? null, + newValue: row ?? null, + performedBy: userId, + }); + return row; + }); + res.json(result); + }, +); + export default router; diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 4ebcab268f4dfaf8a069049d06e6ca3de5a8514b..326f2774f3edca446b0994e47f30cf6c64849b1c 100644 GIT binary patch literal 25930 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 literal 24619 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%0DJ({pcvz=UhNnKM8_Mf%>6mDq{!WM`LCw^4e2G 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 diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 208b3cb9..8e8b5dc6 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -622,7 +622,7 @@ "externalAttendance": "حضور خارجي", "placeholderBody": "هذا القسم قيد التطوير في المراحل القادمة. ستتوفر إدارته الكاملة قريباً.", "col": { - "number": "م", + "number": "#", "meeting": "الاجتماع", "attendees": "الحضور", "time": "الوقت" @@ -637,6 +637,231 @@ "audit": "سجل التعديلات", "pdf": "تصدير PDF", "fontSettings": "إعدادات الخط" + }, + "common": { + "save": "حفظ", + "cancel": "إلغاء", + "delete": "حذف", + "edit": "تعديل", + "add": "إضافة", + "submit": "إرسال", + "close": "إغلاق", + "loading": "جارٍ التحميل...", + "saved": "تم الحفظ", + "saveFailed": "تعذر الحفظ", + "actions": "إجراءات", + "noPermission": "ليست لديك صلاحية لتنفيذ هذا الإجراء", + "yes": "نعم", + "no": "لا", + "all": "الكل", + "filter": "تصفية" + }, + "manage": { + "heading": "إدارة الاجتماعات", + "addMeeting": "إضافة اجتماع", + "editMeeting": "تعديل الاجتماع", + "deleteMeeting": "حذف الاجتماع", + "deleteConfirm": "حذف هذا الاجتماع نهائياً؟", + "noMeetings": "لا توجد اجتماعات لهذا اليوم. اضغط \"إضافة اجتماع\" لإنشاء واحد.", + "field": { + "titleAr": "العنوان (عربي)", + "titleEn": "العنوان (إنجليزي)", + "meetingDate": "التاريخ", + "dailyNumber": "الرقم اليومي", + "dailyNumberHint": "اتركه فارغاً ليتم الترقيم تلقائياً", + "startTime": "وقت البدء", + "endTime": "وقت الانتهاء", + "location": "المكان", + "meetingUrl": "رابط الاجتماع", + "platform": "المنصة", + "status": "الحالة", + "isHighlighted": "تمييز (لون أحمر)", + "notes": "ملاحظات" + }, + "attendees": { + "label": "قائمة الحضور", + "name": "الاسم", + "type": "نوع الحضور", + "add": "إضافة حاضر", + "remove": "إزالة" + }, + "platform": { + "none": "بدون", + "webex": "Webex", + "teams": "Teams", + "zoom": "Zoom", + "other": "أخرى" + }, + "attendanceType": { + "internal": "داخلي", + "virtual": "افتراضي", + "external": "خارجي" + }, + "status": { + "scheduled": "مجدول", + "cancelled": "ملغى", + "completed": "منتهٍ", + "postponed": "مؤجل" + }, + "errors": { + "titleRequired": "العنوان بالعربية مطلوب", + "dateRequired": "التاريخ مطلوب", + "duplicateNumber": "هذا الرقم اليومي مستخدم بالفعل في هذا التاريخ", + "saveFailed": "تعذر حفظ الاجتماع" + } + }, + "requests": { + "heading": "طلبات التعديل", + "newRequest": "طلب جديد", + "myRequests": "طلباتي", + "allRequests": "جميع الطلبات", + "empty": "لا توجد طلبات.", + "filterStatus": "تصفية حسب الحالة", + "submitFor": "اقتراح تعديل على:", + "noTargetMeeting": "اقتراح اجتماع جديد (بدون ربط)", + "details": "التفاصيل", + "detailsPlaceholder": "اشرح ما تقترح بالتفصيل...", + "submitted": "تم إرسال الطلب", + "submitFailed": "تعذر إرسال الطلب", + "requestedBy": "مقدم الطلب", + "requestedAt": "وقت الطلب", + "type": { + "create": "إنشاء اجتماع", + "edit": "تعديل بيانات", + "delete": "حذف", + "reschedule": "إعادة جدولة", + "highlight": "تمييز", + "unhighlight": "إزالة التمييز", + "other": "آخر" + }, + "status": { + "new": "جديد", + "approved": "تمت الموافقة", + "rejected": "مرفوض", + "withdrawn": "مسحوب" + } + }, + "approvals": { + "heading": "موافقات مدير المكتب", + "empty": "لا توجد طلبات بانتظار المراجعة.", + "approve": "اعتماد", + "reject": "رفض", + "reviewNotes": "ملاحظات المراجعة", + "reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)", + "approved": "تم الاعتماد", + "rejected": "تم الرفض", + "actionFailed": "تعذر تنفيذ المراجعة" + }, + "tasks": { + "heading": "مهام فريق التنسيق", + "addTask": "إضافة مهمة", + "empty": "لا توجد مهام.", + "field": { + "taskType": "نوع المهمة", + "assignee": "المُكلَّف", + "dueDate": "تاريخ الاستحقاق", + "notes": "ملاحظات", + "meeting": "الاجتماع", + "status": "الحالة" + }, + "status": { + "pending": "بالانتظار", + "in_progress": "قيد التنفيذ", + "completed": "منجزة", + "cancelled": "ملغاة" + }, + "markCompleted": "إنهاء المهمة", + "markInProgress": "بدء التنفيذ", + "deleteConfirm": "حذف هذه المهمة؟", + "saved": "تم حفظ المهمة", + "deleted": "تم حذف المهمة" + }, + "audit": { + "heading": "سجل التعديلات", + "empty": "لا توجد قيود في السجل.", + "filterDate": "تصفية بالتاريخ", + "filterEntity": "النوع", + "all": "الكل", + "col": { + "when": "الوقت", + "actor": "المنفّذ", + "action": "الإجراء", + "entity": "النوع", + "entityId": "المعرف", + "diff": "التفاصيل" + }, + "entity": { + "meeting": "اجتماع", + "attendee": "حاضر", + "request": "طلب", + "task": "مهمة", + "font_settings": "إعدادات الخط" + }, + "action": { + "create": "إنشاء", + "update": "تعديل", + "delete": "حذف", + "approve": "اعتماد", + "reject": "رفض", + "submit": "إرسال" + } + }, + "pdf": { + "heading": "تصدير / طباعة", + "intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.", + "print": "طباعة الجدول", + "openSchedule": "عرض الجدول", + "selectDate": "تاريخ الجدول" + }, + "fontSettingsPage": { + "heading": "إعدادات الخط", + "intro": "تطبق هذه الإعدادات على جدول الاجتماعات. يمكنك حفظ تفضيلك الشخصي، ويمكن للمسؤول حفظ الإعدادات العامة لجميع المستخدمين.", + "scope": "النطاق", + "scopeUser": "تفضيلي الشخصي", + "scopeGlobal": "عام (جميع المستخدمين)", + "fontFamily": "نوع الخط", + "fontSize": "حجم الخط", + "fontWeight": "السماكة", + "alignment": "المحاذاة", + "weight": { + "regular": "عادي", + "medium": "متوسط", + "semibold": "نصف عريض", + "bold": "عريض" + }, + "align": { + "start": "بداية", + "center": "وسط", + "end": "نهاية" + }, + "preview": "معاينة", + "previewText": "نص تجريبي ١٢٣ Sample 123", + "save": "حفظ الإعدادات", + "saved": "تم الحفظ", + "reset": "إعادة الافتراضي" + }, + "notificationsPage": { + "heading": "التنبيهات", + "intro": "هذه قائمة التنبيهات المجدولة المرتبطة باجتماعات المكتب. لا يتم الإرسال الفعلي حالياً (وضع تجريبي).", + "empty": "لا توجد تنبيهات مجدولة.", + "col": { + "meeting": "الاجتماع", + "user": "المستخدم", + "type": "النوع", + "scheduledAt": "الموعد", + "status": "الحالة" + }, + "type": { + "reminder": "تذكير", + "change": "تنبيه تعديل", + "cancel": "تنبيه إلغاء" + }, + "status": { + "pending": "بالانتظار", + "sent": "أُرسل", + "skipped": "متخطى", + "failed": "فشل" + } } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 84262b38..2e351eb0 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -634,6 +634,231 @@ "audit": "Audit Log", "pdf": "PDF Export", "fontSettings": "Font Settings" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "submit": "Submit", + "close": "Close", + "loading": "Loading...", + "saved": "Saved", + "saveFailed": "Save failed", + "actions": "Actions", + "noPermission": "You do not have permission for this action", + "yes": "Yes", + "no": "No", + "all": "All", + "filter": "Filter" + }, + "manage": { + "heading": "Manage Meetings", + "addMeeting": "Add meeting", + "editMeeting": "Edit meeting", + "deleteMeeting": "Delete meeting", + "deleteConfirm": "Permanently delete this meeting?", + "noMeetings": "No meetings on this day. Click \"Add meeting\" to create one.", + "field": { + "titleAr": "Title (Arabic)", + "titleEn": "Title (English)", + "meetingDate": "Date", + "dailyNumber": "Daily number", + "dailyNumberHint": "Leave blank for auto-numbering", + "startTime": "Start time", + "endTime": "End time", + "location": "Location", + "meetingUrl": "Meeting URL", + "platform": "Platform", + "status": "Status", + "isHighlighted": "Highlight (red)", + "notes": "Notes" + }, + "attendees": { + "label": "Attendees", + "name": "Name", + "type": "Attendance type", + "add": "Add attendee", + "remove": "Remove" + }, + "platform": { + "none": "None", + "webex": "Webex", + "teams": "Teams", + "zoom": "Zoom", + "other": "Other" + }, + "attendanceType": { + "internal": "Internal", + "virtual": "Virtual", + "external": "External" + }, + "status": { + "scheduled": "Scheduled", + "cancelled": "Cancelled", + "completed": "Completed", + "postponed": "Postponed" + }, + "errors": { + "titleRequired": "Arabic title is required", + "dateRequired": "Date is required", + "duplicateNumber": "That daily number already exists for this date", + "saveFailed": "Could not save the meeting" + } + }, + "requests": { + "heading": "Change Requests", + "newRequest": "New request", + "myRequests": "My requests", + "allRequests": "All requests", + "empty": "No requests yet.", + "filterStatus": "Filter by status", + "submitFor": "Suggest a change to:", + "noTargetMeeting": "Propose a brand-new meeting (no link)", + "details": "Details", + "detailsPlaceholder": "Explain your suggestion in detail...", + "submitted": "Request submitted", + "submitFailed": "Could not submit the request", + "requestedBy": "Submitted by", + "requestedAt": "Submitted at", + "type": { + "create": "Create meeting", + "edit": "Edit details", + "delete": "Delete", + "reschedule": "Reschedule", + "highlight": "Highlight", + "unhighlight": "Remove highlight", + "other": "Other" + }, + "status": { + "new": "New", + "approved": "Approved", + "rejected": "Rejected", + "withdrawn": "Withdrawn" + } + }, + "approvals": { + "heading": "Office Manager Approvals", + "empty": "No requests pending review.", + "approve": "Approve", + "reject": "Reject", + "reviewNotes": "Review notes", + "reviewNotesPlaceholder": "Optional notes for the requester", + "approved": "Approved", + "rejected": "Rejected", + "actionFailed": "Could not complete the review" + }, + "tasks": { + "heading": "Coordination Tasks", + "addTask": "Add task", + "empty": "No tasks yet.", + "field": { + "taskType": "Task type", + "assignee": "Assignee", + "dueDate": "Due date", + "notes": "Notes", + "meeting": "Meeting", + "status": "Status" + }, + "status": { + "pending": "Pending", + "in_progress": "In progress", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "markCompleted": "Mark completed", + "markInProgress": "Start", + "deleteConfirm": "Delete this task?", + "saved": "Task saved", + "deleted": "Task deleted" + }, + "audit": { + "heading": "Audit Log", + "empty": "No audit entries.", + "filterDate": "Filter by date", + "filterEntity": "Entity", + "all": "All", + "col": { + "when": "When", + "actor": "Actor", + "action": "Action", + "entity": "Entity", + "entityId": "ID", + "diff": "Details" + }, + "entity": { + "meeting": "Meeting", + "attendee": "Attendee", + "request": "Request", + "task": "Task", + "font_settings": "Font settings" + }, + "action": { + "create": "Create", + "update": "Update", + "delete": "Delete", + "approve": "Approve", + "reject": "Reject", + "submit": "Submit" + } + }, + "pdf": { + "heading": "Print / Export", + "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" + }, + "fontSettingsPage": { + "heading": "Font Settings", + "intro": "These settings apply to the meetings schedule. You can save your personal preference; admins can also save the global default.", + "scope": "Scope", + "scopeUser": "My preference", + "scopeGlobal": "Global (everyone)", + "fontFamily": "Font family", + "fontSize": "Font size", + "fontWeight": "Weight", + "alignment": "Alignment", + "weight": { + "regular": "Regular", + "medium": "Medium", + "semibold": "Semibold", + "bold": "Bold" + }, + "align": { + "start": "Start", + "center": "Center", + "end": "End" + }, + "preview": "Preview", + "previewText": "Sample text 123 نص تجريبي ١٢٣", + "save": "Save settings", + "saved": "Saved", + "reset": "Reset to default" + }, + "notificationsPage": { + "heading": "Notifications", + "intro": "Scheduled reminders and change-notice records for executive meetings. No actual delivery is wired up yet (placeholder).", + "empty": "No scheduled notifications.", + "col": { + "meeting": "Meeting", + "user": "User", + "type": "Type", + "scheduledAt": "Scheduled", + "status": "Status" + }, + "type": { + "reminder": "Reminder", + "change": "Change notice", + "cancel": "Cancellation notice" + }, + "status": { + "pending": "Pending", + "sent": "Sent", + "skipped": "Skipped", + "failed": "Failed" + } } } } diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 1d4c0e73..f7ba7ce4 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, ArrowRight, @@ -17,12 +17,37 @@ import { ScrollText, FileText, Settings as SettingsIcon, + Plus, + Pencil, + Trash2, + Printer, + Check, + X, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; type Attendee = { - id: number; - meetingId: number; + id?: number; + meetingId?: number; name: string; title: string | null; attendanceType: "internal" | "virtual" | "external"; @@ -46,9 +71,98 @@ type Meeting = { attendees: Attendee[]; }; -type DayResponse = { - date: string; - meetings: Meeting[]; +type DayResponse = { date: string; meetings: Meeting[] }; + +type MeRoles = { + userId: number; + roles: string[]; + canRead: boolean; + canMutate: boolean; + canApprove: boolean; + canSubmitRequest: boolean; + canViewAudit: boolean; +}; + +type RequestRow = { + id: number; + meetingId: number | null; + requestedBy: number | null; + requestType: string; + requestDetails: unknown; + status: "new" | "approved" | "rejected" | "withdrawn"; + reviewedBy: number | null; + reviewDecision: string | null; + reviewNotes: string | null; + createdAt: string; + updatedAt: string; + requester: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type TaskRow = { + id: number; + requestId: number | null; + meetingId: number | null; + assignedTo: number | null; + taskType: string; + status: "pending" | "in_progress" | "completed" | "cancelled"; + dueAt: string | null; + completedAt: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + assignee: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type AuditEntry = { + id: number; + action: string; + entityType: string; + entityId: number | null; + oldValue: unknown; + newValue: unknown; + performedBy: number | null; + performedAt: string; + actor: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type NotificationRow = { + id: number; + meetingId: number | null; + userId: number | null; + notificationType: string; + scheduledAt: string | null; + sentAt: string | null; + status: string; + createdAt: string; + user: { + username: string; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; +}; + +type FontPrefs = { + fontFamily: string; + fontSize: number; + fontWeight: "regular" | "medium" | "semibold" | "bold"; + alignment: "start" | "center" | "end"; +}; + +type FontSettingsResponse = { + global: ({ scope: "global"; userId: null } & FontPrefs) | null; + user: ({ scope: "user"; userId: number } & FontPrefs) | null; }; const SECTIONS = [ @@ -65,6 +179,13 @@ const SECTIONS = [ type SectionKey = (typeof SECTIONS)[number]["key"]; +const DEFAULT_FONT: FontPrefs = { + fontFamily: "system", + fontSize: 14, + fontWeight: "regular", + alignment: "start", +}; + function formatTime(t: string | null): string { if (!t) return ""; return t.slice(0, 5); @@ -74,6 +195,46 @@ function todayIso(): string { return new Date().toISOString().slice(0, 10); } +function fontWeightToCss(w: FontPrefs["fontWeight"]): number { + return { regular: 400, medium: 500, semibold: 600, bold: 700 }[w]; +} + +function fontFamilyToCss(name: string): string { + if (name === "system") return ""; + if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`; + return `${name}, system-ui, sans-serif`; +} + +function buildFontStyle(prefs: FontPrefs): CSSProperties { + return { + fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined, + fontSize: prefs.fontSize, + fontWeight: fontWeightToCss(prefs.fontWeight), + textAlign: prefs.alignment, + }; +} + +async function apiJson( + url: string, + init?: RequestInit & { body?: unknown }, +): Promise { + const opts: RequestInit = { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...(init ?? {}), + }; + if (init?.body !== undefined && typeof init.body !== "string") { + opts.body = JSON.stringify(init.body); + } + const res = await fetch(url, opts); + if (res.status === 204) return undefined as T; + const data = (await res.json().catch(() => ({}))) as T & { error?: string }; + if (!res.ok) { + throw new Error((data as { error?: string }).error || `HTTP ${res.status}`); + } + return data; +} + export default function ExecutiveMeetingsPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -94,12 +255,37 @@ export default function ExecutiveMeetingsPage() { }, }); + const { data: me } = useQuery({ + queryKey: ["/api/executive-meetings/me"], + queryFn: () => apiJson("/api/executive-meetings/me"), + }); + + const { data: fontResp } = useQuery({ + queryKey: ["/api/executive-meetings/font-settings"], + queryFn: () => apiJson("/api/executive-meetings/font-settings"), + }); + + const effectiveFont: FontPrefs = useMemo(() => { + const u = fontResp?.user; + const g = fontResp?.global; + const src = u ?? g; + if (!src) return DEFAULT_FONT; + return { + fontFamily: src.fontFamily, + fontSize: src.fontSize, + fontWeight: src.fontWeight, + alignment: src.alignment, + }; + }, [fontResp]); + const meetings = data?.meetings ?? []; return ( -
- {/* Top header */} -
+
+
@@ -153,7 +345,6 @@ export default function ExecutiveMeetingsPage() {
- {/* Section tabs */}