Compare commits
12 Commits
bb0ecaa9f9
...
16e30e1bb1
| Author | SHA1 | Date | |
|---|---|---|---|
| 16e30e1bb1 | |||
| 17950daa5e | |||
| 323472eec0 | |||
| 457eff44c4 | |||
| 2a194de12c | |||
| 62c38a508f | |||
| 6c5ae9d437 | |||
| 5d76d25eb7 | |||
| 5fe5194349 | |||
| 5e460920c7 | |||
| 2322b77b8d | |||
| 62470c580d |
@@ -119,3 +119,9 @@ id = "FXgAx0l684F40vfGD6lqD"
|
||||
uri = "file://attached_assets/generated_images/platform_architecture_ar.png"
|
||||
type = "image"
|
||||
title = "بنية المنصة — تخطيط احترافي"
|
||||
|
||||
[[outputs]]
|
||||
id = "8gXcnoA-hNBH1c_T0ki__"
|
||||
uri = "file://dist/TX-latest.tar.gz"
|
||||
type = "unspecified"
|
||||
title = "TX-latest — كامل المشروع مع التاريخ (git bundle)"
|
||||
|
||||
@@ -253,3 +253,36 @@ export async function requireExecutiveAccess(
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate any read access to the Public Relations & Protocol module. Guarded by
|
||||
* the scoped `protocol.access` permission — the same permission that gates the
|
||||
* home-screen tile (via app_permissions) — so backend usability aligns exactly
|
||||
* with the visible permission grant. Finer-grained request / mutate / approve /
|
||||
* rooms / audit capabilities are layered on top via scoped permissions in the
|
||||
* routes file.
|
||||
*/
|
||||
export async function requireProtocolAccess(
|
||||
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 ok = await userHasPermission(req.session.userId, "protocol.access");
|
||||
if (!ok) {
|
||||
res.status(403).json({ error: "Forbidden", code: "forbidden" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import groupsRouter from "./groups";
|
||||
import rolesRouter from "./roles";
|
||||
import auditRouter from "./audit";
|
||||
import executiveMeetingsRouter from "./executive-meetings";
|
||||
import protocolRouter from "./protocol";
|
||||
import pushRouter from "./push";
|
||||
import systemRouter from "./system";
|
||||
|
||||
@@ -38,6 +39,7 @@ router.use(groupsRouter);
|
||||
router.use(rolesRouter);
|
||||
router.use(auditRouter);
|
||||
router.use(executiveMeetingsRouter);
|
||||
router.use(protocolRouter);
|
||||
router.use(pushRouter);
|
||||
router.use(systemRouter);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ import NotesPage from "@/pages/notes";
|
||||
import MyOrdersPage from "@/pages/my-orders";
|
||||
import OrdersIncomingPage from "@/pages/orders-incoming";
|
||||
import ExecutiveMeetingsPage from "@/pages/executive-meetings";
|
||||
import ProtocolPage from "@/pages/protocol";
|
||||
import ProtocolRequestPage from "@/pages/protocol-request";
|
||||
import EmbeddedAppPage from "@/pages/embedded-app";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -73,6 +75,8 @@ function Router() {
|
||||
<Route path="/my-orders" component={MyOrdersPage} />
|
||||
<Route path="/orders/incoming" component={OrdersIncomingPage} />
|
||||
<Route path="/meetings" component={ExecutiveMeetingsPage} />
|
||||
<Route path="/protocol" component={ProtocolPage} />
|
||||
<Route path="/protocol/:tab" component={ProtocolPage} />
|
||||
{/* Back-compat redirect: the page used to live at
|
||||
/executive-meetings. Stored PDFs, email links, and bookmarks
|
||||
still point there, so catch the old path (and any deep
|
||||
@@ -94,7 +98,16 @@ function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
{/* The public room booking request form must render for logged-OUT
|
||||
guests, so it lives OUTSIDE <Router> (and thus outside
|
||||
AuthProvider, which redirects unauthenticated users to /login).
|
||||
The catch-all falls through to the authenticated app. */}
|
||||
<Switch>
|
||||
<Route path="/protocol/request" component={ProtocolRequestPage} />
|
||||
<Route>
|
||||
<Router />
|
||||
</Route>
|
||||
</Switch>
|
||||
</WouterRouter>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as React from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const AR_LATN = "ar-u-nu-latn";
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
|
||||
// native datetime-local input produces) or "" when nothing is selected.
|
||||
function parseValue(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
function toValue(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function to24(hour12: number, period: "am" | "pm"): number {
|
||||
if (period === "am") return hour12 === 12 ? 0 : hour12;
|
||||
return hour12 === 12 ? 12 : hour12 + 12;
|
||||
}
|
||||
|
||||
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
|
||||
|
||||
function TimeColumn({
|
||||
values,
|
||||
active,
|
||||
onSelect,
|
||||
format,
|
||||
ariaLabel,
|
||||
}: {
|
||||
values: number[];
|
||||
active: number;
|
||||
onSelect: (v: number) => void;
|
||||
format: (v: number) => string;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const activeRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "center" });
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.map((v) => {
|
||||
const isActive = v === active;
|
||||
return (
|
||||
<Button
|
||||
key={v}
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className="h-8 w-full shrink-0"
|
||||
onClick={() => onSelect(v)}
|
||||
>
|
||||
{format(v)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "اختر التاريخ والوقت",
|
||||
disabled,
|
||||
minDate,
|
||||
}: {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = parseValue(value);
|
||||
|
||||
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
|
||||
const minute = selected ? selected.getMinutes() : 0;
|
||||
const period: "am" | "pm" = selected
|
||||
? selected.getHours() < 12
|
||||
? "am"
|
||||
: "pm"
|
||||
: "am";
|
||||
|
||||
const baseDate = (): Date => {
|
||||
if (selected) return new Date(selected);
|
||||
const d = minDate ? new Date(minDate) : new Date();
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d;
|
||||
};
|
||||
|
||||
const commit = (next: Date) => onChange(toValue(next));
|
||||
|
||||
const handleDay = (day: Date | undefined) => {
|
||||
if (!day) return;
|
||||
const base = baseDate();
|
||||
const next = new Date(day);
|
||||
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleHour = (h: number) => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handleMinute = (m: number) => {
|
||||
const next = baseDate();
|
||||
next.setMinutes(m, 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const handlePeriod = (p: "am" | "pm") => {
|
||||
const next = baseDate();
|
||||
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
const label = selected
|
||||
? new Intl.DateTimeFormat(AR_LATN, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(selected)
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<CalendarClock className="size-4 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleDay}
|
||||
defaultMonth={selected ?? minDate ?? new Date()}
|
||||
disabled={minDate ? { before: minDate } : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="border-t p-3">
|
||||
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
|
||||
الوقت
|
||||
</div>
|
||||
<div className="flex items-stretch justify-center gap-2">
|
||||
<TimeColumn
|
||||
values={HOURS_12}
|
||||
active={hour12}
|
||||
onSelect={handleHour}
|
||||
format={pad}
|
||||
ariaLabel="الساعة"
|
||||
/>
|
||||
<div className="flex items-center text-muted-foreground">:</div>
|
||||
<TimeColumn
|
||||
values={MINUTES}
|
||||
active={minute}
|
||||
onSelect={handleMinute}
|
||||
format={pad}
|
||||
ariaLabel="الدقيقة"
|
||||
/>
|
||||
<div className="flex flex-col justify-center gap-1">
|
||||
{(["am", "pm"] as const).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={period === p ? "default" : "outline"}
|
||||
className="h-8 w-12"
|
||||
onClick={() => handlePeriod(p)}
|
||||
>
|
||||
{p === "am" ? "ص" : "م"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t p-2">
|
||||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||||
تم
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -971,7 +971,9 @@
|
||||
"appName": "نظام Tx",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"empty": "(فارغ)"
|
||||
"empty": "(فارغ)",
|
||||
"back": "رجوع",
|
||||
"confirmDelete": "تأكيد الحذف"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "تطبيق مدمج",
|
||||
@@ -1656,5 +1658,128 @@
|
||||
"saved": "تم الحفظ",
|
||||
"reset": "إعادة الافتراضي"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "العلاقات العامة والمراسم",
|
||||
"notes": "ملاحظات",
|
||||
"statusLabel": "الحالة",
|
||||
"confirmReject": "هل أنت متأكد من رفض هذا الطلب؟",
|
||||
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tabs": {
|
||||
"dashboard": "لوحة المعلومات",
|
||||
"bookings": "حجوزات القاعات",
|
||||
"external": "اللقاءات الخارجية",
|
||||
"gifts": "الهدايا والدروع",
|
||||
"issues": "طلبات الصرف",
|
||||
"reports": "التقارير",
|
||||
"audit": "سجل النشاط",
|
||||
"rooms": "القاعات"
|
||||
},
|
||||
"status": {
|
||||
"pending": "قيد الانتظار",
|
||||
"approved": "معتمد",
|
||||
"rejected": "مرفوض",
|
||||
"cancelled": "ملغى",
|
||||
"issued": "تم الصرف",
|
||||
"scheduled": "مجدول",
|
||||
"completed": "منجز"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "هدية",
|
||||
"shield": "درع"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "اعتماد",
|
||||
"reject": "رفض",
|
||||
"cancel": "إلغاء",
|
||||
"approveIssue": "اعتماد وصرف"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "تعذّر إتمام العملية."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "القاعات المتاحة",
|
||||
"todayBookings": "حجوزات اليوم",
|
||||
"pendingBookings": "حجوزات بانتظار الاعتماد",
|
||||
"upcomingExternal": "لقاءات خارجية قادمة",
|
||||
"giftStock": "إجمالي مخزون الهدايا",
|
||||
"pendingIssues": "طلبات صرف معلّقة",
|
||||
"roomsAvailableNow": "القاعات المتاحة الآن",
|
||||
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "حجز جديد",
|
||||
"edit": "تعديل الحجز",
|
||||
"empty": "لا توجد حجوزات.",
|
||||
"requester": "مقدّم الطلب",
|
||||
"org": "الجهة",
|
||||
"phone": "الهاتف",
|
||||
"attendeeCount": "عدد الحضور",
|
||||
"publicRequest": "طلب عام",
|
||||
"roomLabel": "القاعة",
|
||||
"titleLabel": "عنوان الحجز",
|
||||
"startsAt": "من",
|
||||
"endsAt": "إلى",
|
||||
"viewList": "قائمة",
|
||||
"viewCalendar": "تقويم",
|
||||
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
"linkCopyFailed": "تعذّر نسخ الرابط"
|
||||
},
|
||||
"external": {
|
||||
"new": "لقاء جديد",
|
||||
"edit": "تعديل اللقاء",
|
||||
"empty": "لا توجد لقاءات خارجية.",
|
||||
"titleLabel": "عنوان اللقاء",
|
||||
"party": "الجهة",
|
||||
"location": "المكان",
|
||||
"startsAt": "التاريخ والوقت"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "صنف جديد",
|
||||
"edit": "تعديل الصنف",
|
||||
"empty": "لا توجد أصناف.",
|
||||
"stock": "المخزون",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"kind": "النوع"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "قاعة جديدة",
|
||||
"edit": "تعديل القاعة",
|
||||
"title": "القاعات",
|
||||
"inactive": "غير مفعّلة",
|
||||
"nameAr": "الاسم (عربي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"capacity": "السعة",
|
||||
"location": "الموقع",
|
||||
"active": "مفعّلة",
|
||||
"isActive": "مفعّلة",
|
||||
"empty": "لا توجد قاعات."
|
||||
},
|
||||
"issues": {
|
||||
"new": "طلب صرف",
|
||||
"empty": "لا توجد طلبات صرف.",
|
||||
"recipient": "المستفيد",
|
||||
"gift": "الصنف",
|
||||
"occasion": "المناسبة",
|
||||
"quantity": "الكمية"
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "الحجوزات حسب القاعة",
|
||||
"byGift": "الصرف حسب الصنف",
|
||||
"room": "القاعة",
|
||||
"gift": "الصنف",
|
||||
"total": "الإجمالي",
|
||||
"requests": "الطلبات",
|
||||
"issued": "المصروفة",
|
||||
"issuedQty": "الكمية المصروفة",
|
||||
"empty": "لا توجد بيانات.",
|
||||
"byExternal": "اللقاءات الخارجية حسب الحالة"
|
||||
},
|
||||
"audit": {
|
||||
"empty": "لا يوجد نشاط."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +883,9 @@
|
||||
"appName": "Tx OS",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"empty": "(empty)"
|
||||
"empty": "(empty)",
|
||||
"back": "Back",
|
||||
"confirmDelete": "Confirm delete"
|
||||
},
|
||||
"embeddedFrame": {
|
||||
"title": "Embedded app",
|
||||
@@ -1529,5 +1531,128 @@
|
||||
"saved": "Saved",
|
||||
"reset": "Reset to default"
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"title": "Public Relations & Protocol",
|
||||
"notes": "Notes",
|
||||
"statusLabel": "Status",
|
||||
"confirmReject": "Are you sure you want to reject this request?",
|
||||
"confirmDelete": "This action cannot be undone.",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"bookings": "Room Bookings",
|
||||
"external": "External Meetings",
|
||||
"gifts": "Gifts & Shields",
|
||||
"issues": "Issuance Requests",
|
||||
"reports": "Reports",
|
||||
"audit": "Activity Log",
|
||||
"rooms": "Rooms"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"cancelled": "Cancelled",
|
||||
"issued": "Issued",
|
||||
"scheduled": "Scheduled",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"giftKind": {
|
||||
"gift": "Gift",
|
||||
"shield": "Shield"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"cancel": "Cancel",
|
||||
"approveIssue": "Approve & issue"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "The operation could not be completed."
|
||||
},
|
||||
"dashboard": {
|
||||
"activeRooms": "Active rooms",
|
||||
"todayBookings": "Today's bookings",
|
||||
"pendingBookings": "Bookings awaiting approval",
|
||||
"upcomingExternal": "Upcoming external meetings",
|
||||
"giftStock": "Total gift stock",
|
||||
"pendingIssues": "Pending issuance requests",
|
||||
"roomsAvailableNow": "Rooms available now",
|
||||
"giftsIssuedThisMonth": "Gifts issued this month"
|
||||
},
|
||||
"bookings": {
|
||||
"new": "New booking",
|
||||
"edit": "Edit booking",
|
||||
"empty": "No bookings yet.",
|
||||
"requester": "Requester",
|
||||
"org": "Organization",
|
||||
"phone": "Phone",
|
||||
"attendeeCount": "Attendees",
|
||||
"publicRequest": "Public request",
|
||||
"roomLabel": "Room",
|
||||
"titleLabel": "Booking title",
|
||||
"startsAt": "From",
|
||||
"endsAt": "To",
|
||||
"viewList": "List",
|
||||
"viewCalendar": "Calendar",
|
||||
"shareLinkTitle": "Public booking link (share with guests)",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied",
|
||||
"linkCopyFailed": "Could not copy the link"
|
||||
},
|
||||
"external": {
|
||||
"new": "New meeting",
|
||||
"edit": "Edit meeting",
|
||||
"empty": "No external meetings yet.",
|
||||
"titleLabel": "Meeting title",
|
||||
"party": "Party",
|
||||
"location": "Location",
|
||||
"startsAt": "Date & time"
|
||||
},
|
||||
"gifts": {
|
||||
"new": "New item",
|
||||
"edit": "Edit item",
|
||||
"empty": "No items yet.",
|
||||
"stock": "Stock",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"kind": "Kind"
|
||||
},
|
||||
"rooms": {
|
||||
"new": "New room",
|
||||
"edit": "Edit room",
|
||||
"title": "Rooms",
|
||||
"inactive": "inactive",
|
||||
"nameAr": "Name (Arabic)",
|
||||
"nameEn": "Name (English)",
|
||||
"capacity": "Capacity",
|
||||
"location": "Location",
|
||||
"active": "Active",
|
||||
"isActive": "Active",
|
||||
"empty": "No rooms."
|
||||
},
|
||||
"issues": {
|
||||
"new": "Issuance request",
|
||||
"empty": "No issuance requests yet.",
|
||||
"recipient": "Recipient",
|
||||
"gift": "Item",
|
||||
"occasion": "Occasion",
|
||||
"quantity": "Quantity"
|
||||
},
|
||||
"reports": {
|
||||
"byRoom": "Bookings by room",
|
||||
"byGift": "Issuance by item",
|
||||
"room": "Room",
|
||||
"gift": "Item",
|
||||
"total": "Total",
|
||||
"requests": "Requests",
|
||||
"issued": "Issued",
|
||||
"issuedQty": "Issued qty",
|
||||
"empty": "No data.",
|
||||
"byExternal": "External meetings by status"
|
||||
},
|
||||
"audit": {
|
||||
"empty": "No activity yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarClock, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/date-time-picker";
|
||||
|
||||
const API = `${import.meta.env.BASE_URL}api`;
|
||||
|
||||
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
||||
|
||||
type FormState = {
|
||||
requesterName: string;
|
||||
title: string;
|
||||
requesterPhone: string;
|
||||
requesterOrg: string;
|
||||
attendeeCount: string;
|
||||
roomId: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
notes: string;
|
||||
website: string; // honeypot
|
||||
};
|
||||
|
||||
const EMPTY: FormState = {
|
||||
requesterName: "",
|
||||
title: "",
|
||||
requesterPhone: "",
|
||||
requesterOrg: "",
|
||||
attendeeCount: "",
|
||||
roomId: "",
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
notes: "",
|
||||
website: "",
|
||||
};
|
||||
|
||||
function localToIso(v: string): string {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
export default function ProtocolRequestPage() {
|
||||
// This page is public (no auth). Force RTL Arabic regardless of any
|
||||
// stored language preference so a guest always sees the intended form.
|
||||
useEffect(() => {
|
||||
const prevDir = document.documentElement.dir;
|
||||
const prevLang = document.documentElement.lang;
|
||||
document.documentElement.dir = "rtl";
|
||||
document.documentElement.lang = "ar";
|
||||
return () => {
|
||||
// Restore the app's direction/language so navigating away from this
|
||||
// public page doesn't leave the rest of the app forced into RTL Arabic.
|
||||
document.documentElement.dir = prevDir;
|
||||
document.documentElement.lang = prevLang;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rooms = useQuery<PublicRoom[]>({
|
||||
queryKey: ["protocol-public", "rooms"],
|
||||
queryFn: async () => {
|
||||
const r = await fetch(`${API}/protocol/public/rooms`);
|
||||
if (!r.ok) throw new Error("rooms");
|
||||
return r.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return (
|
||||
form.requesterName.trim().length > 0 &&
|
||||
form.title.trim().length > 0 &&
|
||||
form.requesterPhone.trim().length >= 3 &&
|
||||
form.roomId !== "" &&
|
||||
form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) < new Date(form.endsAt)
|
||||
);
|
||||
}, [form]);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!canSubmit || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/protocol/public/booking-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roomId: Number(form.roomId),
|
||||
title: form.title.trim(),
|
||||
requesterName: form.requesterName.trim(),
|
||||
requesterPhone: form.requesterPhone.trim(),
|
||||
requesterOrg: form.requesterOrg.trim() || null,
|
||||
attendeeCount:
|
||||
form.attendeeCount.trim() === ""
|
||||
? null
|
||||
: Number(form.attendeeCount),
|
||||
startsAt: localToIso(form.startsAt),
|
||||
endsAt: localToIso(form.endsAt),
|
||||
notes: form.notes.trim() || null,
|
||||
website: form.website,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error && typeof data.error === "string") msg = data.error;
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
if (res.status === 429) {
|
||||
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
|
||||
}
|
||||
setError(msg);
|
||||
} catch {
|
||||
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
|
||||
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-slate-800 mb-2">
|
||||
تم استلام طلبك
|
||||
</h1>
|
||||
<p className="text-slate-500 leading-relaxed">
|
||||
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
|
||||
المُدخل لتأكيد الموعد.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setForm(EMPTY);
|
||||
setDone(false);
|
||||
}}
|
||||
>
|
||||
إرسال طلب آخر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
|
||||
<CalendarClock className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
|
||||
>
|
||||
{/* Honeypot: hidden from real users, catches bots. */}
|
||||
<div className="hidden" aria-hidden="true">
|
||||
<label>
|
||||
Website
|
||||
<input
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={form.website}
|
||||
onChange={(e) => set("website", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterName">
|
||||
الاسم الكامل <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="requesterName"
|
||||
value={form.requesterName}
|
||||
onChange={(e) => set("requesterName", e.target.value)}
|
||||
placeholder="الاسم الثلاثي"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">
|
||||
عنوان الاجتماع <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
placeholder="موضوع أو عنوان الاجتماع"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterPhone">
|
||||
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="requesterPhone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
className="text-right"
|
||||
value={form.requesterPhone}
|
||||
onChange={(e) => set("requesterPhone", e.target.value)}
|
||||
placeholder="05xxxxxxxx"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requesterOrg">الجهة / المؤسسة</Label>
|
||||
<Input
|
||||
id="requesterOrg"
|
||||
value={form.requesterOrg}
|
||||
onChange={(e) => set("requesterOrg", e.target.value)}
|
||||
placeholder="اسم الجهة أو المؤسسة (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="attendeeCount">عدد الحضور</Label>
|
||||
<Input
|
||||
id="attendeeCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={form.attendeeCount}
|
||||
onChange={(e) => set("attendeeCount", e.target.value)}
|
||||
placeholder="العدد المتوقع للحضور (اختياري)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room">
|
||||
القاعة <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.roomId}
|
||||
onValueChange={(v) => set("roomId", v)}
|
||||
>
|
||||
<SelectTrigger id="room">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(rooms.data ?? []).map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.nameAr || r.nameEn}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rooms.isError && (
|
||||
<p className="text-xs text-rose-500">
|
||||
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startsAt">
|
||||
من <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<DateTimePicker
|
||||
id="startsAt"
|
||||
value={form.startsAt}
|
||||
onChange={(v) => set("startsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت البداية"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endsAt">
|
||||
إلى <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<DateTimePicker
|
||||
id="endsAt"
|
||||
value={form.endsAt}
|
||||
onChange={(v) => set("endsAt", v)}
|
||||
placeholder="اختر تاريخ ووقت النهاية"
|
||||
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) >= new Date(form.endsAt) && (
|
||||
<p className="text-xs text-rose-500 -mt-2">
|
||||
يجب أن يكون وقت البداية قبل وقت النهاية.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">ملاحظات</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => set("notes", e.target.value)}
|
||||
placeholder="أي تفاصيل إضافية (اختياري)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!canSubmit || submitting}
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
إرسال الطلب
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ export const BUILTIN_APP_SLUGS = [
|
||||
"executive-meetings",
|
||||
"calendar",
|
||||
"documents",
|
||||
"protocol",
|
||||
] as const;
|
||||
|
||||
export type BuiltinAppSlug = (typeof BUILTIN_APP_SLUGS)[number];
|
||||
|
||||
@@ -14,4 +14,5 @@ export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
export * from "./protocol";
|
||||
export * from "./push-subscriptions";
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
integer,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
boolean,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod/v4";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Relations & Protocol module ("العلاقات العامة والمراسم").
|
||||
//
|
||||
// A completely standalone module. Every table is prefixed `protocol_` and has
|
||||
// no foreign keys into the Executive Meetings tables, so the two modules stay
|
||||
// fully independent. Only `usersTable` (the shared account table) is
|
||||
// referenced, and always with `on delete set null` so removing a user never
|
||||
// cascades into protocol history.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Status values for room bookings and gift issuances. The overlap /
|
||||
// conflict-prevention rule only considers "pending" and "approved" rows as
|
||||
// occupying a room; "rejected" and "cancelled" free the slot again.
|
||||
export const PROTOCOL_BOOKING_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type ProtocolBookingStatus =
|
||||
(typeof PROTOCOL_BOOKING_STATUSES)[number];
|
||||
|
||||
export const PROTOCOL_ISSUE_STATUSES = [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"issued",
|
||||
] as const;
|
||||
export type ProtocolIssueStatus = (typeof PROTOCOL_ISSUE_STATUSES)[number];
|
||||
|
||||
// Gift catalogue kinds: a regular gift or a commemorative shield (درع).
|
||||
export const PROTOCOL_GIFT_KINDS = ["gift", "shield"] as const;
|
||||
export type ProtocolGiftKind = (typeof PROTOCOL_GIFT_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolRoomsTable = pgTable(
|
||||
"protocol_rooms",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
nameAr: varchar("name_ar", { length: 200 }).notNull(),
|
||||
nameEn: varchar("name_en", { length: 200 }).notNull().default(""),
|
||||
capacity: integer("capacity"),
|
||||
location: varchar("location", { length: 300 }),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdBy: integer("created_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) => ({
|
||||
activeIdx: index("protocol_rooms_active_idx").on(t.isActive),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room bookings
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolRoomBookingsTable = pgTable(
|
||||
"protocol_room_bookings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roomId: integer("room_id")
|
||||
.notNull()
|
||||
.references(() => protocolRoomsTable.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 500 }).notNull(),
|
||||
requesterName: varchar("requester_name", { length: 300 }),
|
||||
// Guest-supplied contact phone. Only populated for public
|
||||
// (self-service) booking requests; internal bookings leave it null.
|
||||
requesterPhone: varchar("requester_phone", { length: 40 }),
|
||||
// The requester's organization / entity (الجهة). Optional even for
|
||||
// public requests; always null for internal bookings unless set.
|
||||
requesterOrg: varchar("requester_org", { length: 300 }),
|
||||
// Expected number of attendees (عدد الحضور). Optional; supplied by the
|
||||
// guest on a public request or by staff on an internal booking.
|
||||
attendeeCount: integer("attendee_count"),
|
||||
// Provenance marker. "internal" for bookings created by authenticated
|
||||
// protocol staff through the app; "public" for guest requests coming in
|
||||
// via the no-login booking-request link. Defaults to "internal" so all
|
||||
// pre-existing rows are treated as internal.
|
||||
source: varchar("source", { length: 20 }).notNull().default("internal"),
|
||||
// Full timestamps so bookings can span any part of a day. The overlap
|
||||
// rule compares [startsAt, endsAt) intervals per room.
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
roomIdx: index("protocol_bookings_room_idx").on(t.roomId),
|
||||
startIdx: index("protocol_bookings_start_idx").on(t.startsAt),
|
||||
statusIdx: index("protocol_bookings_status_idx").on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External protocol meetings (اجتماعات ومراسم خارجية) — independent from the
|
||||
// Executive Meetings module's is_external flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolExternalMeetingsTable = pgTable(
|
||||
"protocol_external_meetings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
title: varchar("title", { length: 500 }).notNull(),
|
||||
// The visiting party / external delegation.
|
||||
partyName: varchar("party_name", { length: 300 }),
|
||||
location: varchar("location", { length: 300 }),
|
||||
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }),
|
||||
// Lifecycle: pending -> approved | rejected, then optionally
|
||||
// completed | cancelled. Mirrors the booking / gift-issue approval flow.
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
|
||||
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gifts & shields catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolGiftsTable = pgTable(
|
||||
"protocol_gifts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
nameAr: varchar("name_ar", { length: 300 }).notNull(),
|
||||
nameEn: varchar("name_en", { length: 300 }).notNull().default(""),
|
||||
kind: varchar("kind", { length: 20 }).notNull().default("gift"),
|
||||
descriptionAr: text("description_ar"),
|
||||
descriptionEn: text("description_en"),
|
||||
imageUrl: varchar("image_url", { length: 500 }),
|
||||
// Running stock count. Issuing an approved gift decrements this.
|
||||
quantityInStock: integer("quantity_in_stock").notNull().default(0),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdBy: integer("created_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) => ({
|
||||
kindIdx: index("protocol_gifts_kind_idx").on(t.kind),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gift issuances (صرف الهدايا/الدروع)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolGiftIssuesTable = pgTable(
|
||||
"protocol_gift_issues",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
giftId: integer("gift_id")
|
||||
.notNull()
|
||||
.references(() => protocolGiftsTable.id, { onDelete: "restrict" }),
|
||||
recipientName: varchar("recipient_name", { length: 300 }).notNull(),
|
||||
occasion: varchar("occasion", { length: 300 }),
|
||||
quantity: integer("quantity").notNull().default(1),
|
||||
issuedAt: timestamp("issued_at", { withTimezone: true }),
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
notes: text("notes"),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedBy: integer("approved_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
rejectionReason: text("rejection_reason"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
giftIdx: index("protocol_gift_issues_gift_idx").on(t.giftId),
|
||||
statusIdx: index("protocol_gift_issues_status_idx").on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log (scoped to the protocol module only)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolAuditLogsTable = pgTable(
|
||||
"protocol_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(),
|
||||
},
|
||||
(t) => ({
|
||||
entityIdx: index("protocol_audit_entity_idx").on(t.entityType, t.entityId),
|
||||
performedAtIdx: index("protocol_audit_performed_at_idx").on(t.performedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Insert schemas / row types
|
||||
// ---------------------------------------------------------------------------
|
||||
export const insertProtocolRoomSchema = createInsertSchema(
|
||||
protocolRoomsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolRoom = z.infer<typeof insertProtocolRoomSchema>;
|
||||
export type ProtocolRoom = typeof protocolRoomsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolRoomBookingSchema = createInsertSchema(
|
||||
protocolRoomBookingsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolRoomBooking = z.infer<
|
||||
typeof insertProtocolRoomBookingSchema
|
||||
>;
|
||||
export type ProtocolRoomBooking = typeof protocolRoomBookingsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolExternalMeetingSchema = createInsertSchema(
|
||||
protocolExternalMeetingsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolExternalMeeting = z.infer<
|
||||
typeof insertProtocolExternalMeetingSchema
|
||||
>;
|
||||
export type ProtocolExternalMeeting =
|
||||
typeof protocolExternalMeetingsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolGiftSchema = createInsertSchema(
|
||||
protocolGiftsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolGift = z.infer<typeof insertProtocolGiftSchema>;
|
||||
export type ProtocolGift = typeof protocolGiftsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolGiftIssueSchema = createInsertSchema(
|
||||
protocolGiftIssuesTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolGiftIssue = z.infer<
|
||||
typeof insertProtocolGiftIssueSchema
|
||||
>;
|
||||
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
|
||||
|
||||
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
|
||||
+206
-1
@@ -18,8 +18,9 @@ import {
|
||||
executiveMeetingsTable,
|
||||
executiveMeetingAttendeesTable,
|
||||
executiveMeetingNotificationsTable,
|
||||
protocolRoomsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
async function main() {
|
||||
@@ -327,6 +328,19 @@ async function main() {
|
||||
isSystem: false,
|
||||
sortOrder: 7,
|
||||
},
|
||||
{
|
||||
slug: "protocol",
|
||||
nameAr: "العلاقات العامة والمراسم",
|
||||
nameEn: "Public Relations & Protocol",
|
||||
descriptionAr: "حجز القاعات والاجتماعات الخارجية والهدايا والدروع",
|
||||
descriptionEn: "Room bookings, external meetings, gifts and shields",
|
||||
iconName: "Handshake",
|
||||
route: "/protocol",
|
||||
color: "#0ea5e9",
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
sortOrder: 9,
|
||||
},
|
||||
];
|
||||
|
||||
// Drift guard: every built-in slug (those with hardcoded routes in the
|
||||
@@ -345,6 +359,7 @@ async function main() {
|
||||
"executive-meetings": "/meetings",
|
||||
calendar: "/calendar",
|
||||
documents: "/documents",
|
||||
protocol: "/protocol",
|
||||
};
|
||||
for (const a of apps) {
|
||||
if ((BUILTIN_APP_SLUGS as readonly string[]).includes(a.slug)) {
|
||||
@@ -763,6 +778,196 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Public Relations & Protocol: roles.
|
||||
const protocolRoles = [
|
||||
{
|
||||
name: "protocol_super_admin",
|
||||
descriptionAr: "المدير العام للعلاقات العامة والمراسم",
|
||||
descriptionEn: "Protocol Super Admin",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_pr_manager",
|
||||
descriptionAr: "مدير العلاقات العامة",
|
||||
descriptionEn: "PR Manager",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_officer",
|
||||
descriptionAr: "موظف المراسم",
|
||||
descriptionEn: "Protocol Officer",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_requester",
|
||||
descriptionAr: "مقدم الطلب",
|
||||
descriptionEn: "Requester",
|
||||
isSystem: 1,
|
||||
},
|
||||
{
|
||||
name: "protocol_viewer",
|
||||
descriptionAr: "مشاهد العلاقات العامة والمراسم",
|
||||
descriptionEn: "Protocol Viewer",
|
||||
isSystem: 1,
|
||||
},
|
||||
];
|
||||
await db.insert(rolesTable).values(protocolRoles).onConflictDoNothing();
|
||||
console.log("Protocol roles created");
|
||||
|
||||
// Scoped protocol permissions. `protocol.access` also gates the home-screen
|
||||
// tile (via app_permissions); the rest guard individual route capabilities.
|
||||
// Route middleware authorizes by these permissions, never by role name, so
|
||||
// an admin can delegate any capability by granting the permission.
|
||||
const protocolPermissions = [
|
||||
{
|
||||
name: "protocol.access",
|
||||
descriptionAr: "الوصول إلى وحدة العلاقات العامة والمراسم",
|
||||
descriptionEn: "Access the Public Relations & Protocol module",
|
||||
},
|
||||
{
|
||||
name: "protocol.request",
|
||||
descriptionAr: "تقديم طلبات الحجز وإصدار الدروع",
|
||||
descriptionEn: "Submit protocol booking and gift-issue requests",
|
||||
},
|
||||
{
|
||||
name: "protocol.mutate",
|
||||
descriptionAr: "إنشاء وتعديل سجلات العلاقات العامة والمراسم",
|
||||
descriptionEn: "Create and edit protocol records",
|
||||
},
|
||||
{
|
||||
name: "protocol.approve",
|
||||
descriptionAr: "اعتماد أو رفض الحجوزات وإصدارات الدروع",
|
||||
descriptionEn: "Approve or reject protocol bookings and gift issues",
|
||||
},
|
||||
{
|
||||
name: "protocol.rooms.manage",
|
||||
descriptionAr: "إدارة سجل القاعات",
|
||||
descriptionEn: "Manage the protocol rooms registry",
|
||||
},
|
||||
{
|
||||
name: "protocol.audit.read",
|
||||
descriptionAr: "عرض سجل تدقيق العلاقات العامة والمراسم",
|
||||
descriptionEn: "View the protocol audit log",
|
||||
},
|
||||
];
|
||||
await db
|
||||
.insert(permissionsTable)
|
||||
.values(protocolPermissions)
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Map each protocol permission to the roles that should hold it. Capabilities
|
||||
// are additive: super admin holds everything, viewer only reads.
|
||||
const protocolRolePermMap: Record<string, string[]> = {
|
||||
"protocol.access": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
"protocol_requester",
|
||||
"protocol_viewer",
|
||||
],
|
||||
"protocol.request": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
"protocol_requester",
|
||||
],
|
||||
"protocol.mutate": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
"protocol_officer",
|
||||
],
|
||||
"protocol.approve": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
],
|
||||
"protocol.rooms.manage": ["admin", "protocol_super_admin"],
|
||||
"protocol.audit.read": [
|
||||
"admin",
|
||||
"protocol_super_admin",
|
||||
"protocol_pr_manager",
|
||||
],
|
||||
};
|
||||
|
||||
const allRolesForProtocol = await db.select().from(rolesTable);
|
||||
const roleIdByName = new Map(allRolesForProtocol.map((r) => [r.name, r.id]));
|
||||
const allProtocolPerms = await db
|
||||
.select()
|
||||
.from(permissionsTable)
|
||||
.where(
|
||||
inArray(
|
||||
permissionsTable.name,
|
||||
protocolPermissions.map((p) => p.name),
|
||||
),
|
||||
);
|
||||
const permIdByName = new Map(allProtocolPerms.map((p) => [p.name, p.id]));
|
||||
|
||||
const rolePermValues: { roleId: number; permissionId: number }[] = [];
|
||||
for (const [permName, roleNames] of Object.entries(protocolRolePermMap)) {
|
||||
const permId = permIdByName.get(permName);
|
||||
if (permId === undefined) continue;
|
||||
for (const roleName of roleNames) {
|
||||
const roleId = roleIdByName.get(roleName);
|
||||
if (roleId === undefined) continue;
|
||||
rolePermValues.push({ roleId, permissionId: permId });
|
||||
}
|
||||
}
|
||||
if (rolePermValues.length > 0) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values(rolePermValues)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
console.log("Protocol scoped permissions mapped to roles");
|
||||
|
||||
const protocolAccessPermId = permIdByName.get("protocol.access");
|
||||
if (protocolAccessPermId !== undefined) {
|
||||
const [protocolApp] = await db
|
||||
.select()
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.slug, "protocol"));
|
||||
if (protocolApp) {
|
||||
await db
|
||||
.insert(appPermissionsTable)
|
||||
.values({ appId: protocolApp.id, permissionId: protocolAccessPermId })
|
||||
.onConflictDoNothing();
|
||||
console.log(
|
||||
"App permissions set: protocol app restricted to protocol.access permission",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public Relations & Protocol: three default meeting rooms. Only seeded
|
||||
// when the table is empty so admins can rename/remove them freely without
|
||||
// re-seeds bringing them back.
|
||||
const existingRooms = await db
|
||||
.select({ id: protocolRoomsTable.id })
|
||||
.from(protocolRoomsTable)
|
||||
.limit(1);
|
||||
if (existingRooms.length === 0) {
|
||||
await db.insert(protocolRoomsTable).values([
|
||||
{
|
||||
nameAr: "قاعة الاجتماعات الرئيسية",
|
||||
nameEn: "Main Meeting Hall",
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
nameAr: "قاعة كبار الزوار",
|
||||
nameEn: "VIP Guests Hall",
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
nameAr: "قاعة الاجتماعات التنفيذية",
|
||||
nameEn: "Executive Meetings Hall",
|
||||
sortOrder: 3,
|
||||
},
|
||||
]);
|
||||
console.log("Protocol default rooms created");
|
||||
}
|
||||
|
||||
// Executive meetings: sample data for today (idempotent per-date).
|
||||
//
|
||||
// Opt-in only — a vanilla self-hosted install should start with an
|
||||
|
||||
Reference in New Issue
Block a user