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:
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "لا يوجد نشاط."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user