Task #649: complete Public Relations & Protocol module (fix 8 review gaps)

Addresses the 8 spec gaps that blocked prior completion, all additive and
scoped to the protocol_* module / /protocol routes. executive-meetings untouched.

Backend (api-server/routes/protocol.ts, lib/db schema, seed):
- External meetings create as "pending" (status dropped from create schema);
  added approve + reject endpoints with state-conditional WHERE status='pending'
  and 409 on contention; patch status enum restricted to completed/cancelled.
- Added approvedBy/approvedAt/rejectionReason columns + status index; default
  status now "pending".
- Booking reject and gift-issue reject made state-conditional (409 on 0 rows).
- Dashboard: roomsAvailableNow (NOT EXISTS), giftsIssuedThisMonth/Qty;
  upcomingExternal filtered to future pending|approved.
- Reports: added externalMeetings summary counts by status.
- Seed: room names corrected (AR + EN).

Frontend (tx-os/pages/protocol.tsx, App.tsx, locales):
- URL-synced tabs under /protocol/:tab (slug<->tab maps).
- Dedicated Rooms tab (moved out of Gifts); RoomDialog isActive toggle.
- External approve/reject buttons + rejectionReason display; ExternalDialog
  create omits status, edit limited to completed/cancelled.
- Dashboard cards clickable to navigate; reports external section.
- Bookings list/calendar (grouped-by-day) view toggle.
- New i18n keys in ar.json + en.json.

Verification: tx-os + protocol.ts typecheck clean; drizzle push + re-seed done;
both workflows restarted; /api/protocol/me returns 401 unauth; architect
re-review approved. Pre-existing typecheck errors in executive-meetings.ts and
push.ts are unrelated and were not touched.

