Add executive meeting management system with role-based access

Implement a new API endpoint and UI for managing executive meetings, including role-based access control and database schema changes for meeting data and attendees.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 079afca5-8c32-40ea-b7d1-8bf26358ff75
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/BRoHDEI
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-04-27 12:05:40 +00:00
parent 0a5e89a6d4
commit 66bc96f97f
11 changed files with 1057 additions and 1 deletions
@@ -0,0 +1,114 @@
import { Router, type IRouter } from "express";
import { eq, asc, inArray } from "drizzle-orm";
import { db } from "@workspace/db";
import {
executiveMeetingsTable,
executiveMeetingAttendeesTable,
rolesTable,
} 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}$/;
const EXECUTIVE_ROLE_NAMES = [
"admin",
"executive_ceo",
"executive_office_manager",
"executive_coord_lead",
"executive_coordinator",
"executive_viewer",
] as const;
async function requireExecutiveAccess(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
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 })
.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();
}
router.get(
"/executive-meetings",
requireAuth,
requireExecutiveAccess,
async (req, res): Promise<void> => {
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)" });
return;
}
const targetDate = dateRaw || new Date().toISOString().slice(0, 10);
const meetings = await db
.select()
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, targetDate))
.orderBy(asc(executiveMeetingsTable.dailyNumber));
if (meetings.length === 0) {
res.json({ date: targetDate, meetings: [] });
return;
}
const meetingIds = meetings.map((m) => m.id);
const attendees = await db
.select()
.from(executiveMeetingAttendeesTable)
.where(inArray(executiveMeetingAttendeesTable.meetingId, meetingIds))
.orderBy(
asc(executiveMeetingAttendeesTable.meetingId),
asc(executiveMeetingAttendeesTable.sortOrder),
);
const byMeeting = new Map<number, typeof attendees>();
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 });
},
);
router.get(
"/executive-meetings/dates",
requireAuth,
requireExecutiveAccess,
async (_req, res): Promise<void> => {
const rows = await db
.selectDistinct({ d: executiveMeetingsTable.meetingDate })
.from(executiveMeetingsTable)
.orderBy(asc(executiveMeetingsTable.meetingDate));
res.json({ dates: rows.map((r) => r.d) });
},
);
export default router;
+2
View File
@@ -14,6 +14,7 @@ import notesRouter from "./notes";
import groupsRouter from "./groups";
import rolesRouter from "./roles";
import auditRouter from "./audit";
import executiveMeetingsRouter from "./executive-meetings";
const router: IRouter = Router();
@@ -32,5 +33,6 @@ router.use(notesRouter);
router.use(groupsRouter);
router.use(rolesRouter);
router.use(auditRouter);
router.use(executiveMeetingsRouter);
export default router;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+2
View File
@@ -17,6 +17,7 @@ import AdminPage from "@/pages/admin";
import NotesPage from "@/pages/notes";
import MyOrdersPage from "@/pages/my-orders";
import OrdersIncomingPage from "@/pages/orders-incoming";
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
const queryClient = new QueryClient({
defaultOptions: {
@@ -48,6 +49,7 @@ function Router() {
<Route path="/notes" component={NotesPage} />
<Route path="/my-orders" component={MyOrdersPage} />
<Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/executive-meetings" component={ExecutiveMeetingsPage} />
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
+31
View File
@@ -567,5 +567,36 @@
"deleteConfirm": "حذف هذه الملاحظة؟",
"deleteLabelConfirm": "حذف هذا التصنيف؟",
"editTitle": "تعديل ملاحظة"
},
"executiveMeetings": {
"titleAr": "إدارة الاجتماعات التنفيذية",
"titleEn": "Executive Meetings Management",
"scheduleHeading": "قائمة بأسماء حضور الاجتماعات",
"date": "التاريخ:",
"exportPdf": "تصدير PDF",
"fontSettings": "إعدادات الخط",
"noMeetings": "لا توجد اجتماعات في هذا اليوم",
"virtual": "اتصال مرئي",
"virtualAttendance": "الحضور عبر الاتصال المرئي",
"internalAttendance": "الحضور الداخلي",
"externalAttendance": "حضور خارجي",
"placeholderBody": "هذا القسم قيد التطوير في المراحل القادمة. ستتوفر إدارته الكاملة قريباً.",
"col": {
"number": "م",
"meeting": "الاجتماع",
"attendees": "الحضور",
"time": "الوقت"
},
"nav": {
"schedule": "جدول الاجتماعات اليومي",
"manage": "إدارة الاجتماعات",
"requests": "طلبات التعديل",
"approvals": "موافقات مدير المكتب",
"tasks": "مهام فريق التنسيق",
"notifications": "التنبيهات",
"audit": "سجل التعديلات",
"pdf": "تصدير PDF",
"fontSettings": "إعدادات الخط"
}
}
}
+31
View File
@@ -564,5 +564,36 @@
"deleteConfirm": "Delete this note?",
"deleteLabelConfirm": "Delete this label?",
"editTitle": "Edit note"
},
"executiveMeetings": {
"titleAr": "Executive Meetings Management",
"titleEn": "إدارة الاجتماعات التنفيذية",
"scheduleHeading": "Meeting Attendance List",
"date": "Date:",
"exportPdf": "Export PDF",
"fontSettings": "Font Settings",
"noMeetings": "No meetings scheduled for this day",
"virtual": "Virtual",
"virtualAttendance": "Virtual Attendance",
"internalAttendance": "Internal Attendance",
"externalAttendance": "External Attendance",
"placeholderBody": "This section is under development and will be available in upcoming phases.",
"col": {
"number": "#",
"meeting": "Meeting",
"attendees": "Attendees",
"time": "Time"
},
"nav": {
"schedule": "Daily Schedule",
"manage": "Manage Meetings",
"requests": "Change Requests",
"approvals": "Office Manager Approvals",
"tasks": "Coordination Tasks",
"notifications": "Notifications",
"audit": "Audit Log",
"pdf": "PDF Export",
"fontSettings": "Font Settings"
}
}
}
@@ -0,0 +1,418 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
CalendarClock,
FileDown,
Type,
Languages,
ListChecks,
ClipboardList,
CheckSquare,
ListTodo,
Bell,
ScrollText,
FileText,
Settings as SettingsIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
type Attendee = {
id: number;
meetingId: number;
name: string;
title: string | null;
attendanceType: "internal" | "virtual" | "external";
sortOrder: number;
};
type Meeting = {
id: number;
dailyNumber: number;
titleAr: string;
titleEn: string;
meetingDate: string;
startTime: string | null;
endTime: string | null;
location: string | null;
meetingUrl: string | null;
platform: "none" | "webex" | "teams" | "zoom" | "other";
status: string;
isHighlighted: number;
notes: string | null;
attendees: Attendee[];
};
type DayResponse = {
date: string;
meetings: Meeting[];
};
const SECTIONS = [
{ key: "schedule", icon: CalendarClock },
{ key: "manage", icon: ListChecks },
{ key: "requests", icon: ClipboardList },
{ key: "approvals", icon: CheckSquare },
{ key: "tasks", icon: ListTodo },
{ key: "notifications", icon: Bell },
{ key: "audit", icon: ScrollText },
{ key: "pdf", icon: FileText },
{ key: "fontSettings", icon: SettingsIcon },
] as const;
type SectionKey = (typeof SECTIONS)[number]["key"];
function formatTime(t: string | null): string {
if (!t) return "";
return t.slice(0, 5);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export default function ExecutiveMeetingsPage() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
const isRtl = i18n.language === "ar";
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const [section, setSection] = useState<SectionKey>("schedule");
const [date, setDate] = useState<string>(todayIso());
const { data, isLoading } = useQuery<DayResponse>({
queryKey: ["/api/executive-meetings", date],
queryFn: async () => {
const res = await fetch(`/api/executive-meetings?date=${date}`, {
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
const meetings = data?.meetings ?? [];
return (
<div className="min-h-screen bg-[#f4f6fb] text-foreground" dir={isRtl ? "rtl" : "ltr"}>
{/* Top header */}
<header className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 flex-wrap">
<Button
variant="ghost"
size="icon"
onClick={() => setLocation("/")}
aria-label={t("common.back")}
className="text-foreground"
>
<BackIcon className="w-5 h-5" />
</Button>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="w-10 h-10 sm:w-11 sm:h-11 rounded-md bg-[#0B1E3F] flex items-center justify-center text-white shrink-0">
<CalendarClock className="w-6 h-6" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm sm:text-base font-semibold text-[#0B1E3F] truncate">
{t("executiveMeetings.titleAr")}
</div>
<div className="text-[11px] sm:text-xs text-muted-foreground truncate">
{t("executiveMeetings.titleEn")}
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1.5" disabled>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("fontSettings")}
>
<Type className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.fontSettings")}</span>
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => i18n.changeLanguage(isRtl ? "en" : "ar")}
>
<Languages className="w-4 h-4" />
<span className="text-xs">{isRtl ? "EN" : "ع"}</span>
</Button>
</div>
</div>
{/* Section tabs */}
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.map(({ key, icon: Icon }) => {
const active = section === key;
return (
<button
key={key}
type="button"
onClick={() => setSection(key)}
className={
"flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
(active
? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
: "border-transparent text-muted-foreground hover:text-foreground")
}
data-testid={`em-nav-${key}`}
>
<Icon className="w-4 h-4" />
{t(`executiveMeetings.nav.${key}`)}
</button>
);
})}
</div>
</nav>
</header>
<main className="max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
{section === "schedule" && (
<ScheduleSection
meetings={meetings}
date={date}
onDateChange={setDate}
isLoading={isLoading}
isRtl={isRtl}
t={t}
/>
)}
{section !== "schedule" && (
<PlaceholderSection sectionKey={section} t={t} />
)}
</main>
</div>
);
}
function ScheduleSection({
meetings,
date,
onDateChange,
isLoading,
isRtl,
t,
}: {
meetings: Meeting[];
date: string;
onDateChange: (d: string) => void;
isLoading: boolean;
isRtl: boolean;
t: (k: string) => string;
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2
className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
data-testid="em-schedule-heading"
>
{t("executiveMeetings.scheduleHeading")}
</h2>
<label className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
</div>
<div className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-hidden shadow-sm">
<table
className="w-full border-collapse text-sm sm:text-[15px]"
dir={isRtl ? "rtl" : "ltr"}
>
<thead>
<tr className="bg-[#0B1E3F] text-white">
<th className="border border-[#0B1E3F] px-2 py-2 w-12 text-center font-semibold">
{t("executiveMeetings.col.number")}
</th>
<th className="border border-[#0B1E3F] px-3 py-2 w-[28%] text-start font-semibold">
{t("executiveMeetings.col.meeting")}
</th>
<th className="border border-[#0B1E3F] px-3 py-2 text-start font-semibold">
{t("executiveMeetings.col.attendees")}
</th>
<th className="border border-[#0B1E3F] px-3 py-2 w-28 text-center font-semibold">
{t("executiveMeetings.col.time")}
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={4} className="py-10 text-center text-muted-foreground">
{t("common.loading")}
</td>
</tr>
)}
{!isLoading && meetings.length === 0 && (
<tr>
<td colSpan={4} className="py-10 text-center text-muted-foreground">
{t("executiveMeetings.noMeetings")}
</td>
</tr>
)}
{meetings.map((m) => (
<MeetingRow key={m.id} meeting={m} isRtl={isRtl} t={t} />
))}
</tbody>
</table>
</div>
</div>
);
}
function MeetingRow({
meeting,
isRtl,
t,
}: {
meeting: Meeting;
isRtl: boolean;
t: (k: string) => string;
}) {
const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
const isCancelled = meeting.status === "cancelled";
const highlight = meeting.isHighlighted === 1;
const numberCellCls =
"border border-gray-300 px-2 py-3 text-center align-middle font-semibold " +
(highlight || isCancelled
? "bg-red-600 text-white"
: "bg-white text-[#0B1E3F]");
const virtual = meeting.attendees.filter((a) => a.attendanceType === "virtual");
const internal = meeting.attendees.filter((a) => a.attendanceType === "internal");
const external = meeting.attendees.filter((a) => a.attendanceType === "external");
const hasSplit = virtual.length > 0 && (internal.length > 0 || external.length > 0);
const platformLabel: Record<Meeting["platform"], string> = {
none: "",
webex: "Webex",
teams: "Teams",
zoom: "Zoom",
other: t("executiveMeetings.virtual"),
};
return (
<tr className="bg-white">
<td className={numberCellCls}>{meeting.dailyNumber}</td>
<td className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F]">
<div className="font-medium leading-snug">{title}</div>
{meeting.location && (
<div className="text-[11px] text-muted-foreground mt-0.5">{meeting.location}</div>
)}
</td>
<td className="border border-gray-300 px-3 py-3 align-middle">
{hasSplit ? (
<div className="space-y-2">
{virtual.length > 0 && (
<AttendeeGroup
label={
meeting.platform !== "none"
? `${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`
: t("executiveMeetings.virtualAttendance")
}
items={virtual}
/>
)}
{internal.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.internalAttendance")}
items={internal}
/>
)}
{external.length > 0 && (
<AttendeeGroup
label={t("executiveMeetings.externalAttendance")}
items={external}
/>
)}
</div>
) : meeting.platform !== "none" && virtual.length > 0 ? (
<AttendeeGroup
label={`${t("executiveMeetings.virtualAttendance")}${platformLabel[meeting.platform]}`}
items={virtual}
/>
) : (
<AttendeeFlow items={meeting.attendees} />
)}
</td>
<td className="border border-gray-300 px-3 py-3 align-middle text-center">
<div className="font-mono text-[#0B1E3F] leading-tight whitespace-nowrap">
<div>{formatTime(meeting.startTime)}</div>
<div className="text-muted-foreground text-xs"></div>
<div>{formatTime(meeting.endTime)}</div>
</div>
</td>
</tr>
);
}
function AttendeeGroup({ label, items }: { label: string; items: Attendee[] }) {
return (
<div>
<div className="text-xs sm:text-sm font-semibold text-[#0B1E3F] mb-1"> {label} </div>
<AttendeeFlow items={items} />
</div>
);
}
function AttendeeFlow({ items }: { items: Attendee[] }) {
if (items.length === 0) {
return <span className="text-xs text-muted-foreground"></span>;
}
return (
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-[#0B1E3F] leading-snug">
{items.map((a, idx) => (
<li key={a.id} className="text-sm whitespace-nowrap">
<span className="text-muted-foreground me-1">{idx + 1}-</span>
{a.name}
</li>
))}
</ul>
);
}
function PlaceholderSection({
sectionKey,
t,
}: {
sectionKey: SectionKey;
t: (k: string) => string;
}) {
return (
<div className="bg-white border border-gray-200 rounded-md p-10 sm:p-16 text-center shadow-sm">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-[#0B1E3F]/10 flex items-center justify-center text-[#0B1E3F]">
<SettingsIcon className="w-6 h-6" />
</div>
<h3 className="text-lg sm:text-xl font-semibold text-[#0B1E3F] mb-2">
{t(`executiveMeetings.nav.${sectionKey}`)}
</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{t("executiveMeetings.placeholderBody")}
</p>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import {
pgTable,
serial,
integer,
varchar,
text,
timestamp,
date,
time,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { usersTable } from "./users";
export const executiveMeetingsTable = pgTable(
"executive_meetings",
{
id: serial("id").primaryKey(),
dailyNumber: integer("daily_number").notNull(),
titleAr: varchar("title_ar", { length: 300 }).notNull(),
titleEn: varchar("title_en", { length: 300 }).notNull().default(""),
meetingDate: date("meeting_date").notNull(),
startTime: time("start_time", { withTimezone: false }),
endTime: time("end_time", { withTimezone: false }),
location: varchar("location", { length: 300 }),
meetingUrl: text("meeting_url"),
platform: varchar("platform", { length: 32 }).notNull().default("none"),
status: varchar("status", { length: 32 }).notNull().default("scheduled"),
isHighlighted: integer("is_highlighted").notNull().default(0),
notes: text("notes"),
attachments: jsonb("attachments").default([]),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
updatedBy: integer("updated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
dateIdx: index("executive_meetings_date_idx").on(t.meetingDate),
dateNumberUnique: uniqueIndex("executive_meetings_date_number_unique").on(
t.meetingDate,
t.dailyNumber,
),
}),
);
export const executiveMeetingAttendeesTable = pgTable(
"executive_meeting_attendees",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
name: varchar("name", { length: 200 }).notNull(),
title: varchar("title", { length: 200 }),
attendanceType: varchar("attendance_type", { length: 32 })
.notNull()
.default("internal"),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => ({
meetingIdx: index("executive_meeting_attendees_meeting_idx").on(t.meetingId),
}),
);
export const executiveMeetingRequestsTable = pgTable(
"executive_meeting_requests",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id")
.notNull()
.references(() => executiveMeetingsTable.id, { onDelete: "cascade" }),
requestedBy: integer("requested_by").references(() => usersTable.id, {
onDelete: "set null",
}),
requestType: varchar("request_type", { length: 64 }).notNull(),
requestDetails: jsonb("request_details"),
status: varchar("status", { length: 32 }).notNull().default("new"),
reviewedBy: integer("reviewed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
reviewDecision: varchar("review_decision", { length: 32 }),
reviewNotes: text("review_notes"),
assignedTo: integer("assigned_to").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export const executiveMeetingTasksTable = pgTable("executive_meeting_tasks", {
id: serial("id").primaryKey(),
requestId: integer("request_id").references(() => executiveMeetingRequestsTable.id, {
onDelete: "cascade",
}),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
assignedTo: integer("assigned_to").references(() => usersTable.id, {
onDelete: "set null",
}),
taskType: varchar("task_type", { length: 64 }).notNull(),
status: varchar("status", { length: 32 }).notNull().default("pending"),
dueAt: timestamp("due_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});
export const executiveMeetingNotificationsTable = pgTable(
"executive_meeting_notifications",
{
id: serial("id").primaryKey(),
meetingId: integer("meeting_id").references(() => executiveMeetingsTable.id, {
onDelete: "cascade",
}),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
notificationType: varchar("notification_type", { length: 64 }).notNull(),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
sentAt: timestamp("sent_at", { withTimezone: true }),
status: varchar("status", { length: 32 }).notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingAuditLogsTable = pgTable(
"executive_meeting_audit_logs",
{
id: serial("id").primaryKey(),
action: varchar("action", { length: 100 }).notNull(),
entityType: varchar("entity_type", { length: 50 }).notNull(),
entityId: integer("entity_id"),
oldValue: jsonb("old_value"),
newValue: jsonb("new_value"),
performedBy: integer("performed_by").references(() => usersTable.id, {
onDelete: "set null",
}),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingPdfArchivesTable = pgTable(
"executive_meeting_pdf_archives",
{
id: serial("id").primaryKey(),
archiveDate: date("archive_date").notNull(),
filePath: text("file_path").notNull(),
version: integer("version").notNull().default(1),
generatedBy: integer("generated_by").references(() => usersTable.id, {
onDelete: "set null",
}),
generatedAt: timestamp("generated_at", { withTimezone: true }).notNull().defaultNow(),
},
);
export const executiveMeetingFontSettingsTable = pgTable(
"executive_meeting_font_settings",
{
id: serial("id").primaryKey(),
scope: varchar("scope", { length: 16 }).notNull().default("global"),
userId: integer("user_id").references(() => usersTable.id, { onDelete: "cascade" }),
fontFamily: varchar("font_family", { length: 100 }).notNull().default("system"),
fontSize: integer("font_size").notNull().default(14),
fontWeight: varchar("font_weight", { length: 16 }).notNull().default("regular"),
alignment: varchar("alignment", { length: 16 }).notNull().default("start"),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
);
export type ExecutiveMeeting = typeof executiveMeetingsTable.$inferSelect;
export type ExecutiveMeetingAttendee = typeof executiveMeetingAttendeesTable.$inferSelect;
export type ExecutiveMeetingRequest = typeof executiveMeetingRequestsTable.$inferSelect;
export type ExecutiveMeetingTask = typeof executiveMeetingTasksTable.$inferSelect;
+1
View File
@@ -12,3 +12,4 @@ export * from "./password-reset-tokens";
export * from "./notes";
export * from "./groups";
export * from "./audit-logs";
export * from "./executive-meetings";
+2 -1
View File
@@ -40,6 +40,7 @@
- **Notifications**: unread tracking, mark-all-read
- **Admin Panel**: CRUD for apps, services, users (admin role required)
- **Session-based Auth**: RBAC with roles (admin/user)
- **Executive Meetings (Phase 1)**: bilingual daily-schedule table for the executive office, app entry on the home grid (`/executive-meetings`), 9-section sub-nav (schedule, manage, requests, approvals, tasks, notifications, audit, pdf, font settings) — only the daily schedule is implemented in Phase 1; other sections render placeholders.
## Key Commands
@@ -56,7 +57,7 @@
## Database Tables
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
## Important Notes
+265
View File
@@ -13,6 +13,8 @@ import {
userGroupsTable,
groupAppsTable,
groupRolesTable,
executiveMeetingsTable,
executiveMeetingAttendeesTable,
} from "@workspace/db";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -232,6 +234,19 @@ async function main() {
isSystem: false,
sortOrder: 6,
},
{
slug: "executive-meetings",
nameAr: "إدارة الاجتماعات التنفيذية",
nameEn: "Executive Meetings",
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
descriptionEn: "Schedule and track executive office meetings",
iconName: "CalendarClock",
route: "/executive-meetings",
color: "#0B1E3F",
isActive: true,
isSystem: false,
sortOrder: 7,
},
];
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
@@ -436,6 +451,256 @@ async function main() {
}
console.log("Groups created and existing users mapped");
// Executive meetings: roles
const execRoles = [
{
name: "executive_ceo",
descriptionAr: "الرئيس التنفيذي",
descriptionEn: "Chief Executive Officer",
isSystem: 1,
},
{
name: "executive_office_manager",
descriptionAr: "مدير المكتب التنفيذي",
descriptionEn: "Executive Office Manager",
isSystem: 1,
},
{
name: "executive_coord_lead",
descriptionAr: "رئيس التنسيق والتوثيق",
descriptionEn: "Coordination Lead",
isSystem: 1,
},
{
name: "executive_coordinator",
descriptionAr: "منسق المكتب التنفيذي",
descriptionEn: "Executive Coordinator",
isSystem: 1,
},
{
name: "executive_viewer",
descriptionAr: "مشاهد الاجتماعات التنفيذية",
descriptionEn: "Executive Meetings Viewer",
isSystem: 1,
},
];
await db.insert(rolesTable).values(execRoles).onConflictDoNothing();
console.log("Executive Meetings roles created");
// Executive meetings: sample data for today (idempotent per-date)
const today = new Date().toISOString().slice(0, 10);
const existingForToday = await db
.select({ id: executiveMeetingsTable.id })
.from(executiveMeetingsTable)
.where(eq(executiveMeetingsTable.meetingDate, today));
if (existingForToday.length === 0) {
{
const sampleMeetings: Array<{
meeting: {
dailyNumber: number;
titleAr: string;
titleEn: string;
meetingDate: string;
startTime: string;
endTime: string;
platform: "none" | "webex" | "teams" | "zoom" | "other";
isHighlighted: number;
status: string;
};
attendees: Array<{
name: string;
attendanceType: "internal" | "virtual" | "external";
}>;
}> = [
{
meeting: {
dailyNumber: 1,
titleAr: "اجتماع افتتاحي للمكتب التنفيذي",
titleEn: "Executive Office Opening Meeting",
meetingDate: today,
startTime: "10:00",
endTime: "10:30",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "أ. سعد العتيبي", attendanceType: "internal" },
{ name: "أ. خالد الشهري", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 2,
titleAr: "اجتماع بشأن خطة التحول الرقمي",
titleEn: "Digital Transformation Plan Meeting",
meetingDate: today,
startTime: "11:00",
endTime: "11:30",
platform: "webex",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "د. محمد الزهراني", attendanceType: "virtual" },
{ name: "أ. عبدالعزيز القحطاني", attendanceType: "virtual" },
{ name: "أ. ناصر الدوسري", attendanceType: "virtual" },
{ name: "م. فهد المالكي", attendanceType: "virtual" },
],
},
{
meeting: {
dailyNumber: 3,
titleAr: "اجتماع تحضيري لاجتماع مجلس الإدارة",
titleEn: "Board Meeting Preparation",
meetingDate: today,
startTime: "11:40",
endTime: "12:10",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "أ. تركي السبيعي", attendanceType: "internal" },
{ name: "أ. ماجد الحربي", attendanceType: "internal" },
{ name: "أ. عبدالله البقمي", attendanceType: "internal" },
{ name: "أ. سلطان الغامدي", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 4,
titleAr: "اجتماع بشأن عرض الخطة الخاصة بالقطاعين",
titleEn: "Two-Sector Plan Presentation",
meetingDate: today,
startTime: "13:00",
endTime: "13:30",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "أ. بدر العنزي", attendanceType: "internal" },
{ name: "أ. هاني المطيري", attendanceType: "internal" },
{ name: "أ. وليد الرشيدي", attendanceType: "internal" },
{ name: "أ. يوسف الجهني", attendanceType: "internal" },
{ name: "أ. عبدالرحمن الشمري", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 5,
titleAr: "اجتماع مع أ. عبدالله بشأن المتابعة",
titleEn: "Follow-up Meeting with Mr. Abdullah",
meetingDate: today,
startTime: "13:30",
endTime: "13:50",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [{ name: "أ. عبدالله الزهراني", attendanceType: "internal" }],
},
{
meeting: {
dailyNumber: 6,
titleAr: "اجتماع دوري بشأن مواضيع مكتب الإدارة",
titleEn: "Periodic Office Topics Meeting",
meetingDate: today,
startTime: "14:00",
endTime: "14:30",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "أ. مازن الفيفي", attendanceType: "internal" },
{ name: "أ. سامي العسيري", attendanceType: "internal" },
{ name: "أ. أحمد البلوي", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 7,
titleAr: "اجتماع بشأن إطار اعتماد البرامج",
titleEn: "Program Accreditation Framework",
meetingDate: today,
startTime: "14:40",
endTime: "15:10",
platform: "none",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "د. عبدالمجيد الحارثي", attendanceType: "internal" },
{ name: "د. منصور القرني", attendanceType: "internal" },
{ name: "أ. فيصل الزهراني", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 8,
titleAr: "اجتماع بشأن استراتيجية الموارد البشرية",
titleEn: "HR Strategy Meeting",
meetingDate: today,
startTime: "15:30",
endTime: "16:30",
platform: "webex",
isHighlighted: 1,
status: "scheduled",
},
attendees: [
{ name: "د. سعود الشهراني", attendanceType: "virtual" },
{ name: "أ. عبدالإله الحربي", attendanceType: "virtual" },
{ name: "أ. خالد العنزي", attendanceType: "virtual" },
{ name: "أ. أحمد المالكي", attendanceType: "internal" },
{ name: "أ. ناصر القحطاني", attendanceType: "internal" },
{ name: "أ. سعيد الغامدي", attendanceType: "internal" },
{ name: "أ. علي الزهراني", attendanceType: "internal" },
{ name: "أ. محمد البقمي", attendanceType: "internal" },
],
},
{
meeting: {
dailyNumber: 9,
titleAr: "اجتماع بشأن متابعة المشاريع المشتركة",
titleEn: "Joint Projects Follow-up",
meetingDate: today,
startTime: "16:40",
endTime: "17:10",
platform: "webex",
isHighlighted: 0,
status: "scheduled",
},
attendees: [
{ name: "أ. يزيد العتيبي", attendanceType: "virtual" },
{ name: "أ. تركي السهلي", attendanceType: "virtual" },
{ name: "أ. عبدالله الزيد", attendanceType: "internal" },
],
},
];
for (const { meeting, attendees } of sampleMeetings) {
const [inserted] = await db
.insert(executiveMeetingsTable)
.values(meeting)
.returning();
if (!inserted) continue;
await db.insert(executiveMeetingAttendeesTable).values(
attendees.map((a, idx) => ({
meetingId: inserted.id,
name: a.name,
attendanceType: a.attendanceType,
sortOrder: idx,
})),
);
}
console.log("Executive Meetings sample data created for today");
}
} else {
console.log("Executive Meetings sample data already exists for today, skipping");
}
console.log("\n✅ Seeding complete!");
console.log("\nDemo accounts:");
console.log(" Admin: username=admin, password=admin123");