Task #108 — Executive Meetings Phase 2 (RBAC + audit + Zod + i18n)
Schedule UI / shell:
- Centered cells, locked RTL column order # | الاجتماع | الحضور | الوقت,
attendees column widest, tighter padding, navy/white/gray + red highlight only.
- Header label "م" → "#" (ar.json).
Schema (lib/db/src/schema/executive-meetings.ts):
- executive_meeting_requests.meetingId is now nullable so create-suggestion
requests work without an existing meeting. db push applied.
Backend (artifacts/api-server/src/routes/executive-meetings.ts) — full rewrite:
- All routes (including /me) gated by requireExecutiveAccess. Fine-grained
middleware: requireMutate / requireApprove / requireRequest /
requireAdminAudit on the appropriate routes.
- Zod schemas validate every mutating body via parseBody().
- All mutations write to executiveMeetingAuditLogsTable via logAudit().
- New nested route POST /:id/requests in addition to top-level requests POST.
- Status-driven approvals: PATCH /requests/:id accepts
{status: 'approved'|'rejected'|'needs_edit', reviewNotes?} or
{action:'withdraw'} for the requester.
- PUT /font-settings (canonical) plus PATCH alias — both wired directly
to the same shared upsertFontSettingsHandler (no method-rewrite hack).
- GET /tasks supports date and assigneeId filters.
- Removed all `as never` casts via $inferInsert/Partial typing.
Auth middleware (artifacts/api-server/src/middlewares/auth.ts):
- Added requireExecutiveAccess gating EXECUTIVE_READ_ROLES
(admin + executive_*).
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Notifications visibility now uses canRead (was canViewAudit).
- Approvals: review() sends {status, reviewNotes} and a new
"Send back for edits" (needs_edit) button is rendered alongside
Approve/Reject.
- PdfSection: primary "Print + Archive" button (testid em-print) does
archive then print in one click; em-print-only and em-archive remain.
- AuditSection: 6th column renders the new in-file AuditDiffSummary
component (compact "field: old → new" diff with truncation).
- Print page (executive-meetings-print.tsx): kept its inline bilingual T
table — it loads in a standalone print window before the i18n provider
mounts, so an inline dictionary is the right pattern. No hardcoded UI
text remains in the main page.
i18n (en.json + ar.json):
- New keys executiveMeetings.approvals.{needsEdit, needsEditDone},
.audit.col.changes, .audit.{created, removed, moreChanges},
.audit.entity.pdf_archive,
.audit.action.{approved, rejected, needs_edit, withdraw, apply,
duplicate, replace_attendees, done},
.pdf.{print = "Print + archive"/"طباعة وأرشفة", printOnly,
archivedAndPrinted}.
Validation:
- runTest e2e passed: login, schedule loads with "#" header, Manage tab
creates "اجتماع تجريبي E2E", Approvals/PDF/Audit tabs render with
expected testids and the new Changes column.
- curl smoke: login 200, /me 200, dates 200, create meeting 201,
nested POST /:id/requests 201, PATCH /requests/:id status approval 200,
audit-logs 200, DELETE meeting 204, PUT and PATCH /font-settings both
200 with persisted data.
- Architect re-reviewed twice: VERDICT: APPROVED on the second pass
after fixing /me gating and the PATCH font-settings dispatch.
Out of scope (handled in follow-ups #110/#111/#112): real notifications
delivery, server-side PDF rendering, mobile polish.
Drift: none material. Followed plan T1–T8.
This commit is contained in:
@@ -24,7 +24,8 @@
|
|||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"pino": "^9",
|
"pino": "^9",
|
||||||
"pino-http": "^10",
|
"pino-http": "^10",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
|||||||
@@ -190,3 +190,52 @@ export async function getUserRoles(userId: number): Promise<string[]> {
|
|||||||
return Array.from(new Set(roles.map((r) => r.roleName)));
|
return Array.from(new Set(roles.map((r) => r.roleName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXECUTIVE_READ_ROLES: ReadonlyArray<string> = [
|
||||||
|
"admin",
|
||||||
|
"executive_ceo",
|
||||||
|
"executive_office_manager",
|
||||||
|
"executive_coord_lead",
|
||||||
|
"executive_coordinator",
|
||||||
|
"executive_viewer",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate any read access to the Executive Meetings module. Allows admin and
|
||||||
|
* any of the executive_* roles. Mutate / approve / audit-view sub-roles
|
||||||
|
* are layered on top via local helpers in the routes file.
|
||||||
|
*/
|
||||||
|
export async function requireExecutiveAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!req.session.userId) {
|
||||||
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [user] = await db
|
||||||
|
.select({ isActive: usersTable.isActive })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, req.session.userId));
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
res.status(401).json({ error: "Unauthorized", code: "unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = await getEffectiveRoleIds(req.session.userId);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({ name: rolesTable.name })
|
||||||
|
.from(rolesTable)
|
||||||
|
.where(inArray(rolesTable.id, ids));
|
||||||
|
const names = new Set(rows.map((r) => r.name));
|
||||||
|
const ok = EXECUTIVE_READ_ROLES.some((r) => names.has(r));
|
||||||
|
if (!ok) {
|
||||||
|
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -748,6 +748,8 @@
|
|||||||
"empty": "لا توجد طلبات بانتظار المراجعة.",
|
"empty": "لا توجد طلبات بانتظار المراجعة.",
|
||||||
"approve": "اعتماد",
|
"approve": "اعتماد",
|
||||||
"reject": "رفض",
|
"reject": "رفض",
|
||||||
|
"needsEdit": "إعادة للتعديل",
|
||||||
|
"needsEditDone": "تم إرجاع الطلب لمقدّمه",
|
||||||
"reviewNotes": "ملاحظات المراجعة",
|
"reviewNotes": "ملاحظات المراجعة",
|
||||||
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
|
"reviewNotesPlaceholder": "اكتب ملاحظاتك هنا (اختياري)",
|
||||||
"approved": "تم الاعتماد",
|
"approved": "تم الاعتماد",
|
||||||
@@ -790,32 +792,47 @@
|
|||||||
"action": "الإجراء",
|
"action": "الإجراء",
|
||||||
"entity": "النوع",
|
"entity": "النوع",
|
||||||
"entityId": "المعرف",
|
"entityId": "المعرف",
|
||||||
"diff": "التفاصيل"
|
"diff": "التفاصيل",
|
||||||
|
"changes": "التغييرات"
|
||||||
},
|
},
|
||||||
|
"created": "تم إنشاء",
|
||||||
|
"removed": "تم حذف",
|
||||||
|
"moreChanges": "تغييرات أخرى",
|
||||||
"entity": {
|
"entity": {
|
||||||
"meeting": "اجتماع",
|
"meeting": "اجتماع",
|
||||||
"attendee": "حاضر",
|
"attendee": "حاضر",
|
||||||
"request": "طلب",
|
"request": "طلب",
|
||||||
"task": "مهمة",
|
"task": "مهمة",
|
||||||
"font_settings": "إعدادات الخط"
|
"font_settings": "إعدادات الخط",
|
||||||
|
"pdf_archive": "أرشيف PDF"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"create": "إنشاء",
|
"create": "إنشاء",
|
||||||
"update": "تعديل",
|
"update": "تعديل",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"approve": "اعتماد",
|
"approve": "اعتماد",
|
||||||
|
"approved": "تم الاعتماد",
|
||||||
"reject": "رفض",
|
"reject": "رفض",
|
||||||
"submit": "إرسال"
|
"rejected": "تم الرفض",
|
||||||
|
"needs_edit": "إعادة للتعديل",
|
||||||
|
"withdraw": "سحب",
|
||||||
|
"submit": "إرسال",
|
||||||
|
"apply": "تطبيق",
|
||||||
|
"duplicate": "تكرار",
|
||||||
|
"replace_attendees": "تحديث الحضور",
|
||||||
|
"done": "تم"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"heading": "تصدير / طباعة",
|
"heading": "تصدير / طباعة",
|
||||||
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
|
"intro": "تستخدم هذه الواجهة طابعة المتصفح لإنتاج نسخة PDF من جدول اليوم. اختر التاريخ ثم اضغط طباعة.",
|
||||||
"print": "طباعة الجدول",
|
"print": "طباعة وأرشفة",
|
||||||
|
"printOnly": "طباعة فقط",
|
||||||
"openSchedule": "عرض الجدول",
|
"openSchedule": "عرض الجدول",
|
||||||
"selectDate": "تاريخ الجدول",
|
"selectDate": "تاريخ الجدول",
|
||||||
"archiveSnapshot": "أرشفة نسخة",
|
"archiveSnapshot": "أرشفة نسخة",
|
||||||
"archived": "تمت أرشفة النسخة",
|
"archived": "تمت أرشفة النسخة",
|
||||||
|
"archivedAndPrinted": "تمت أرشفة النسخة وفتح نافذة الطباعة",
|
||||||
"archivesHeading": "النسخ المؤرشفة",
|
"archivesHeading": "النسخ المؤرشفة",
|
||||||
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
|
"noArchives": "لا توجد نسخ مؤرشفة لهذا التاريخ بعد."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -745,6 +745,8 @@
|
|||||||
"empty": "No requests pending review.",
|
"empty": "No requests pending review.",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
|
"needsEdit": "Send back for edits",
|
||||||
|
"needsEditDone": "Sent back to requester",
|
||||||
"reviewNotes": "Review notes",
|
"reviewNotes": "Review notes",
|
||||||
"reviewNotesPlaceholder": "Optional notes for the requester",
|
"reviewNotesPlaceholder": "Optional notes for the requester",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
@@ -787,32 +789,47 @@
|
|||||||
"action": "Action",
|
"action": "Action",
|
||||||
"entity": "Entity",
|
"entity": "Entity",
|
||||||
"entityId": "ID",
|
"entityId": "ID",
|
||||||
"diff": "Details"
|
"diff": "Details",
|
||||||
|
"changes": "Changes"
|
||||||
},
|
},
|
||||||
|
"created": "Created",
|
||||||
|
"removed": "Removed",
|
||||||
|
"moreChanges": "more changes",
|
||||||
"entity": {
|
"entity": {
|
||||||
"meeting": "Meeting",
|
"meeting": "Meeting",
|
||||||
"attendee": "Attendee",
|
"attendee": "Attendee",
|
||||||
"request": "Request",
|
"request": "Request",
|
||||||
"task": "Task",
|
"task": "Task",
|
||||||
"font_settings": "Font settings"
|
"font_settings": "Font settings",
|
||||||
|
"pdf_archive": "PDF archive"
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
|
"approved": "Approved",
|
||||||
"reject": "Reject",
|
"reject": "Reject",
|
||||||
"submit": "Submit"
|
"rejected": "Rejected",
|
||||||
|
"needs_edit": "Needs edit",
|
||||||
|
"withdraw": "Withdrawn",
|
||||||
|
"submit": "Submit",
|
||||||
|
"apply": "Apply",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"replace_attendees": "Update attendees",
|
||||||
|
"done": "Done"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"heading": "Print / Export",
|
"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.",
|
"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",
|
"print": "Print + archive",
|
||||||
|
"printOnly": "Print only",
|
||||||
"openSchedule": "Open schedule",
|
"openSchedule": "Open schedule",
|
||||||
"selectDate": "Schedule date",
|
"selectDate": "Schedule date",
|
||||||
"archiveSnapshot": "Archive snapshot",
|
"archiveSnapshot": "Archive snapshot",
|
||||||
"archived": "Snapshot archived",
|
"archived": "Snapshot archived",
|
||||||
|
"archivedAndPrinted": "Snapshot archived — print dialog opened",
|
||||||
"archivesHeading": "Archived snapshots",
|
"archivesHeading": "Archived snapshots",
|
||||||
"noArchives": "No snapshots archived for this date yet."
|
"noArchives": "No snapshots archived for this date yet."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ export default function ExecutiveMeetingsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{section === "notifications" && me && (
|
{section === "notifications" && me && (
|
||||||
<NotificationsSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
|
<NotificationsSection canView={me.canRead} isRtl={isRtl} t={t} />
|
||||||
)}
|
)}
|
||||||
{section === "audit" && me && (
|
{section === "audit" && me && (
|
||||||
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
|
<AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
|
||||||
@@ -1595,17 +1595,22 @@ function ApprovalsSection({
|
|||||||
});
|
});
|
||||||
const requests = data?.requests ?? [];
|
const requests = data?.requests ?? [];
|
||||||
|
|
||||||
async function review(id: number, action: "approve" | "reject") {
|
async function review(
|
||||||
|
id: number,
|
||||||
|
status: "approved" | "rejected" | "needs_edit",
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await apiJson(`/api/executive-meetings/requests/${id}`, {
|
await apiJson(`/api/executive-meetings/requests/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: { action, reviewNotes: notes[id] ?? "" },
|
body: { status, reviewNotes: notes[id] ?? "" },
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title:
|
title:
|
||||||
action === "approve"
|
status === "approved"
|
||||||
? t("executiveMeetings.approvals.approved")
|
? t("executiveMeetings.approvals.approved")
|
||||||
: t("executiveMeetings.approvals.rejected"),
|
: status === "rejected"
|
||||||
|
? t("executiveMeetings.approvals.rejected")
|
||||||
|
: t("executiveMeetings.approvals.needsEditDone"),
|
||||||
});
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
|
qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
|
||||||
@@ -1675,10 +1680,18 @@ function ApprovalsSection({
|
|||||||
value={notes[r.id] ?? ""}
|
value={notes[r.id] ?? ""}
|
||||||
onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
|
onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => review(r.id, "reject")}
|
onClick={() => review(r.id, "needs_edit")}
|
||||||
|
className="gap-1"
|
||||||
|
data-testid={`em-needs-edit-${r.id}`}
|
||||||
|
>
|
||||||
|
{t("executiveMeetings.approvals.needsEdit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => review(r.id, "rejected")}
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
data-testid={`em-reject-${r.id}`}
|
data-testid={`em-reject-${r.id}`}
|
||||||
>
|
>
|
||||||
@@ -1686,7 +1699,7 @@ function ApprovalsSection({
|
|||||||
{t("executiveMeetings.approvals.reject")}
|
{t("executiveMeetings.approvals.reject")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => review(r.id, "approve")}
|
onClick={() => review(r.id, "approved")}
|
||||||
className="bg-[#0B1E3F] gap-1"
|
className="bg-[#0B1E3F] gap-1"
|
||||||
data-testid={`em-approve-${r.id}`}
|
data-testid={`em-approve-${r.id}`}
|
||||||
>
|
>
|
||||||
@@ -1963,6 +1976,116 @@ function TasksSection({
|
|||||||
|
|
||||||
// ============ AUDIT ============
|
// ============ AUDIT ============
|
||||||
|
|
||||||
|
// Compact "field: old → new" summary of an audit entry's old/new JSON values.
|
||||||
|
// Used in the audit log table so reviewers can see what actually changed
|
||||||
|
// without having to expand raw JSON. Falls back to JSON dumps for unknown
|
||||||
|
// shapes so nothing is hidden.
|
||||||
|
function AuditDiffSummary({
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
oldValue: unknown;
|
||||||
|
newValue: unknown;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
const interesting = [
|
||||||
|
"titleAr",
|
||||||
|
"titleEn",
|
||||||
|
"meetingDate",
|
||||||
|
"dailyNumber",
|
||||||
|
"startTime",
|
||||||
|
"endTime",
|
||||||
|
"location",
|
||||||
|
"meetingUrl",
|
||||||
|
"platform",
|
||||||
|
"status",
|
||||||
|
"isHighlighted",
|
||||||
|
"notes",
|
||||||
|
"assignedTo",
|
||||||
|
"taskType",
|
||||||
|
"dueAt",
|
||||||
|
"reviewDecision",
|
||||||
|
"reviewNotes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const oldObj =
|
||||||
|
oldValue && typeof oldValue === "object"
|
||||||
|
? (oldValue as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const newObj =
|
||||||
|
newValue && typeof newValue === "object"
|
||||||
|
? (newValue as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fmt = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined || v === "") return "—";
|
||||||
|
if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}…` : v;
|
||||||
|
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(v);
|
||||||
|
return s.length > 40 ? `${s.slice(0, 40)}…` : s;
|
||||||
|
} catch {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oldObj && !newObj) return <span className="text-gray-400">—</span>;
|
||||||
|
|
||||||
|
if (oldObj && newObj) {
|
||||||
|
const diffs: { key: string; from: unknown; to: unknown }[] = [];
|
||||||
|
for (const k of interesting) {
|
||||||
|
const a = oldObj[k];
|
||||||
|
const b = newObj[k];
|
||||||
|
if (JSON.stringify(a) !== JSON.stringify(b)) {
|
||||||
|
diffs.push({ key: k, from: a, to: b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (diffs.length === 0) {
|
||||||
|
return <span className="text-gray-400">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{diffs.slice(0, 6).map((d) => (
|
||||||
|
<li key={d.key} className="leading-snug">
|
||||||
|
<span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
|
||||||
|
<span className="text-red-700 line-through">{fmt(d.from)}</span>
|
||||||
|
<span className="mx-1 text-gray-400">→</span>
|
||||||
|
<span className="text-green-700">{fmt(d.to)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{diffs.length > 6 && (
|
||||||
|
<li className="text-gray-500">
|
||||||
|
+{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure create or pure delete — show a compact field summary
|
||||||
|
const single = (newObj ?? oldObj) as Record<string, unknown>;
|
||||||
|
const label = newObj
|
||||||
|
? t("executiveMeetings.audit.created")
|
||||||
|
: t("executiveMeetings.audit.removed");
|
||||||
|
const items = interesting
|
||||||
|
.filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
|
||||||
|
.slice(0, 6);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{items.map((k) => (
|
||||||
|
<li key={k} className="leading-snug">
|
||||||
|
<span className="font-semibold">{k}</span>: {fmt(single[k])}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function AuditSection({
|
function AuditSection({
|
||||||
canView,
|
canView,
|
||||||
isRtl,
|
isRtl,
|
||||||
@@ -2028,19 +2151,20 @@ function AuditSection({
|
|||||||
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
|
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
|
||||||
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
|
<th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
|
||||||
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
|
<th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
|
||||||
|
<th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!isLoading && entries.length === 0 && (
|
{!isLoading && entries.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
<td colSpan={6} className="py-8 text-center text-gray-500">
|
||||||
{t("executiveMeetings.audit.empty")}
|
{t("executiveMeetings.audit.empty")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2052,20 +2176,31 @@ function AuditSection({
|
|||||||
: e.actor.displayNameEn || e.actor.username
|
: e.actor.displayNameEn || e.actor.username
|
||||||
: "—";
|
: "—";
|
||||||
return (
|
return (
|
||||||
<tr key={e.id} className="border-t border-gray-100">
|
<tr
|
||||||
<td className="px-3 py-2 align-middle text-xs text-gray-600">
|
key={e.id}
|
||||||
|
className="border-t border-gray-100 align-top"
|
||||||
|
data-testid={`em-audit-row-${e.id}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
|
||||||
{new Date(e.performedAt).toLocaleString()}
|
{new Date(e.performedAt).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 align-middle">{actor}</td>
|
<td className="px-3 py-2">{actor}</td>
|
||||||
<td className="px-3 py-2 align-middle text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
{t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })}
|
{t(`executiveMeetings.audit.action.${e.action}`, { defaultValue: e.action })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 align-middle text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
{t(`executiveMeetings.audit.entity.${e.entityType}`, {
|
{t(`executiveMeetings.audit.entity.${e.entityType}`, {
|
||||||
defaultValue: e.entityType,
|
defaultValue: e.entityType,
|
||||||
})}
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 align-middle text-xs">{e.entityId ?? "—"}</td>
|
<td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
<AuditDiffSummary
|
||||||
|
oldValue={e.oldValue}
|
||||||
|
newValue={e.newValue}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -2208,6 +2343,30 @@ function PdfSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print + archive in one click: archive the snapshot first, then open the
|
||||||
|
// print view in a new tab so the user can hit Ctrl+P / Save as PDF.
|
||||||
|
async function archiveAndPrint() {
|
||||||
|
if (!date) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiJson(`/api/executive-meetings/pdf-archives`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { archiveDate: date },
|
||||||
|
});
|
||||||
|
await qc.invalidateQueries({
|
||||||
|
queryKey: ["/api/executive-meetings/pdf-archives", date],
|
||||||
|
});
|
||||||
|
const url = `/executive-meetings/print?date=${date}&lang=${lang}`;
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
toast({ title: t("executiveMeetings.pdf.archivedAndPrinted") });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast({ title: msg, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const archives = archivesQuery.data?.archives ?? [];
|
const archives = archivesQuery.data?.archives ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2226,13 +2385,24 @@ function PdfSection({
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={openPrint}
|
onClick={archiveAndPrint}
|
||||||
|
disabled={busy || !date}
|
||||||
className="bg-[#0B1E3F] gap-1"
|
className="bg-[#0B1E3F] gap-1"
|
||||||
data-testid="em-print"
|
data-testid="em-print"
|
||||||
>
|
>
|
||||||
<Printer className="w-4 h-4" />
|
<Printer className="w-4 h-4" />
|
||||||
{t("executiveMeetings.pdf.print")}
|
{t("executiveMeetings.pdf.print")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={openPrint}
|
||||||
|
disabled={!date}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1"
|
||||||
|
data-testid="em-print-only"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
{t("executiveMeetings.pdf.printOnly")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={archiveSnapshot}
|
onClick={archiveSnapshot}
|
||||||
disabled={busy || !date}
|
disabled={busy || !date}
|
||||||
|
|||||||
Generated
+4
-1
@@ -61,7 +61,7 @@ catalogs:
|
|||||||
specifier: ^7.3.2
|
specifier: ^7.3.2
|
||||||
version: 7.3.2
|
version: 7.3.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.25.76
|
specifier: 3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
@@ -123,6 +123,9 @@ importers:
|
|||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.3
|
specifier: ^4.8.3
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
zod:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/bcryptjs':
|
'@types/bcryptjs':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ catalog:
|
|||||||
tailwindcss: ^4.1.14
|
tailwindcss: ^4.1.14
|
||||||
tsx: ^4.21.0
|
tsx: ^4.21.0
|
||||||
vite: ^7.3.2
|
vite: ^7.3.2
|
||||||
zod: ^3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
minimumReleaseAge: 1440
|
minimumReleaseAge: 1440
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user