From 5e460920c749b840e6b1d9e8479df802fafb1af9 Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Mon, 6 Jul 2026 09:46:12 +0000 Subject: [PATCH] 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. --- artifacts/api-server/src/routes/protocol.ts | 205 ++++++- artifacts/tx-os/src/App.tsx | 1 + artifacts/tx-os/src/locales/ar.json | 19 +- artifacts/tx-os/src/locales/en.json | 19 +- artifacts/tx-os/src/pages/protocol.tsx | 633 ++++++++++++++------ lib/db/src/schema/protocol.ts | 10 +- scripts/src/seed.ts | 18 +- 7 files changed, 685 insertions(+), 220 deletions(-) diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index d727ac4a..623a7500 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -1,5 +1,5 @@ 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 { db } from "@workspace/db"; import { @@ -169,7 +169,6 @@ const externalCreateSchema = z location: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime, endsAt: isoDateTime.nullable().optional(), - status: z.enum(["scheduled", "completed", "cancelled"]).optional(), notes: z.string().trim().max(5000).nullable().optional(), }) .refine( @@ -177,6 +176,9 @@ const externalCreateSchema = z { 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 .object({ title: z.string().trim().min(1).max(500).optional(), @@ -184,7 +186,7 @@ const externalPatchSchema = z location: z.string().trim().max(300).nullable().optional(), startsAt: isoDateTime.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(), }) .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" }); return; } + // Only a pending booking can be rejected; the state-conditional update + // makes this atomic against a concurrent approve/cancel. const [row] = await db .update(protocolRoomBookingsTable) .set({ @@ -730,8 +734,19 @@ router.post( approvedAt: new Date(), rejectionReason: body.reason ?? null, }) - .where(eq(protocolRoomBookingsTable.id, id)) + .where( + and( + eq(protocolRoomBookingsTable.id, id), + eq(protocolRoomBookingsTable.status, "pending"), + ), + ) .returning(); + if (!row) { + res + .status(409) + .json({ error: "booking is not pending", code: "invalid_status" }); + return; + } await logAudit(db, { action: "booking.reject", entityType: "booking", @@ -830,7 +845,7 @@ router.post( location: body.location ?? null, startsAt: new Date(body.startsAt), endsAt: body.endsAt ? new Date(body.endsAt) : null, - status: body.status ?? "scheduled", + status: "pending", notes: body.notes ?? null, 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 // --------------------------------------------------------------------------- @@ -1234,6 +1344,8 @@ router.post( res.status(404).json({ error: "Not found", code: "not_found" }); return; } + // Only a pending issue can be rejected; the state-conditional update + // makes this atomic against a concurrent approve. const [row] = await db .update(protocolGiftIssuesTable) .set({ @@ -1242,8 +1354,19 @@ router.post( approvedAt: new Date(), rejectionReason: body.reason ?? null, }) - .where(eq(protocolGiftIssuesTable.id, id)) + .where( + and( + eq(protocolGiftIssuesTable.id, id), + eq(protocolGiftIssuesTable.status, "pending"), + ), + ) .returning(); + if (!row) { + res + .status(409) + .json({ error: "issue is not pending", code: "invalid_status" }); + return; + } await logAudit(db, { action: "gift_issue.reject", entityType: "gift_issue", @@ -1297,6 +1420,8 @@ router.get( now.getDate(), ); 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 .select({ c: sql`count(*)::int` }) @@ -1324,7 +1449,7 @@ router.get( .from(protocolExternalMeetingsTable) .where( and( - eq(protocolExternalMeetingsTable.status, "scheduled"), + inArray(protocolExternalMeetingsTable.status, ["pending", "approved"]), gte(protocolExternalMeetingsTable.startsAt, now), ), ); @@ -1342,14 +1467,50 @@ router.get( .from(protocolGiftIssuesTable) .where(eq(protocolGiftIssuesTable.status, "pending")); + // Gifts/shields actually issued during the current calendar month. + const [issuedThisMonth] = await db + .select({ + count: sql`count(*)::int`, + qty: sql`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`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({ activeRooms: roomCount?.c ?? 0, + roomsAvailableNow: availableNow?.c ?? 0, pendingBookings: pendingBookings?.c ?? 0, todayBookings: todayBookings?.c ?? 0, upcomingExternalMeetings: upcomingExternal?.c ?? 0, giftCatalogCount: giftStock?.kinds ?? 0, totalGiftStock: giftStock?.totalStock ?? 0, pendingGiftIssues: pendingIssues?.c ?? 0, + giftsIssuedThisMonth: issuedThisMonth?.count ?? 0, + giftsIssuedThisMonthQty: issuedThisMonth?.qty ?? 0, }); }, ); @@ -1429,7 +1590,35 @@ router.get( ) .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`count(*)::int`, + pending: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'pending')::int`, + approved: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'approved')::int`, + rejected: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'rejected')::int`, + completed: sql`count(*) filter (where ${protocolExternalMeetingsTable.status} = 'completed')::int`, + cancelled: sql`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, + }, + }); }, ); diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index ecc2eced..373171ac 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -75,6 +75,7 @@ function Router() { + {/* 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 diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index a9a25b23..380b0602 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1672,7 +1672,8 @@ "gifts": "الهدايا والدروع", "issues": "طلبات الصرف", "reports": "التقارير", - "audit": "سجل النشاط" + "audit": "سجل النشاط", + "rooms": "القاعات" }, "status": { "pending": "قيد الانتظار", @@ -1702,7 +1703,9 @@ "pendingBookings": "حجوزات بانتظار الاعتماد", "upcomingExternal": "لقاءات خارجية قادمة", "giftStock": "إجمالي مخزون الهدايا", - "pendingIssues": "طلبات صرف معلّقة" + "pendingIssues": "طلبات صرف معلّقة", + "roomsAvailableNow": "القاعات المتاحة الآن", + "giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر" }, "bookings": { "new": "حجز جديد", @@ -1712,7 +1715,9 @@ "roomLabel": "القاعة", "titleLabel": "عنوان الحجز", "startsAt": "من", - "endsAt": "إلى" + "endsAt": "إلى", + "viewList": "قائمة", + "viewCalendar": "تقويم" }, "external": { "new": "لقاء جديد", @@ -1740,7 +1745,10 @@ "nameAr": "الاسم (عربي)", "nameEn": "الاسم (إنجليزي)", "capacity": "السعة", - "location": "الموقع" + "location": "الموقع", + "active": "مفعّلة", + "isActive": "مفعّلة", + "empty": "لا توجد قاعات." }, "issues": { "new": "طلب صرف", @@ -1759,7 +1767,8 @@ "requests": "الطلبات", "issued": "المصروفة", "issuedQty": "الكمية المصروفة", - "empty": "لا توجد بيانات." + "empty": "لا توجد بيانات.", + "byExternal": "اللقاءات الخارجية حسب الحالة" }, "audit": { "empty": "لا يوجد نشاط." diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 8b3b7400..3bb4acc9 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1545,7 +1545,8 @@ "gifts": "Gifts & Shields", "issues": "Issuance Requests", "reports": "Reports", - "audit": "Activity Log" + "audit": "Activity Log", + "rooms": "Rooms" }, "status": { "pending": "Pending", @@ -1575,7 +1576,9 @@ "pendingBookings": "Bookings awaiting approval", "upcomingExternal": "Upcoming external meetings", "giftStock": "Total gift stock", - "pendingIssues": "Pending issuance requests" + "pendingIssues": "Pending issuance requests", + "roomsAvailableNow": "Rooms available now", + "giftsIssuedThisMonth": "Gifts issued this month" }, "bookings": { "new": "New booking", @@ -1585,7 +1588,9 @@ "roomLabel": "Room", "titleLabel": "Booking title", "startsAt": "From", - "endsAt": "To" + "endsAt": "To", + "viewList": "List", + "viewCalendar": "Calendar" }, "external": { "new": "New meeting", @@ -1613,7 +1618,10 @@ "nameAr": "Name (Arabic)", "nameEn": "Name (English)", "capacity": "Capacity", - "location": "Location" + "location": "Location", + "active": "Active", + "isActive": "Active", + "empty": "No rooms." }, "issues": { "new": "Issuance request", @@ -1632,7 +1640,8 @@ "requests": "Requests", "issued": "Issued", "issuedQty": "Issued qty", - "empty": "No data." + "empty": "No data.", + "byExternal": "External meetings by status" }, "audit": { "empty": "No activity yet." diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 3daa52aa..03cf3f50 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation } from "wouter"; +import { useLocation, useRoute } from "wouter"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, @@ -18,6 +18,8 @@ import { Check, X, PackageCheck, + CalendarDays, + List as ListIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -96,8 +98,9 @@ type ExternalMeeting = { location: string | null; startsAt: string; endsAt: string | null; - status: "scheduled" | "completed" | "cancelled"; + status: "pending" | "approved" | "rejected" | "completed" | "cancelled"; notes: string | null; + rejectionReason: string | null; }; type GiftKind = "gift" | "shield"; @@ -126,12 +129,15 @@ type GiftIssue = { type Dashboard = { activeRooms: number; + roomsAvailableNow: number; pendingBookings: number; todayBookings: number; upcomingExternalMeetings: number; giftCatalogCount: number; totalGiftStock: number; pendingGiftIssues: number; + giftsIssuedThisMonth: number; + giftsIssuedThisMonthQty: number; }; type ReportRow = { @@ -156,10 +162,19 @@ type ReportRow = { pending: number; rejected: number; }>; + externalMeetings: { + total: number; + pending: number; + approved: number; + rejected: number; + completed: number; + cancelled: number; + }; }; type TabKey = | "dashboard" + | "rooms" | "bookings" | "external" | "gifts" @@ -167,6 +182,21 @@ type TabKey = | "reports" | "audit"; +// URL slugs for each tab so pages are deep-linkable under /protocol/*. +const TAB_SLUGS: Record = { + dashboard: "dashboard", + rooms: "rooms", + bookings: "bookings", + external: "external-meetings", + gifts: "gifts", + issues: "gift-issues", + reports: "reports", + audit: "audit", +}; +const SLUG_TABS: Record = Object.fromEntries( + Object.entries(TAB_SLUGS).map(([k, v]) => [v, k as TabKey]), +) as Record; + // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- @@ -182,6 +212,36 @@ function fromLocalInput(v: string): string { 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(); + 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 = { pending: "bg-amber-100 text-amber-800", approved: "bg-emerald-100 text-emerald-700", @@ -199,7 +259,10 @@ export default function ProtocolPage() { const [, setLocation] = useLocation(); const { toast } = useToast(); const qc = useQueryClient(); - const [tab, setTab] = useState("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); @@ -290,9 +353,10 @@ export default function ProtocolPage() { edit?: Booking; }>({ open: false }); const [rejectTarget, setRejectTarget] = useState<{ - kind: "booking" | "issue"; + kind: "booking" | "issue" | "external"; id: number; } | null>(null); + const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list"); const [deleteTarget, setDeleteTarget] = useState< | { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number } | null @@ -323,19 +387,33 @@ export default function ProtocolPage() { onError: onApiError, }); const rejectMut = useMutation({ - mutationFn: (args: { kind: "booking" | "issue"; id: number }) => - apiJson( - `${API}/protocol/${ - args.kind === "booking" ? "bookings" : "gift-issues" - }/${args.id}/reject`, - { method: "POST", body: JSON.stringify({}) }, - ), + mutationFn: (args: { kind: "booking" | "issue" | "external"; id: number }) => { + const path = + args.kind === "booking" + ? "bookings" + : args.kind === "issue" + ? "gift-issues" + : "external-meetings"; + return apiJson(`${API}/protocol/${path}/${args.id}/reject`, { + method: "POST", + body: JSON.stringify({}), + }); + }, onSuccess: () => { setRejectTarget(null); - invalidate("bookings", "issues"); + invalidate("bookings", "issues", "external"); }, 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({ mutationFn: (id: number) => 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 }> = [ { 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: "gifts", label: t("protocol.tabs.gifts"), icon: Gift }, { key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck }, @@ -387,6 +466,90 @@ export default function ProtocolPage() { const BackIcon = isAr ? ArrowRight : ArrowLeft; + const renderBookingCard = (b: Booking) => ( +
+
+
+
{b.title}
+
+ {roomName(b.roomId)} · {fmtDateTime(b.startsAt)} –{" "} + {fmtDateTime(b.endsAt)} +
+ {b.requesterName && ( +
+ {t("protocol.bookings.requester")}: {b.requesterName} +
+ )} +
+ + {t(`protocol.status.${b.status}`)} + +
+
+ {c?.canApprove && b.status === "pending" && ( + <> + + + + )} + {c?.canMutate && + (b.status === "pending" || b.status === "approved") && ( + + )} + {c?.canMutate && ( + <> + + + + )} +
+
+ ); + return (
@@ -424,7 +587,7 @@ export default function ProtocolPage() {
{tab === "dashboard" && ( - + )} {tab === "bookings" && ( @@ -433,108 +596,135 @@ export default function ProtocolPage() {

{t("protocol.tabs.bookings")}

- {c?.canRequest && ( - + +
+ {c?.canRequest && ( + + )} + + + {bookings.data?.length === 0 && ( + + )} + {bookingsView === "list" ? ( +
+ {(bookings.data ?? []).map((b) => renderBookingCard(b))} +
+ ) : ( +
+ {groupBookingsByDay(bookings.data ?? [], isAr).map((group) => ( +
+
+ {group.label} +
+
+ {group.items.map((b) => renderBookingCard(b))} +
+
+ ))} +
+ )} + + )} + + {tab === "rooms" && ( +
+
+

+ {t("protocol.tabs.rooms")} +

+ {c?.canManageRooms && ( + )}
-
- {(bookings.data ?? []).map((b) => ( +
+ {(rooms.data ?? []).map((r) => (
-
{b.title}
-
- {roomName(b.roomId)} · {fmtDateTime(b.startsAt)} –{" "} - {fmtDateTime(b.endsAt)} +
+ {nameOf(r.nameAr, r.nameEn)} +
+
+ {r.capacity + ? `${t("protocol.rooms.capacity")}: ${r.capacity}` + : ""}
- {b.requesterName && ( -
- {t("protocol.bookings.requester")}: {b.requesterName} -
- )}
- {t(`protocol.status.${b.status}`)} + {r.isActive + ? t("protocol.rooms.active") + : t("protocol.rooms.inactive")}
-
- {c?.canApprove && b.status === "pending" && ( - <> - - - - )} - {c?.canMutate && - (b.status === "pending" || b.status === "approved") && ( - - )} - {c?.canMutate && ( - <> - - - - )} -
+ {c?.canManageRooms && ( +
+ + +
+ )}
))} - {bookings.data?.length === 0 && ( - + {rooms.data?.length === 0 && ( + )}
@@ -579,28 +769,57 @@ export default function ProtocolPage() { {t(`protocol.status.${m.status}`)} - {c?.canMutate && ( -
- - + {m.status === "rejected" && m.rejectionReason && ( +
+ {t("protocol.actions.reject")}: {m.rejectionReason}
)} +
+ {c?.canApprove && m.status === "pending" && ( + <> + + + + )} + {c?.canMutate && ( + <> + + + + )} +
))} {external.data?.length === 0 && ( @@ -617,16 +836,6 @@ export default function ProtocolPage() { {t("protocol.tabs.gifts")}
- {c?.canManageRooms && ( - - )} {c?.canMutate && (
- {c?.canManageRooms && ( -
-
- {t("protocol.rooms.title")} -
-
- {(rooms.data ?? []).map((r) => ( -
- - {nameOf(r.nameAr, r.nameEn)} - {r.capacity ? ` · ${r.capacity}` : ""} - {!r.isActive ? ` · ${t("protocol.rooms.inactive")}` : ""} - -
- - -
-
- ))} -
-
- )} -
{(gifts.data ?? []).map((g) => (
string; + goTab: (k: TabKey) => void; }) { - const cards = [ - { label: t("protocol.dashboard.activeRooms"), value: data?.activeRooms }, - { label: t("protocol.dashboard.todayBookings"), value: data?.todayBookings }, + const cards: Array<{ label: string; value?: number; tab: TabKey }> = [ + { + 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"), value: data?.pendingBookings, + tab: "bookings", }, { label: t("protocol.dashboard.upcomingExternal"), value: data?.upcomingExternalMeetings, + tab: "external", }, { label: t("protocol.dashboard.giftStock"), value: data?.totalGiftStock, + tab: "gifts", }, { label: t("protocol.dashboard.pendingIssues"), value: data?.pendingGiftIssues, + tab: "issues", + }, + { + label: t("protocol.dashboard.giftsIssuedThisMonth"), + value: data?.giftsIssuedThisMonth, + tab: "issues", }, ]; return (
{cards.map((c) => ( -
goTab(c.tab)} + className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition" >
{c.value ?? "—"}
{c.label}
-
+ ))}
); @@ -1083,6 +1278,34 @@ function ReportsView({
+ +
+

+ {t("protocol.reports.byExternal")} +

+
+ {( + [ + ["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]) => ( +
+
+ {data?.externalMeetings?.[key] ?? "—"} +
+
{t(label)}
+
+ ))} +
+
); } @@ -1262,6 +1485,7 @@ function RoomDialog({ edit?.capacity != null ? String(edit.capacity) : "", ); const [location, setLocation] = useState(edit?.location ?? ""); + const [isActive, setIsActive] = useState(edit?.isActive ?? true); const mut = useMutation({ mutationFn: () => { @@ -1270,6 +1494,7 @@ function RoomDialog({ nameEn, capacity: capacity ? Number(capacity) : null, location: location || null, + isActive, }; return edit ? apiJson(`${API}/protocol/rooms/${edit.id}`, { @@ -1315,6 +1540,15 @@ function RoomDialog({ setLocation(e.target.value)} /> + ); } @@ -1337,21 +1571,23 @@ function ExternalDialog({ const [startsAt, setStartsAt] = useState( edit ? toLocalInput(edit.startsAt) : "", ); - const [status, setStatus] = useState( - edit?.status ?? "scheduled", + const [status, setStatus] = useState<"completed" | "cancelled" | "">( + edit?.status === "completed" || edit?.status === "cancelled" + ? edit.status + : "", ); const [notes, setNotes] = useState(edit?.notes ?? ""); const mut = useMutation({ mutationFn: () => { - const body = { + const body: Record = { title, partyName: partyName || null, location: location || null, startsAt: fromLocalInput(startsAt), - status, notes: notes || null, }; + if (edit && status) body.status = status; return edit ? apiJson(`${API}/protocol/external-meetings/${edit.id}`, { method: "PATCH", @@ -1396,28 +1632,29 @@ function ExternalDialog({ required /> -
- - -
+ {edit && ( +
+ + +
+ )}
diff --git a/lib/db/src/schema/protocol.ts b/lib/db/src/schema/protocol.ts index 50419bd0..2fd8c9a9 100644 --- a/lib/db/src/schema/protocol.ts +++ b/lib/db/src/schema/protocol.ts @@ -131,11 +131,18 @@ export const protocolExternalMeetingsTable = pgTable( location: varchar("location", { length: 300 }), startsAt: timestamp("starts_at", { withTimezone: true }).notNull(), 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"), 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(), @@ -146,6 +153,7 @@ export const protocolExternalMeetingsTable = pgTable( }, (t) => ({ startIdx: index("protocol_ext_meetings_start_idx").on(t.startsAt), + statusIdx: index("protocol_ext_meetings_status_idx").on(t.status), }), ); diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index 1ff3dd3f..ea7f0e4e 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -878,9 +878,21 @@ async function main() { .limit(1); if (existingRooms.length === 0) { await db.insert(protocolRoomsTable).values([ - { nameAr: "القاعة الأولى", nameEn: "Room 1", sortOrder: 1 }, - { nameAr: "القاعة الثانية", nameEn: "Room 2", sortOrder: 2 }, - { nameAr: "القاعة الثالثة", nameEn: "Room 3", sortOrder: 3 }, + { + 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"); }