Note: had to rebuild lib/db declarations (tsc --build --force) so api-server
project references picked up the new schema columns.
This commit is contained in:
Replit Agent
2026-07-06 09:46:12 +00:00
parent 2322b77b8d
commit 5e460920c7
7 changed files with 685 additions and 220 deletions
+197 -8
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { and, asc, desc, eq, gte, inArray, lte, ne, sql } from "drizzle-orm"; import { and, asc, desc, eq, gte, inArray, lt, lte, ne, sql } from "drizzle-orm";
import { z, type ZodType } from "zod"; import { z, type ZodType } from "zod";
import { db } from "@workspace/db"; import { db } from "@workspace/db";
import { import {
@@ -169,7 +169,6 @@ const externalCreateSchema = z
location: z.string().trim().max(300).nullable().optional(), location: z.string().trim().max(300).nullable().optional(),
startsAt: isoDateTime, startsAt: isoDateTime,
endsAt: isoDateTime.nullable().optional(), endsAt: isoDateTime.nullable().optional(),
status: z.enum(["scheduled", "completed", "cancelled"]).optional(),
notes: z.string().trim().max(5000).nullable().optional(), notes: z.string().trim().max(5000).nullable().optional(),
}) })
.refine( .refine(
@@ -177,6 +176,9 @@ const externalCreateSchema = z
{ message: "startsAt must be before endsAt", path: ["endsAt"] }, { message: "startsAt must be before endsAt", path: ["endsAt"] },
); );
// Edits may only mark an already-decided meeting completed/cancelled; the
// pending -> approved | rejected transition goes through the dedicated
// approve/reject endpoints so it stays state-conditional.
const externalPatchSchema = z const externalPatchSchema = z
.object({ .object({
title: z.string().trim().min(1).max(500).optional(), title: z.string().trim().min(1).max(500).optional(),
@@ -184,7 +186,7 @@ const externalPatchSchema = z
location: z.string().trim().max(300).nullable().optional(), location: z.string().trim().max(300).nullable().optional(),
startsAt: isoDateTime.optional(), startsAt: isoDateTime.optional(),
endsAt: isoDateTime.nullable().optional(), endsAt: isoDateTime.nullable().optional(),
status: z.enum(["scheduled", "completed", "cancelled"]).optional(), status: z.enum(["completed", "cancelled"]).optional(),
notes: z.string().trim().max(5000).nullable().optional(), notes: z.string().trim().max(5000).nullable().optional(),
}) })
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
@@ -722,6 +724,8 @@ router.post(
res.status(404).json({ error: "Not found", code: "not_found" }); res.status(404).json({ error: "Not found", code: "not_found" });
return; return;
} }
// Only a pending booking can be rejected; the state-conditional update
// makes this atomic against a concurrent approve/cancel.
const [row] = await db const [row] = await db
.update(protocolRoomBookingsTable) .update(protocolRoomBookingsTable)
.set({ .set({
@@ -730,8 +734,19 @@ router.post(
approvedAt: new Date(), approvedAt: new Date(),
rejectionReason: body.reason ?? null, rejectionReason: body.reason ?? null,
}) })
.where(eq(protocolRoomBookingsTable.id, id)) .where(
and(
eq(protocolRoomBookingsTable.id, id),
eq(protocolRoomBookingsTable.status, "pending"),
),
)
.returning(); .returning();
if (!row) {
res
.status(409)
.json({ error: "booking is not pending", code: "invalid_status" });
return;
}
await logAudit(db, { await logAudit(db, {
action: "booking.reject", action: "booking.reject",
entityType: "booking", entityType: "booking",
@@ -830,7 +845,7 @@ router.post(
location: body.location ?? null, location: body.location ?? null,
startsAt: new Date(body.startsAt), startsAt: new Date(body.startsAt),
endsAt: body.endsAt ? new Date(body.endsAt) : null, endsAt: body.endsAt ? new Date(body.endsAt) : null,
status: body.status ?? "scheduled", status: "pending",
notes: body.notes ?? null, notes: body.notes ?? null,
createdBy: req.session.userId!, createdBy: req.session.userId!,
}) })
@@ -917,6 +932,101 @@ router.delete(
}, },
); );
router.post(
"/protocol/external-meetings/:id/approve",
requireApprove,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const [existing] = await db
.select()
.from(protocolExternalMeetingsTable)
.where(eq(protocolExternalMeetingsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// State-conditional claim: only a pending meeting flips to approved.
const [row] = await db
.update(protocolExternalMeetingsTable)
.set({
status: "approved",
approvedBy: req.session.userId!,
approvedAt: new Date(),
rejectionReason: null,
})
.where(
and(
eq(protocolExternalMeetingsTable.id, id),
eq(protocolExternalMeetingsTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "meeting is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "external_meeting.approve",
entityType: "external_meeting",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
},
);
router.post(
"/protocol/external-meetings/:id/reject",
requireApprove,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const body = parseBody(res, rejectSchema, req.body);
if (!body) return;
const [existing] = await db
.select()
.from(protocolExternalMeetingsTable)
.where(eq(protocolExternalMeetingsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const [row] = await db
.update(protocolExternalMeetingsTable)
.set({
status: "rejected",
approvedBy: req.session.userId!,
approvedAt: new Date(),
rejectionReason: body.reason ?? null,
})
.where(
and(
eq(protocolExternalMeetingsTable.id, id),
eq(protocolExternalMeetingsTable.status, "pending"),
),
)
.returning();
if (!row) {
res
.status(409)
.json({ error: "meeting is not pending", code: "invalid_status" });
return;
}
await logAudit(db, {
action: "external_meeting.reject",
entityType: "external_meeting",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
},
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Gifts & shields catalogue // Gifts & shields catalogue
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1234,6 +1344,8 @@ router.post(
res.status(404).json({ error: "Not found", code: "not_found" }); res.status(404).json({ error: "Not found", code: "not_found" });
return; return;
} }
// Only a pending issue can be rejected; the state-conditional update
// makes this atomic against a concurrent approve.
const [row] = await db const [row] = await db
.update(protocolGiftIssuesTable) .update(protocolGiftIssuesTable)
.set({ .set({
@@ -1242,8 +1354,19 @@ router.post(
approvedAt: new Date(), approvedAt: new Date(),
rejectionReason: body.reason ?? null, rejectionReason: body.reason ?? null,
}) })
.where(eq(protocolGiftIssuesTable.id, id)) .where(
and(
eq(protocolGiftIssuesTable.id, id),
eq(protocolGiftIssuesTable.status, "pending"),
),
)
.returning(); .returning();
if (!row) {
res
.status(409)
.json({ error: "issue is not pending", code: "invalid_status" });
return;
}
await logAudit(db, { await logAudit(db, {
action: "gift_issue.reject", action: "gift_issue.reject",
entityType: "gift_issue", entityType: "gift_issue",
@@ -1297,6 +1420,8 @@ router.get(
now.getDate(), now.getDate(),
); );
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const [roomCount] = await db const [roomCount] = await db
.select({ c: sql<number>`count(*)::int` }) .select({ c: sql<number>`count(*)::int` })
@@ -1324,7 +1449,7 @@ router.get(
.from(protocolExternalMeetingsTable) .from(protocolExternalMeetingsTable)
.where( .where(
and( and(
eq(protocolExternalMeetingsTable.status, "scheduled"), inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]),
gte(protocolExternalMeetingsTable.startsAt, now), gte(protocolExternalMeetingsTable.startsAt, now),
), ),
); );
@@ -1342,14 +1467,50 @@ router.get(
.from(protocolGiftIssuesTable) .from(protocolGiftIssuesTable)
.where(eq(protocolGiftIssuesTable.status, "pending")); .where(eq(protocolGiftIssuesTable.status, "pending"));
// Gifts/shields actually issued during the current calendar month.
const [issuedThisMonth] = await db
.select({
count: sql<number>`count(*)::int`,
qty: sql<number>`coalesce(sum(${protocolGiftIssuesTable.quantity}), 0)::int`,
})
.from(protocolGiftIssuesTable)
.where(
and(
eq(protocolGiftIssuesTable.status, "issued"),
gte(protocolGiftIssuesTable.issuedAt, startOfMonth),
lt(protocolGiftIssuesTable.issuedAt, startOfNextMonth),
),
);
// Rooms free right now = active rooms with no pending/approved booking
// currently spanning [startsAt, endsAt).
const [availableNow] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomsTable)
.where(
and(
eq(protocolRoomsTable.isActive, true),
sql`not exists (
select 1 from ${protocolRoomBookingsTable} b
where b.room_id = ${protocolRoomsTable.id}
and b.status in ('pending', 'approved')
and b.starts_at <= ${now.toISOString()}
and b.ends_at > ${now.toISOString()}
)`,
),
);
res.json({ res.json({
activeRooms: roomCount?.c ?? 0, activeRooms: roomCount?.c ?? 0,
roomsAvailableNow: availableNow?.c ?? 0,
pendingBookings: pendingBookings?.c ?? 0, pendingBookings: pendingBookings?.c ?? 0,
todayBookings: todayBookings?.c ?? 0, todayBookings: todayBookings?.c ?? 0,
upcomingExternalMeetings: upcomingExternal?.c ?? 0, upcomingExternalMeetings: upcomingExternal?.c ?? 0,
giftCatalogCount: giftStock?.kinds ?? 0, giftCatalogCount: giftStock?.kinds ?? 0,
totalGiftStock: giftStock?.totalStock ?? 0, totalGiftStock: giftStock?.totalStock ?? 0,
pendingGiftIssues: pendingIssues?.c ?? 0, pendingGiftIssues: pendingIssues?.c ?? 0,
giftsIssuedThisMonth: issuedThisMonth?.count ?? 0,
giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0,
}); });
}, },
); );
@@ -1429,7 +1590,35 @@ router.get(
) )
.orderBy(desc(sql`count(*)`)); .orderBy(desc(sql`count(*)`));
res.json({ bookingsByRoom, issuesByGift }); const externalRange = [];
if (from)
externalRange.push(gte(protocolExternalMeetingsTable.startsAt, from));
if (to) externalRange.push(lte(protocolExternalMeetingsTable.startsAt, to));
const [externalMeetings] = await db
.select({
total: sql<number>`count(*)::int`,
pending: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`,
approved: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`,
rejected: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`,
completed: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`,
cancelled: sql<number>`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'cancelled')::int`,
})
.from(protocolExternalMeetingsTable)
.where(externalRange.length > 0 ? and(...externalRange) : undefined);
res.json({
bookingsByRoom,
issuesByGift,
externalMeetings: externalMeetings ?? {
total: 0,
pending: 0,
approved: 0,
rejected: 0,
completed: 0,
cancelled: 0,
},
});
}, },
); );
+1
View File
@@ -75,6 +75,7 @@ function Router() {
<Route path="/orders/incoming" component={OrdersIncomingPage} /> <Route path="/orders/incoming" component={OrdersIncomingPage} />
<Route path="/meetings" component={ExecutiveMeetingsPage} /> <Route path="/meetings" component={ExecutiveMeetingsPage} />
<Route path="/protocol" component={ProtocolPage} /> <Route path="/protocol" component={ProtocolPage} />
<Route path="/protocol/:tab" component={ProtocolPage} />
{/* Back-compat redirect: the page used to live at {/* Back-compat redirect: the page used to live at
/executive-meetings. Stored PDFs, email links, and bookmarks /executive-meetings. Stored PDFs, email links, and bookmarks
still point there, so catch the old path (and any deep still point there, so catch the old path (and any deep
+14 -5
View File
@@ -1672,7 +1672,8 @@
"gifts": "الهدايا والدروع", "gifts": "الهدايا والدروع",
"issues": "طلبات الصرف", "issues": "طلبات الصرف",
"reports": "التقارير", "reports": "التقارير",
"audit": "سجل النشاط" "audit": "سجل النشاط",
"rooms": "القاعات"
}, },
"status": { "status": {
"pending": "قيد الانتظار", "pending": "قيد الانتظار",
@@ -1702,7 +1703,9 @@
"pendingBookings": "حجوزات بانتظار الاعتماد", "pendingBookings": "حجوزات بانتظار الاعتماد",
"upcomingExternal": "لقاءات خارجية قادمة", "upcomingExternal": "لقاءات خارجية قادمة",
"giftStock": "إجمالي مخزون الهدايا", "giftStock": "إجمالي مخزون الهدايا",
"pendingIssues": "طلبات صرف معلّقة" "pendingIssues": "طلبات صرف معلّقة",
"roomsAvailableNow": "القاعات المتاحة الآن",
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
}, },
"bookings": { "bookings": {
"new": "حجز جديد", "new": "حجز جديد",
@@ -1712,7 +1715,9 @@
"roomLabel": "القاعة", "roomLabel": "القاعة",
"titleLabel": "عنوان الحجز", "titleLabel": "عنوان الحجز",
"startsAt": "من", "startsAt": "من",
"endsAt": "إلى" "endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم"
}, },
"external": { "external": {
"new": "لقاء جديد", "new": "لقاء جديد",
@@ -1740,7 +1745,10 @@
"nameAr": "الاسم (عربي)", "nameAr": "الاسم (عربي)",
"nameEn": "الاسم (إنجليزي)", "nameEn": "الاسم (إنجليزي)",
"capacity": "السعة", "capacity": "السعة",
"location": "الموقع" "location": "الموقع",
"active": "مفعّلة",
"isActive": "مفعّلة",
"empty": "لا توجد قاعات."
}, },
"issues": { "issues": {
"new": "طلب صرف", "new": "طلب صرف",
@@ -1759,7 +1767,8 @@
"requests": "الطلبات", "requests": "الطلبات",
"issued": "المصروفة", "issued": "المصروفة",
"issuedQty": "الكمية المصروفة", "issuedQty": "الكمية المصروفة",
"empty": "لا توجد بيانات." "empty": "لا توجد بيانات.",
"byExternal": "اللقاءات الخارجية حسب الحالة"
}, },
"audit": { "audit": {
"empty": "لا يوجد نشاط." "empty": "لا يوجد نشاط."
+14 -5
View File
@@ -1545,7 +1545,8 @@
"gifts": "Gifts & Shields", "gifts": "Gifts & Shields",
"issues": "Issuance Requests", "issues": "Issuance Requests",
"reports": "Reports", "reports": "Reports",
"audit": "Activity Log" "audit": "Activity Log",
"rooms": "Rooms"
}, },
"status": { "status": {
"pending": "Pending", "pending": "Pending",
@@ -1575,7 +1576,9 @@
"pendingBookings": "Bookings awaiting approval", "pendingBookings": "Bookings awaiting approval",
"upcomingExternal": "Upcoming external meetings", "upcomingExternal": "Upcoming external meetings",
"giftStock": "Total gift stock", "giftStock": "Total gift stock",
"pendingIssues": "Pending issuance requests" "pendingIssues": "Pending issuance requests",
"roomsAvailableNow": "Rooms available now",
"giftsIssuedThisMonth": "Gifts issued this month"
}, },
"bookings": { "bookings": {
"new": "New booking", "new": "New booking",
@@ -1585,7 +1588,9 @@
"roomLabel": "Room", "roomLabel": "Room",
"titleLabel": "Booking title", "titleLabel": "Booking title",
"startsAt": "From", "startsAt": "From",
"endsAt": "To" "endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar"
}, },
"external": { "external": {
"new": "New meeting", "new": "New meeting",
@@ -1613,7 +1618,10 @@
"nameAr": "Name (Arabic)", "nameAr": "Name (Arabic)",
"nameEn": "Name (English)", "nameEn": "Name (English)",
"capacity": "Capacity", "capacity": "Capacity",
"location": "Location" "location": "Location",
"active": "Active",
"isActive": "Active",
"empty": "No rooms."
}, },
"issues": { "issues": {
"new": "Issuance request", "new": "Issuance request",
@@ -1632,7 +1640,8 @@
"requests": "Requests", "requests": "Requests",
"issued": "Issued", "issued": "Issued",
"issuedQty": "Issued qty", "issuedQty": "Issued qty",
"empty": "No data." "empty": "No data.",
"byExternal": "External meetings by status"
}, },
"audit": { "audit": {
"empty": "No activity yet." "empty": "No activity yet."
+435 -198
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "wouter"; import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
@@ -18,6 +18,8 @@ import {
Check, Check,
X, X,
PackageCheck, PackageCheck,
CalendarDays,
List as ListIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -96,8 +98,9 @@ type ExternalMeeting = {
location: string | null; location: string | null;
startsAt: string; startsAt: string;
endsAt: string | null; endsAt: string | null;
status: "scheduled" | "completed" | "cancelled"; status: "pending" | "approved" | "rejected" | "completed" | "cancelled";
notes: string | null; notes: string | null;
rejectionReason: string | null;
}; };
type GiftKind = "gift" | "shield"; type GiftKind = "gift" | "shield";
@@ -126,12 +129,15 @@ type GiftIssue = {
type Dashboard = { type Dashboard = {
activeRooms: number; activeRooms: number;
roomsAvailableNow: number;
pendingBookings: number; pendingBookings: number;
todayBookings: number; todayBookings: number;
upcomingExternalMeetings: number; upcomingExternalMeetings: number;
giftCatalogCount: number; giftCatalogCount: number;
totalGiftStock: number; totalGiftStock: number;
pendingGiftIssues: number; pendingGiftIssues: number;
giftsIssuedThisMonth: number;
giftsIssuedThisMonthQty: number;
}; };
type ReportRow = { type ReportRow = {
@@ -156,10 +162,19 @@ type ReportRow = {
pending: number; pending: number;
rejected: number; rejected: number;
}>; }>;
externalMeetings: {
total: number;
pending: number;
approved: number;
rejected: number;
completed: number;
cancelled: number;
};
}; };
type TabKey = type TabKey =
| "dashboard" | "dashboard"
| "rooms"
| "bookings" | "bookings"
| "external" | "external"
| "gifts" | "gifts"
@@ -167,6 +182,21 @@ type TabKey =
| "reports" | "reports"
| "audit"; | "audit";
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
const TAB_SLUGS: Record<TabKey, string> = {
dashboard: "dashboard",
rooms: "rooms",
bookings: "bookings",
external: "external-meetings",
gifts: "gifts",
issues: "gift-issues",
reports: "reports",
audit: "audit",
};
const SLUG_TABS: Record<string, TabKey> = Object.fromEntries(
Object.entries(TAB_SLUGS).map(([k, v]) => [v, k as TabKey]),
) as Record<string, TabKey>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Small helpers // Small helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -182,6 +212,36 @@ function fromLocalInput(v: string): string {
return new Date(v).toISOString(); return new Date(v).toISOString();
} }
// Group bookings by calendar day (ascending) for the calendar view.
function groupBookingsByDay(
items: Booking[],
isAr: boolean,
): Array<{ key: string; label: string; items: Booking[] }> {
const map = new Map<string, Booking[]>();
for (const b of items) {
const d = new Date(b.startsAt);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
2,
"0",
)}-${String(d.getDate()).padStart(2, "0")}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(b);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, group]) => ({
key,
label: new Date(`${key}T00:00:00`).toLocaleDateString(
isAr ? "ar" : "en",
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
),
items: group.sort(
(a, b) =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
),
}));
}
const STATUS_PILL: Record<string, string> = { const STATUS_PILL: Record<string, string> = {
pending: "bg-amber-100 text-amber-800", pending: "bg-amber-100 text-amber-800",
approved: "bg-emerald-100 text-emerald-700", approved: "bg-emerald-100 text-emerald-700",
@@ -199,7 +259,10 @@ export default function ProtocolPage() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const [tab, setTab] = useState<TabKey>("dashboard"); const [matchTab, tabParams] = useRoute("/protocol/:tab");
const tab: TabKey =
(matchTab && SLUG_TABS[tabParams?.tab ?? ""]) || "dashboard";
const setTab = (k: TabKey) => setLocation(`/protocol/${TAB_SLUGS[k]}`);
const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar); const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar);
@@ -290,9 +353,10 @@ export default function ProtocolPage() {
edit?: Booking; edit?: Booking;
}>({ open: false }); }>({ open: false });
const [rejectTarget, setRejectTarget] = useState<{ const [rejectTarget, setRejectTarget] = useState<{
kind: "booking" | "issue"; kind: "booking" | "issue" | "external";
id: number; id: number;
} | null>(null); } | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [deleteTarget, setDeleteTarget] = useState< const [deleteTarget, setDeleteTarget] = useState<
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number } | { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
| null | null
@@ -323,19 +387,33 @@ export default function ProtocolPage() {
onError: onApiError, onError: onApiError,
}); });
const rejectMut = useMutation({ const rejectMut = useMutation({
mutationFn: (args: { kind: "booking" | "issue"; id: number }) => mutationFn: (args: { kind: "booking" | "issue" | "external"; id: number }) => {
apiJson( const path =
`${API}/protocol/${ args.kind === "booking"
args.kind === "booking" ? "bookings" : "gift-issues" ? "bookings"
}/${args.id}/reject`, : args.kind === "issue"
{ method: "POST", body: JSON.stringify({}) }, ? "gift-issues"
), : "external-meetings";
return apiJson(`${API}/protocol/${path}/${args.id}/reject`, {
method: "POST",
body: JSON.stringify({}),
});
},
onSuccess: () => { onSuccess: () => {
setRejectTarget(null); setRejectTarget(null);
invalidate("bookings", "issues"); invalidate("bookings", "issues", "external");
}, },
onError: onApiError, onError: onApiError,
}); });
const externalApprove = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/external-meetings/${id}/approve`, {
method: "POST",
body: JSON.stringify({}),
}),
onSuccess: () => invalidate("external"),
onError: onApiError,
});
const issueApprove = useMutation({ const issueApprove = useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
apiJson(`${API}/protocol/gift-issues/${id}/approve`, { apiJson(`${API}/protocol/gift-issues/${id}/approve`, {
@@ -375,7 +453,8 @@ export default function ProtocolPage() {
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [ const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard }, { key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: DoorOpen }, { key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake }, { key: "external", label: t("protocol.tabs.external"), icon: Handshake },
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift }, { key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift },
{ key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck }, { key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck },
@@ -387,6 +466,90 @@ export default function ProtocolPage() {
const BackIcon = isAr ? ArrowRight : ArrowLeft; const BackIcon = isAr ? ArrowRight : ArrowLeft;
const renderBookingCard = (b: Booking) => (
<div
key={b.id}
className="bg-white rounded-xl border border-slate-200 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium text-slate-800">{b.title}</div>
<div className="text-sm text-slate-500">
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "}
{fmtDateTime(b.endsAt)}
</div>
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[b.status],
)}
>
{t(`protocol.status.${b.status}`)}
</span>
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && b.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "approve" })
}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectTarget({ kind: "booking", id: b.id })}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
{t("protocol.actions.cancel")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setBookingDialog({ open: true, edit: b })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteTarget({ kind: "booking", id: b.id })}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div>
);
return ( return (
<div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}> <div className="min-h-screen bg-slate-50" dir={isAr ? "rtl" : "ltr"}>
<header className="sticky top-0 z-10 bg-white border-b border-slate-200"> <header className="sticky top-0 z-10 bg-white border-b border-slate-200">
@@ -424,7 +587,7 @@ export default function ProtocolPage() {
<main className="max-w-5xl mx-auto px-4 py-5"> <main className="max-w-5xl mx-auto px-4 py-5">
{tab === "dashboard" && ( {tab === "dashboard" && (
<DashboardView data={dashboard.data} t={t} /> <DashboardView data={dashboard.data} t={t} goTab={setTab} />
)} )}
{tab === "bookings" && ( {tab === "bookings" && (
@@ -433,108 +596,135 @@ export default function ProtocolPage() {
<h2 className="font-semibold text-slate-700"> <h2 className="font-semibold text-slate-700">
{t("protocol.tabs.bookings")} {t("protocol.tabs.bookings")}
</h2> </h2>
{c?.canRequest && ( <div className="flex items-center gap-2">
<Button <div className="flex rounded-lg border border-slate-200 overflow-hidden">
size="sm" <button
onClick={() => setBookingDialog({ open: true })} type="button"
> onClick={() => setBookingsView("list")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "list"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<ListIcon size={14} />
{t("protocol.bookings.viewList")}
</button>
<button
type="button"
onClick={() => setBookingsView("calendar")}
className={cn(
"px-2.5 py-1.5 text-xs flex items-center gap-1",
bookingsView === "calendar"
? "bg-sky-500 text-white"
: "text-slate-600 hover:bg-slate-100",
)}
>
<CalendarDays size={14} />
{t("protocol.bookings.viewCalendar")}
</button>
</div>
{c?.canRequest && (
<Button
size="sm"
onClick={() => setBookingDialog({ open: true })}
>
<Plus size={16} className="me-1" />
{t("protocol.bookings.new")}
</Button>
)}
</div>
</div>
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
)}
{bookingsView === "list" ? (
<div className="grid gap-2">
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
</div>
) : (
<div className="space-y-4">
{groupBookingsByDay(bookings.data ?? [], isAr).map((group) => (
<div key={group.key}>
<div className="text-sm font-semibold text-slate-600 mb-2 sticky top-16 bg-slate-50 py-1">
{group.label}
</div>
<div className="grid gap-2">
{group.items.map((b) => renderBookingCard(b))}
</div>
</div>
))}
</div>
)}
</section>
)}
{tab === "rooms" && (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.rooms")}
</h2>
{c?.canManageRooms && (
<Button size="sm" onClick={() => setRoomDialog({ open: true })}>
<Plus size={16} className="me-1" /> <Plus size={16} className="me-1" />
{t("protocol.bookings.new")} {t("protocol.rooms.new")}
</Button> </Button>
)} )}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2 sm:grid-cols-2">
{(bookings.data ?? []).map((b) => ( {(rooms.data ?? []).map((r) => (
<div <div
key={b.id} key={r.id}
className="bg-white rounded-xl border border-slate-200 p-3" className="bg-white rounded-xl border border-slate-200 p-3"
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium text-slate-800">{b.title}</div> <div className="font-medium text-slate-800">
<div className="text-sm text-slate-500"> {nameOf(r.nameAr, r.nameEn)}
{roomName(b.roomId)} · {fmtDateTime(b.startsAt)} {" "} </div>
{fmtDateTime(b.endsAt)} <div className="text-xs text-slate-500">
{r.capacity
? `${t("protocol.rooms.capacity")}: ${r.capacity}`
: ""}
</div> </div>
{b.requesterName && (
<div className="text-xs text-slate-400">
{t("protocol.bookings.requester")}: {b.requesterName}
</div>
)}
</div> </div>
<span <span
className={cn( className={cn(
"text-xs px-2 py-0.5 rounded-full shrink-0", "text-xs px-2 py-0.5 rounded-full shrink-0",
STATUS_PILL[b.status], r.isActive
? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500",
)} )}
> >
{t(`protocol.status.${b.status}`)} {r.isActive
? t("protocol.rooms.active")
: t("protocol.rooms.inactive")}
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-1.5 mt-2"> {c?.canManageRooms && (
{c?.canApprove && b.status === "pending" && ( <div className="flex gap-1.5 mt-2">
<> <Button
<Button size="sm"
size="sm" variant="ghost"
variant="outline" onClick={() => setRoomDialog({ open: true, edit: r })}
onClick={() => >
bookingAction.mutate({ id: b.id, action: "approve" }) <Pencil size={14} />
} </Button>
> <Button
<Check size={14} className="me-1" /> size="sm"
{t("protocol.actions.approve")} variant="ghost"
</Button> onClick={() => setDeleteTarget({ kind: "room", id: r.id })}
<Button >
size="sm" <Trash2 size={14} className="text-rose-500" />
variant="outline" </Button>
onClick={() => </div>
setRejectTarget({ kind: "booking", id: b.id }) )}
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate &&
(b.status === "pending" || b.status === "approved") && (
<Button
size="sm"
variant="ghost"
onClick={() =>
bookingAction.mutate({ id: b.id, action: "cancel" })
}
>
{t("protocol.actions.cancel")}
</Button>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBookingDialog({ open: true, edit: b })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "booking", id: b.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div> </div>
))} ))}
{bookings.data?.length === 0 && ( {rooms.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} /> <EmptyState text={t("protocol.rooms.empty")} />
)} )}
</div> </div>
</section> </section>
@@ -579,28 +769,57 @@ export default function ProtocolPage() {
{t(`protocol.status.${m.status}`)} {t(`protocol.status.${m.status}`)}
</span> </span>
</div> </div>
{c?.canMutate && ( {m.status === "rejected" && m.rejectionReason && (
<div className="flex gap-1.5 mt-2"> <div className="text-xs text-rose-500 mt-1">
<Button {t("protocol.actions.reject")}: {m.rejectionReason}
size="sm"
variant="ghost"
onClick={() =>
setExternalDialog({ open: true, edit: m })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "external", id: m.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</div> </div>
)} )}
<div className="flex flex-wrap gap-1.5 mt-2">
{c?.canApprove && m.status === "pending" && (
<>
<Button
size="sm"
variant="outline"
onClick={() => externalApprove.mutate(m.id)}
>
<Check size={14} className="me-1" />
{t("protocol.actions.approve")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRejectTarget({ kind: "external", id: m.id })
}
>
<X size={14} className="me-1" />
{t("protocol.actions.reject")}
</Button>
</>
)}
{c?.canMutate && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setExternalDialog({ open: true, edit: m })
}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "external", id: m.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</div>
</div> </div>
))} ))}
{external.data?.length === 0 && ( {external.data?.length === 0 && (
@@ -617,16 +836,6 @@ export default function ProtocolPage() {
{t("protocol.tabs.gifts")} {t("protocol.tabs.gifts")}
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
{c?.canManageRooms && (
<Button
size="sm"
variant="outline"
onClick={() => setRoomDialog({ open: true })}
>
<DoorOpen size={16} className="me-1" />
{t("protocol.rooms.new")}
</Button>
)}
{c?.canMutate && ( {c?.canMutate && (
<Button size="sm" onClick={() => setGiftDialog({ open: true })}> <Button size="sm" onClick={() => setGiftDialog({ open: true })}>
<Plus size={16} className="me-1" /> <Plus size={16} className="me-1" />
@@ -636,46 +845,6 @@ export default function ProtocolPage() {
</div> </div>
</div> </div>
{c?.canManageRooms && (
<div className="bg-white rounded-xl border border-slate-200 p-3">
<div className="text-sm font-semibold text-slate-600 mb-2">
{t("protocol.rooms.title")}
</div>
<div className="grid gap-1.5">
{(rooms.data ?? []).map((r) => (
<div
key={r.id}
className="flex items-center justify-between text-sm"
>
<span className="text-slate-700">
{nameOf(r.nameAr, r.nameEn)}
{r.capacity ? ` · ${r.capacity}` : ""}
{!r.isActive ? ` · ${t("protocol.rooms.inactive")}` : ""}
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setRoomDialog({ open: true, edit: r })}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() =>
setDeleteTarget({ kind: "room", id: r.id })
}
>
<Trash2 size={14} className="text-rose-500" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{(gifts.data ?? []).map((g) => ( {(gifts.data ?? []).map((g) => (
<div <div
@@ -960,40 +1129,66 @@ function EmptyState({ text }: { text: string }) {
function DashboardView({ function DashboardView({
data, data,
t, t,
goTab,
}: { }: {
data: Dashboard | undefined; data: Dashboard | undefined;
t: (k: string) => string; t: (k: string) => string;
goTab: (k: TabKey) => void;
}) { }) {
const cards = [ const cards: Array<{ label: string; value?: number; tab: TabKey }> = [
{ label: t("protocol.dashboard.activeRooms"), value: data?.activeRooms }, {
{ label: t("protocol.dashboard.todayBookings"), value: data?.todayBookings }, label: t("protocol.dashboard.roomsAvailableNow"),
value: data?.roomsAvailableNow,
tab: "rooms",
},
{
label: t("protocol.dashboard.activeRooms"),
value: data?.activeRooms,
tab: "rooms",
},
{
label: t("protocol.dashboard.todayBookings"),
value: data?.todayBookings,
tab: "bookings",
},
{ {
label: t("protocol.dashboard.pendingBookings"), label: t("protocol.dashboard.pendingBookings"),
value: data?.pendingBookings, value: data?.pendingBookings,
tab: "bookings",
}, },
{ {
label: t("protocol.dashboard.upcomingExternal"), label: t("protocol.dashboard.upcomingExternal"),
value: data?.upcomingExternalMeetings, value: data?.upcomingExternalMeetings,
tab: "external",
}, },
{ {
label: t("protocol.dashboard.giftStock"), label: t("protocol.dashboard.giftStock"),
value: data?.totalGiftStock, value: data?.totalGiftStock,
tab: "gifts",
}, },
{ {
label: t("protocol.dashboard.pendingIssues"), label: t("protocol.dashboard.pendingIssues"),
value: data?.pendingGiftIssues, value: data?.pendingGiftIssues,
tab: "issues",
},
{
label: t("protocol.dashboard.giftsIssuedThisMonth"),
value: data?.giftsIssuedThisMonth,
tab: "issues",
}, },
]; ];
return ( return (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3"> <div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
{cards.map((c) => ( {cards.map((c) => (
<div <button
key={c.label} key={c.label}
className="bg-white rounded-2xl border border-slate-200 p-4" type="button"
onClick={() => goTab(c.tab)}
className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition"
> >
<div className="text-3xl font-bold text-sky-600">{c.value ?? "—"}</div> <div className="text-3xl font-bold text-sky-600">{c.value ?? "—"}</div>
<div className="text-sm text-slate-500 mt-1">{c.label}</div> <div className="text-sm text-slate-500 mt-1">{c.label}</div>
</div> </button>
))} ))}
</div> </div>
); );
@@ -1083,6 +1278,34 @@ function ReportsView({
</table> </table>
</div> </div>
</div> </div>
<div>
<h3 className="font-semibold text-slate-700 mb-2">
{t("protocol.reports.byExternal")}
</h3>
<div className="grid gap-2 grid-cols-3 sm:grid-cols-6">
{(
[
["total", "protocol.reports.total"],
["pending", "protocol.status.pending"],
["approved", "protocol.status.approved"],
["rejected", "protocol.status.rejected"],
["completed", "protocol.status.completed"],
["cancelled", "protocol.status.cancelled"],
] as const
).map(([key, label]) => (
<div
key={key}
className="bg-white rounded-xl border border-slate-200 p-3 text-center"
>
<div className="text-2xl font-bold text-sky-600">
{data?.externalMeetings?.[key] ?? "—"}
</div>
<div className="text-xs text-slate-500 mt-1">{t(label)}</div>
</div>
))}
</div>
</div>
</div> </div>
); );
} }
@@ -1262,6 +1485,7 @@ function RoomDialog({
edit?.capacity != null ? String(edit.capacity) : "", edit?.capacity != null ? String(edit.capacity) : "",
); );
const [location, setLocation] = useState(edit?.location ?? ""); const [location, setLocation] = useState(edit?.location ?? "");
const [isActive, setIsActive] = useState(edit?.isActive ?? true);
const mut = useMutation({ const mut = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -1270,6 +1494,7 @@ function RoomDialog({
nameEn, nameEn,
capacity: capacity ? Number(capacity) : null, capacity: capacity ? Number(capacity) : null,
location: location || null, location: location || null,
isActive,
}; };
return edit return edit
? apiJson(`${API}/protocol/rooms/${edit.id}`, { ? apiJson(`${API}/protocol/rooms/${edit.id}`, {
@@ -1315,6 +1540,15 @@ function RoomDialog({
<Input value={location} onChange={(e) => setLocation(e.target.value)} /> <Input value={location} onChange={(e) => setLocation(e.target.value)} />
</div> </div>
</div> </div>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
{t("protocol.rooms.isActive")}
</label>
</DialogShell> </DialogShell>
); );
} }
@@ -1337,21 +1571,23 @@ function ExternalDialog({
const [startsAt, setStartsAt] = useState( const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : "", edit ? toLocalInput(edit.startsAt) : "",
); );
const [status, setStatus] = useState<ExternalMeeting["status"]>( const [status, setStatus] = useState<"completed" | "cancelled" | "">(
edit?.status ?? "scheduled", edit?.status === "completed" || edit?.status === "cancelled"
? edit.status
: "",
); );
const [notes, setNotes] = useState(edit?.notes ?? ""); const [notes, setNotes] = useState(edit?.notes ?? "");
const mut = useMutation({ const mut = useMutation({
mutationFn: () => { mutationFn: () => {
const body = { const body: Record<string, unknown> = {
title, title,
partyName: partyName || null, partyName: partyName || null,
location: location || null, location: location || null,
startsAt: fromLocalInput(startsAt), startsAt: fromLocalInput(startsAt),
status,
notes: notes || null, notes: notes || null,
}; };
if (edit && status) body.status = status;
return edit return edit
? apiJson(`${API}/protocol/external-meetings/${edit.id}`, { ? apiJson(`${API}/protocol/external-meetings/${edit.id}`, {
method: "PATCH", method: "PATCH",
@@ -1396,28 +1632,29 @@ function ExternalDialog({
required required
/> />
</div> </div>
<div> {edit && (
<Label>{t("protocol.statusLabel")}</Label> <div>
<Select <Label>{t("protocol.statusLabel")}</Label>
value={status} <Select
onValueChange={(v) => setStatus(v as ExternalMeeting["status"])} value={status}
> onValueChange={(v) =>
<SelectTrigger> setStatus(v as "completed" | "cancelled")
<SelectValue /> }
</SelectTrigger> >
<SelectContent> <SelectTrigger>
<SelectItem value="scheduled"> <SelectValue placeholder={t("protocol.status.pending")} />
{t("protocol.status.scheduled")} </SelectTrigger>
</SelectItem> <SelectContent>
<SelectItem value="completed"> <SelectItem value="completed">
{t("protocol.status.completed")} {t("protocol.status.completed")}
</SelectItem> </SelectItem>
<SelectItem value="cancelled"> <SelectItem value="cancelled">
{t("protocol.status.cancelled")} {t("protocol.status.cancelled")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
</div> </div>
<div> <div>
<Label>{t("protocol.notes")}</Label> <Label>{t("protocol.notes")}</Label>
+9 -1
View File
@@ -131,11 +131,18 @@ export const protocolExternalMeetingsTable = pgTable(
location: varchar("location", { length: 300 }), location: varchar("location", { length: 300 }),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(), startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }), endsAt: timestamp("ends_at", { withTimezone: true }),
status: varchar("status", { length: 20 }).notNull().default("scheduled"), // 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"), notes: text("notes"),
createdBy: integer("created_by").references(() => usersTable.id, { createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null", 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 }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -146,6 +153,7 @@ export const protocolExternalMeetingsTable = pgTable(
}, },
(t) => ({ (t) => ({
startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt), startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt),
statusIdx: index("protocol_ext_meetings_status_idx").on(t.status),
}), }),
); );
+15 -3
View File
@@ -878,9 +878,21 @@ async function main() {
.limit(1); .limit(1);
if (existingRooms.length === 0) { if (existingRooms.length === 0) {
await db.insert(protocolRoomsTable).values([ await db.insert(protocolRoomsTable).values([
{ nameAr: "القاعة الأولى", nameEn: "Room 1", sortOrder: 1 }, {
{ nameAr: "القاعة الثانية", nameEn: "Room 2", sortOrder: 2 }, nameAr: "قاعة الاجتماعات الرئيسية",
{ nameAr: "القاعة الثالثة", nameEn: "Room 3", sortOrder: 3 }, 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"); console.log("Protocol default rooms created");
} }