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:
@@ -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;
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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": "إعدادات الخط"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -12,3 +12,4 @@ export * from "./password-reset-tokens";
|
||||
export * from "./notes";
|
||||
export * from "./groups";
|
||||
export * from "./audit-logs";
|
||||
export * from "./executive-meetings";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user