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 { 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<number>`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<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({
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<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="/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
+14 -5
View File
@@ -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": "لا يوجد نشاط."
+14 -5
View File
@@ -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."
+435 -198
View File
@@ -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<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
// ---------------------------------------------------------------------------
@@ -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<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> = {
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<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);
@@ -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) => (
<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 (
<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">
@@ -424,7 +587,7 @@ export default function ProtocolPage() {
<main className="max-w-5xl mx-auto px-4 py-5">
{tab === "dashboard" && (
<DashboardView data={dashboard.data} t={t} />
<DashboardView data={dashboard.data} t={t} goTab={setTab} />
)}
{tab === "bookings" && (
@@ -433,108 +596,135 @@ export default function ProtocolPage() {
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.bookings")}
</h2>
{c?.canRequest && (
<Button
size="sm"
onClick={() => setBookingDialog({ open: true })}
>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
<button
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" />
{t("protocol.bookings.new")}
{t("protocol.rooms.new")}
</Button>
)}
</div>
<div className="grid gap-2">
{(bookings.data ?? []).map((b) => (
<div className="grid gap-2 sm:grid-cols-2">
{(rooms.data ?? []).map((r) => (
<div
key={b.id}
key={r.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 className="font-medium text-slate-800">
{nameOf(r.nameAr, r.nameEn)}
</div>
<div className="text-xs text-slate-500">
{r.capacity
? `${t("protocol.rooms.capacity")}: ${r.capacity}`
: ""}
</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],
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>
</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>
{c?.canManageRooms && (
<div className="flex gap-1.5 mt-2">
<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>
))}
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
{rooms.data?.length === 0 && (
<EmptyState text={t("protocol.rooms.empty")} />
)}
</div>
</section>
@@ -579,28 +769,57 @@ export default function ProtocolPage() {
{t(`protocol.status.${m.status}`)}
</span>
</div>
{c?.canMutate && (
<div className="flex gap-1.5 mt-2">
<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>
{m.status === "rejected" && m.rejectionReason && (
<div className="text-xs text-rose-500 mt-1">
{t("protocol.actions.reject")}: {m.rejectionReason}
</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>
))}
{external.data?.length === 0 && (
@@ -617,16 +836,6 @@ export default function ProtocolPage() {
{t("protocol.tabs.gifts")}
</h2>
<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 && (
<Button size="sm" onClick={() => setGiftDialog({ open: true })}>
<Plus size={16} className="me-1" />
@@ -636,46 +845,6 @@ export default function ProtocolPage() {
</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">
{(gifts.data ?? []).map((g) => (
<div
@@ -960,40 +1129,66 @@ function EmptyState({ text }: { text: string }) {
function DashboardView({
data,
t,
goTab,
}: {
data: Dashboard | undefined;
t: (k: string) => 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 (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3">
{cards.map((c) => (
<div
<button
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-sm text-slate-500 mt-1">{c.label}</div>
</div>
</button>
))}
</div>
);
@@ -1083,6 +1278,34 @@ function ReportsView({
</table>
</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>
);
}
@@ -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({
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</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>
);
}
@@ -1337,21 +1571,23 @@ function ExternalDialog({
const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : "",
);
const [status, setStatus] = useState<ExternalMeeting["status"]>(
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<string, unknown> = {
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
/>
</div>
<div>
<Label>{t("protocol.statusLabel")}</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as ExternalMeeting["status"])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="scheduled">
{t("protocol.status.scheduled")}
</SelectItem>
<SelectItem value="completed">
{t("protocol.status.completed")}
</SelectItem>
<SelectItem value="cancelled">
{t("protocol.status.cancelled")}
</SelectItem>
</SelectContent>
</Select>
</div>
{edit && (
<div>
<Label>{t("protocol.statusLabel")}</Label>
<Select
value={status}
onValueChange={(v) =>
setStatus(v as "completed" | "cancelled")
}
>
<SelectTrigger>
<SelectValue placeholder={t("protocol.status.pending")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="completed">
{t("protocol.status.completed")}
</SelectItem>
<SelectItem value="cancelled">
{t("protocol.status.cancelled")}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div>
<Label>{t("protocol.notes")}</Label>
+9 -1
View File
@@ -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),
}),
);
+15 -3
View File
@@ -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");
}