Task #704: أوقات حجز محددة يديرها الأدمن + مهلة حجز مسبقة
- DB: protocol_booking_slots (وقت "HH:mm" بتوقيت الرياض، مدة، تفعيل، ترتيب) + protocol_settings (booking.minLeadMinutes، الافتراضي 60 دقيقة). - API: CRUD /protocol/booking-slots (الكتابة تتطلب protocol.rooms.manage، تكرار الوقت → 409 duplicate_slot)، GET/PATCH /protocol/settings (upsert + سجل تدقيق)، ونقطة عامة /protocol/public/availability (محدّد معدل خاص، تعيد booked/tooSoon/available لكل وقت). - الفرض في الخادم: الطلب العام يجب أن يطابق وقتاً مفعّلاً + يحترم المهلة (400 invalid_slot / lead_time برسائل عربية)؛ الموظفون ملزمون بالأوقات لكن معفيون من المهلة. بدون أوقات مفعّلة يعمل الوضع الحر القديم. - الواجهة: نموذج الطلب العام يعرض شبكة أوقات حسب التوفر (مع fallback قديم)؛ تبويب القاعات يعرض قسم إدارة الأوقات + المهلة لمن يملك canManageRooms؛ حوار الحجز الداخلي يتحول لتاريخ + قائمة أوقات عند وجود أوقات مفعّلة. - i18n: مفاتيح protocol.slots.* بالعربية والإنجليزية. - اختبارات: tests/protocol-booking-slots.test.mjs — 10 اختبارات ناجحة (CRUD/تكرار، الإعدادات، التوفر، invalid_slot، lead_time، تعارض، وقت معطّل، إعفاء الموظفين، صلاحيات القراءة/الكتابة، الحذف). - مراجعة المعماري: PASS؛ عولجت الملاحظات (توثيق قرار الصلاحيات + اختبار regression له، ومنع الإرسال في الواجهة قبل اختيار التاريخ والوقت).
This commit is contained in:
@@ -25,6 +25,8 @@ import {
|
||||
protocolPhotoAlbumsTable,
|
||||
protocolPhotosTable,
|
||||
protocolAuditLogsTable,
|
||||
protocolBookingSlotsTable,
|
||||
protocolSettingsTable,
|
||||
rolesTable,
|
||||
permissionsTable,
|
||||
rolePermissionsTable,
|
||||
@@ -268,6 +270,38 @@ const publicBookingRequestSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
// Bookable time slot management (أوقات الحجز). startTime is a canonical
|
||||
// zero-padded 24h "HH:mm" wall-clock time in Asia/Riyadh.
|
||||
// Postgres unique violation (23505). Drizzle may wrap the pg error in a
|
||||
// DrizzleQueryError, so check the error itself and its `cause`.
|
||||
function isUniqueViolation(e: unknown): boolean {
|
||||
const codeOf = (x: unknown) =>
|
||||
x instanceof Error && "code" in x
|
||||
? (x as { code?: string }).code
|
||||
: undefined;
|
||||
return (
|
||||
codeOf(e) === "23505" ||
|
||||
(e instanceof Error && codeOf((e as { cause?: unknown }).cause) === "23505")
|
||||
);
|
||||
}
|
||||
|
||||
const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const slotCreateSchema = z.object({
|
||||
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
|
||||
durationMinutes: z.number().int().min(5).max(480).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).max(100000).optional(),
|
||||
});
|
||||
const slotPatchSchema = slotCreateSchema
|
||||
.partial()
|
||||
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
|
||||
|
||||
const settingsPatchSchema = z.object({
|
||||
// Minimum advance notice for PUBLIC booking requests, in minutes.
|
||||
// 0 disables the restriction; capped at 7 days.
|
||||
minLeadMinutes: z.number().int().min(0).max(10080),
|
||||
});
|
||||
|
||||
const rejectSchema = z.object({
|
||||
reason: z.string().trim().max(1000).nullable().optional(),
|
||||
});
|
||||
@@ -425,6 +459,80 @@ async function hasBookingConflict(
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Booking slots + lead-time helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIN_LEAD_SETTING_KEY = "booking.minLeadMinutes";
|
||||
const DEFAULT_MIN_LEAD_MINUTES = 60;
|
||||
// Riyadh has no DST, so a fixed +03:00 offset is always correct — but we
|
||||
// still derive the wall-clock time via Intl so the mapping stays exact.
|
||||
const RIYADH_TZ = "Asia/Riyadh";
|
||||
const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: RIYADH_TZ,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
function riyadhHHmm(d: Date): string {
|
||||
return riyadhTimeFmt.format(d);
|
||||
}
|
||||
// Build the absolute Date at which slot `startTime` ("HH:mm" Riyadh wall
|
||||
// clock) begins on `dateStr` ("YYYY-MM-DD", a Riyadh calendar day).
|
||||
function slotStartOn(dateStr: string, startTime: string): Date {
|
||||
return new Date(`${dateStr}T${startTime}:00+03:00`);
|
||||
}
|
||||
|
||||
async function getMinLeadMinutes(executor: DbExecutor): Promise<number> {
|
||||
const [row] = await executor
|
||||
.select({ value: protocolSettingsTable.value })
|
||||
.from(protocolSettingsTable)
|
||||
.where(eq(protocolSettingsTable.key, MIN_LEAD_SETTING_KEY));
|
||||
if (!row) return DEFAULT_MIN_LEAD_MINUTES;
|
||||
const n = Number(row.value);
|
||||
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES;
|
||||
}
|
||||
|
||||
type ActiveSlot = { id: number; startTime: string; durationMinutes: number };
|
||||
async function getActiveSlots(executor: DbExecutor): Promise<ActiveSlot[]> {
|
||||
return executor
|
||||
.select({
|
||||
id: protocolBookingSlotsTable.id,
|
||||
startTime: protocolBookingSlotsTable.startTime,
|
||||
durationMinutes: protocolBookingSlotsTable.durationMinutes,
|
||||
})
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.isActive, true))
|
||||
.orderBy(
|
||||
asc(protocolBookingSlotsTable.sortOrder),
|
||||
asc(protocolBookingSlotsTable.startTime),
|
||||
);
|
||||
}
|
||||
|
||||
// Does [startsAt, endsAt) exactly match one of the configured slots?
|
||||
// Backwards-compat rule: when NO active slots are configured the module
|
||||
// behaves as before (free time input) — enforcement only kicks in once the
|
||||
// admin has defined at least one slot.
|
||||
function matchesActiveSlot(
|
||||
slots: ActiveSlot[],
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
): boolean {
|
||||
if (slots.length === 0) return true;
|
||||
if (startsAt.getUTCSeconds() !== 0 || startsAt.getUTCMilliseconds() !== 0) {
|
||||
return false;
|
||||
}
|
||||
const wall = riyadhHHmm(startsAt);
|
||||
const durationMs = endsAt.getTime() - startsAt.getTime();
|
||||
return slots.some(
|
||||
(s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
const INVALID_SLOT_MESSAGE_AR =
|
||||
"الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة.";
|
||||
const LEAD_TIME_MESSAGE_AR =
|
||||
"لا يمكن الحجز في هذا الوقت، يجب الحجز قبل الموعد بمدة كافية.";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -522,6 +630,26 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
// Slot + lead-time enforcement for anonymous guests. When the admin has
|
||||
// configured booking slots, a public request must land exactly on an
|
||||
// active slot; and it must start at least `minLeadMinutes` from now.
|
||||
const [activeSlots, minLeadMinutes] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
]);
|
||||
if (!matchesActiveSlot(activeSlots, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
if (startsAt.getTime() - Date.now() < minLeadMinutes * 60_000) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: LEAD_TIME_MESSAGE_AR, code: "lead_time" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
if (!(await lockRoomRow(tx, body.roomId))) {
|
||||
@@ -596,6 +724,284 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Public availability: which slots can a guest still pick for a room on a
|
||||
// given Riyadh calendar day. Deliberately reveals only per-slot booleans —
|
||||
// never who booked, what, or exact busy ranges beyond the slot grid itself.
|
||||
// Not behind the strict submission limiter (browsing dates must stay cheap),
|
||||
// but still lightly rate-limited.
|
||||
const publicAvailabilityLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? ""),
|
||||
message: {
|
||||
error: "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.",
|
||||
code: "rate_limited",
|
||||
},
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/protocol/public/availability",
|
||||
publicAvailabilityLimiter,
|
||||
async (req: Request, res: Response) => {
|
||||
const roomIdRaw = req.query.roomId;
|
||||
const dateRaw = req.query.date;
|
||||
if (typeof roomIdRaw !== "string" || !/^\d+$/.test(roomIdRaw)) {
|
||||
res.status(400).json({ error: "roomId is required", code: "validation" });
|
||||
return;
|
||||
}
|
||||
if (typeof dateRaw !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "date must be YYYY-MM-DD", code: "validation" });
|
||||
return;
|
||||
}
|
||||
const roomId = Number(roomIdRaw);
|
||||
const dayStart = slotStartOn(dateRaw, "00:00");
|
||||
if (Number.isNaN(dayStart.getTime())) {
|
||||
res.status(400).json({ error: "invalid date", code: "validation" });
|
||||
return;
|
||||
}
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const [room] = await db
|
||||
.select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive })
|
||||
.from(protocolRoomsTable)
|
||||
.where(eq(protocolRoomsTable.id, roomId));
|
||||
if (!room || !room.isActive) {
|
||||
res.status(400).json({ error: "room not found", code: "invalid_room" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [slots, minLeadMinutes, busy] = await Promise.all([
|
||||
getActiveSlots(db),
|
||||
getMinLeadMinutes(db),
|
||||
db
|
||||
.select({
|
||||
startsAt: protocolRoomBookingsTable.startsAt,
|
||||
endsAt: protocolRoomBookingsTable.endsAt,
|
||||
})
|
||||
.from(protocolRoomBookingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(protocolRoomBookingsTable.roomId, roomId),
|
||||
inArray(
|
||||
protocolRoomBookingsTable.status,
|
||||
OCCUPYING_STATUSES as unknown as string[],
|
||||
),
|
||||
sql`${protocolRoomBookingsTable.startsAt} < ${dayEnd.toISOString()}`,
|
||||
sql`${protocolRoomBookingsTable.endsAt} > ${dayStart.toISOString()}`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
const result = slots.map((s) => {
|
||||
const start = slotStartOn(dateRaw, s.startTime);
|
||||
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
|
||||
const booked = busy.some(
|
||||
(b) => b.startsAt < end && b.endsAt > start,
|
||||
);
|
||||
// A slot is "too soon" for a public guest when its start is closer
|
||||
// than the configured lead time (staff are not bound by this).
|
||||
const tooSoon = start.getTime() - now < minLeadMinutes * 60_000;
|
||||
return {
|
||||
id: s.id,
|
||||
startTime: s.startTime,
|
||||
durationMinutes: s.durationMinutes,
|
||||
booked,
|
||||
tooSoon,
|
||||
available: !booked && !tooSoon,
|
||||
};
|
||||
});
|
||||
res.json({ minLeadMinutes, slots: result });
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Booking slots admin (أوقات الحجز) + module settings
|
||||
//
|
||||
// Authorization decision: READS of slots/settings are deliberately open to
|
||||
// any protocol.access holder (not just protocol.rooms.manage) because every
|
||||
// protocol user's booking dialog needs the active slot list, and viewers
|
||||
// need the lead-time value for display. WRITES stay behind
|
||||
// protocol.rooms.manage. Locked in by the authz regression test in
|
||||
// tests/protocol-booking-slots.test.mjs.
|
||||
// ---------------------------------------------------------------------------
|
||||
router.get(
|
||||
"/protocol/booking-slots",
|
||||
requireProtocolAccess,
|
||||
async (_req: Request, res: Response) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.orderBy(
|
||||
asc(protocolBookingSlotsTable.sortOrder),
|
||||
asc(protocolBookingSlotsTable.startTime),
|
||||
);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/protocol/booking-slots",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, slotCreateSchema, req.body);
|
||||
if (!body) return;
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(protocolBookingSlotsTable)
|
||||
.values({
|
||||
startTime: body.startTime,
|
||||
durationMinutes: body.durationMinutes ?? 30,
|
||||
isActive: body.isActive ?? true,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
createdBy: req.session.userId!,
|
||||
})
|
||||
.returning();
|
||||
await logAudit(db, {
|
||||
action: "slot.create",
|
||||
entityType: "booking_slot",
|
||||
entityId: row.id,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.status(201).json(row);
|
||||
} catch (e) {
|
||||
// Unique start_time violation → a slot already starts at this time.
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
|
||||
code: "duplicate_slot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/protocol/booking-slots/:id",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const body = parseBody(res, slotPatchSchema, req.body);
|
||||
if (!body) return;
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Not found", code: "not_found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [row] = await db
|
||||
.update(protocolBookingSlotsTable)
|
||||
.set({
|
||||
...(body.startTime !== undefined ? { startTime: body.startTime } : {}),
|
||||
...(body.durationMinutes !== undefined
|
||||
? { durationMinutes: body.durationMinutes }
|
||||
: {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
})
|
||||
.where(eq(protocolBookingSlotsTable.id, id))
|
||||
.returning();
|
||||
await logAudit(db, {
|
||||
action: "slot.update",
|
||||
entityType: "booking_slot",
|
||||
entityId: id,
|
||||
oldValue: existing,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.json(row);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({
|
||||
error: "يوجد وقت حجز يبدأ في نفس التوقيت.",
|
||||
code: "duplicate_slot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/protocol/booking-slots/:id",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Not found", code: "not_found" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.delete(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.id, id));
|
||||
await logAudit(db, {
|
||||
action: "slot.delete",
|
||||
entityType: "booking_slot",
|
||||
entityId: id,
|
||||
oldValue: existing,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.status(204).end();
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/protocol/settings",
|
||||
requireProtocolAccess,
|
||||
async (_req: Request, res: Response) => {
|
||||
res.json({ minLeadMinutes: await getMinLeadMinutes(db) });
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/protocol/settings",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, settingsPatchSchema, req.body);
|
||||
if (!body) return;
|
||||
const oldValue = await getMinLeadMinutes(db);
|
||||
await db
|
||||
.insert(protocolSettingsTable)
|
||||
.values({
|
||||
key: MIN_LEAD_SETTING_KEY,
|
||||
value: String(body.minLeadMinutes),
|
||||
updatedBy: req.session.userId!,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: protocolSettingsTable.key,
|
||||
set: {
|
||||
value: String(body.minLeadMinutes),
|
||||
updatedBy: req.session.userId!,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logAudit(db, {
|
||||
action: "settings.update",
|
||||
entityType: "settings",
|
||||
entityId: null,
|
||||
oldValue: { minLeadMinutes: oldValue },
|
||||
newValue: { minLeadMinutes: body.minLeadMinutes },
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
res.json({ minLeadMinutes: body.minLeadMinutes });
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -772,6 +1178,18 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
// Internal bookings must also land on the configured slot grid (when one
|
||||
// exists), but staff are deliberately NOT bound by the public lead-time
|
||||
// setting — the lead time exists to give the protocol team room to
|
||||
// prepare, and the team itself can book at short notice.
|
||||
const activeSlotsForCreate = await getActiveSlots(db);
|
||||
if (!matchesActiveSlot(activeSlotsForCreate, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-approvers submit requests as "pending"; users with the approve
|
||||
// permission may create an already-approved booking directly. Either way
|
||||
// the slot is checked.
|
||||
@@ -875,6 +1293,18 @@ router.patch(
|
||||
.json({ error: "startsAt must be before endsAt", code: "validation" });
|
||||
return;
|
||||
}
|
||||
// If the edit changes the meeting time, the new time must still land on
|
||||
// the configured slot grid (when one exists). Same staff exemption from
|
||||
// the lead-time rule as on create.
|
||||
if (body.startsAt !== undefined || body.endsAt !== undefined) {
|
||||
const activeSlotsForPatch = await getActiveSlots(db);
|
||||
if (!matchesActiveSlot(activeSlotsForPatch, startsAt, endsAt)) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: INVALID_SLOT_MESSAGE_AR, code: "invalid_slot" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
// Lock the (possibly new) room to serialize against concurrent
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// API contract tests for admin-managed booking slots (أوقات الحجز) and the
|
||||
// public lead-time setting. Covers:
|
||||
//
|
||||
// 1. Admin slot CRUD: create returns 201, duplicate startTime returns 409
|
||||
// with code "duplicate_slot".
|
||||
// 2. Settings roundtrip: PATCH minLeadMinutes persists and GET returns it.
|
||||
// 3. Public availability reflects booked/tooSoon/available per slot.
|
||||
// 4. Public request off-slot -> 400 invalid_slot.
|
||||
// 5. Public request on-slot but inside the lead window -> 400 lead_time.
|
||||
// 6. Public request on-slot outside the lead window -> 201; a second
|
||||
// request for the same slot conflicts (409).
|
||||
// 7. A deactivated slot no longer accepts public requests (while another
|
||||
// slot keeps slot-mode active).
|
||||
// 8. Staff (internal POST /protocol/bookings) must still match a slot but
|
||||
// are exempt from the lead time.
|
||||
//
|
||||
// The suite snapshots and clears protocol_booking_slots + the
|
||||
// booking.minLeadMinutes setting up front and restores both in `after`, so
|
||||
// it is safe against pre-existing data and repeated runs.
|
||||
//
|
||||
// NOTE: the public booking endpoint is rate limited at 8 requests / 10 min
|
||||
// per IP; this file performs at most 6 public POSTs.
|
||||
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const created = {
|
||||
slotIds: [],
|
||||
roomIds: [],
|
||||
bookingIds: [],
|
||||
userIds: [],
|
||||
};
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// A dedicated protocol_super_admin user (has protocol.rooms.manage +
|
||||
// protocol.mutate) so the suite doesn't depend on the real admin password.
|
||||
async function createSuperAdmin() {
|
||||
const username = uniqueName("slot_admin");
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Slot Test Admin', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
created.userIds.push(rows[0].id);
|
||||
for (const r of ["user", "protocol_super_admin"]) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[rows[0].id, r],
|
||||
);
|
||||
}
|
||||
return username;
|
||||
}
|
||||
let savedSlots = [];
|
||||
let savedLeadRow = null;
|
||||
|
||||
function extractCookie(res) {
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
if (!setCookie) return null;
|
||||
return (
|
||||
setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid=")) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login should succeed for ${username}`);
|
||||
const cookie = extractCookie(res);
|
||||
assert.ok(cookie, "login response should set a session cookie");
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function api(cookie, method, path, body) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
return fetch(`${API_BASE}/api${path}`, init);
|
||||
}
|
||||
|
||||
let adminCookie = null;
|
||||
let roomId = null;
|
||||
|
||||
// Tomorrow's calendar day in Riyadh (fixed +03:00, no DST) so every slot we
|
||||
// book is comfortably in the future.
|
||||
function riyadhTomorrowYmd() {
|
||||
const now = new Date(Date.now() + (24 * 60 + 180) * 60 * 1000);
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
const day = riyadhTomorrowYmd();
|
||||
function isoAt(hhmm, plusMinutes = 0) {
|
||||
const d = new Date(`${day}T${hhmm}:00+03:00`);
|
||||
return new Date(d.getTime() + plusMinutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
const SLOT_A = "09:37"; // uncommon times to avoid clashing with real data
|
||||
const SLOT_B = "13:41";
|
||||
const DURATION = 45;
|
||||
|
||||
function publicBody(startsAt, endsAt, title) {
|
||||
return {
|
||||
roomId,
|
||||
title,
|
||||
requesterName: "Slot Test Guest",
|
||||
requesterPhone: "0550000000",
|
||||
meetingType: "internal",
|
||||
startsAt,
|
||||
endsAt,
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
adminCookie = await login(await createSuperAdmin(), TEST_PASSWORD);
|
||||
|
||||
// Snapshot + clear pre-existing slots and lead-time setting.
|
||||
savedSlots = (
|
||||
await pool.query(`SELECT * FROM protocol_booking_slots ORDER BY id`)
|
||||
).rows;
|
||||
await pool.query(`DELETE FROM protocol_booking_slots`);
|
||||
savedLeadRow = (
|
||||
await pool.query(
|
||||
`SELECT value FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`,
|
||||
)
|
||||
).rows[0] ?? null;
|
||||
|
||||
const roomRes = await api(adminCookie, "POST", "/protocol/rooms", {
|
||||
nameAr: `قاعة اختبار الأوقات ${Date.now().toString(36)}`,
|
||||
nameEn: "Slot Test Room",
|
||||
isActive: true,
|
||||
});
|
||||
assert.equal(roomRes.status, 201, "room creation should succeed");
|
||||
roomId = (await roomRes.json()).id;
|
||||
created.roomIds.push(roomId);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
if (created.bookingIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM protocol_room_bookings WHERE id = ANY($1::int[])`,
|
||||
[created.bookingIds],
|
||||
);
|
||||
}
|
||||
// Also remove any bookings created via the public endpoint against our
|
||||
// test room (ids captured from responses, but belt-and-braces by room).
|
||||
if (created.roomIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM protocol_room_bookings WHERE room_id = ANY($1::int[])`,
|
||||
[created.roomIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM protocol_rooms WHERE id = ANY($1::int[])`, [
|
||||
created.roomIds,
|
||||
]);
|
||||
}
|
||||
if (created.userIds.length) {
|
||||
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
created.userIds,
|
||||
]);
|
||||
}
|
||||
await pool.query(`DELETE FROM protocol_booking_slots`);
|
||||
for (const s of savedSlots) {
|
||||
await pool.query(
|
||||
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
s.id,
|
||||
s.start_time,
|
||||
s.duration_minutes,
|
||||
s.is_active,
|
||||
s.sort_order,
|
||||
s.created_at,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (savedSlots.length) {
|
||||
await pool.query(
|
||||
`SELECT setval(pg_get_serial_sequence('protocol_booking_slots','id'),
|
||||
(SELECT MAX(id) FROM protocol_booking_slots))`,
|
||||
);
|
||||
}
|
||||
if (savedLeadRow) {
|
||||
await pool.query(
|
||||
`INSERT INTO protocol_settings (key, value) VALUES ('booking.minLeadMinutes', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[savedLeadRow.value],
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`DELETE FROM protocol_settings WHERE key = 'booking.minLeadMinutes'`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
|
||||
let slotAId = null;
|
||||
let slotBId = null;
|
||||
|
||||
test("admin can create slots; duplicate startTime is rejected with 409", async () => {
|
||||
const resA = await api(adminCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: SLOT_A,
|
||||
durationMinutes: DURATION,
|
||||
});
|
||||
assert.equal(resA.status, 201);
|
||||
slotAId = (await resA.json()).id;
|
||||
created.slotIds.push(slotAId);
|
||||
|
||||
const resB = await api(adminCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: SLOT_B,
|
||||
durationMinutes: DURATION,
|
||||
});
|
||||
assert.equal(resB.status, 201);
|
||||
slotBId = (await resB.json()).id;
|
||||
created.slotIds.push(slotBId);
|
||||
|
||||
const dup = await api(adminCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: SLOT_A,
|
||||
});
|
||||
assert.equal(dup.status, 409, "duplicate startTime should 409");
|
||||
assert.equal((await dup.json()).code, "duplicate_slot");
|
||||
});
|
||||
|
||||
test("minLeadMinutes setting persists via PATCH and GET", async () => {
|
||||
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 10080,
|
||||
});
|
||||
assert.equal(patch.status, 200);
|
||||
const get = await api(adminCookie, "GET", "/protocol/settings");
|
||||
assert.equal(get.status, 200);
|
||||
assert.equal((await get.json()).minLeadMinutes, 10080);
|
||||
});
|
||||
|
||||
test("public availability marks slots tooSoon inside the lead window", async () => {
|
||||
// lead is currently 7 days (10080), and our slots are tomorrow -> tooSoon.
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json();
|
||||
assert.equal(data.minLeadMinutes, 10080);
|
||||
const a = data.slots.find((s) => s.id === slotAId);
|
||||
assert.ok(a, "slot A should appear in availability");
|
||||
assert.equal(a.booked, false);
|
||||
assert.equal(a.tooSoon, true);
|
||||
assert.equal(a.available, false);
|
||||
});
|
||||
|
||||
test("public request off-slot is rejected with invalid_slot", async () => {
|
||||
const res = await api(null, "POST", "/protocol/public/booking-requests",
|
||||
publicBody(isoAt("10:15"), isoAt("10:15", DURATION), "Off-slot request"));
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).code, "invalid_slot");
|
||||
});
|
||||
|
||||
test("public request on-slot inside lead window is rejected with lead_time", async () => {
|
||||
const res = await api(null, "POST", "/protocol/public/booking-requests",
|
||||
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Too-soon request"));
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).code, "lead_time");
|
||||
});
|
||||
|
||||
test("public request on-slot outside lead window succeeds; same slot then conflicts", async () => {
|
||||
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 0,
|
||||
});
|
||||
assert.equal(patch.status, 200);
|
||||
|
||||
const ok = await api(null, "POST", "/protocol/public/booking-requests",
|
||||
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Valid slot request"));
|
||||
assert.equal(ok.status, 201, "on-slot request should be accepted");
|
||||
const body = await ok.json();
|
||||
assert.ok(body.id, "response should include an id");
|
||||
created.bookingIds.push(body.id);
|
||||
|
||||
const clash = await api(null, "POST", "/protocol/public/booking-requests",
|
||||
publicBody(isoAt(SLOT_A), isoAt(SLOT_A, DURATION), "Clashing request"));
|
||||
assert.equal(clash.status, 409, "same slot should conflict");
|
||||
|
||||
// Availability now reports the slot as booked.
|
||||
const avail = await fetch(
|
||||
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
|
||||
);
|
||||
const data = await avail.json();
|
||||
const a = data.slots.find((s) => s.id === slotAId);
|
||||
assert.equal(a.booked, true);
|
||||
assert.equal(a.available, false);
|
||||
});
|
||||
|
||||
test("a deactivated slot no longer accepts public requests", async () => {
|
||||
const patch = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/protocol/booking-slots/${slotBId}`,
|
||||
{ isActive: false },
|
||||
);
|
||||
assert.equal(patch.status, 200);
|
||||
|
||||
const res = await api(null, "POST", "/protocol/public/booking-requests",
|
||||
publicBody(isoAt(SLOT_B), isoAt(SLOT_B, DURATION), "Disabled slot request"));
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal((await res.json()).code, "invalid_slot");
|
||||
|
||||
// Re-activate for the staff test below.
|
||||
const undo = await api(
|
||||
adminCookie,
|
||||
"PATCH",
|
||||
`/protocol/booking-slots/${slotBId}`,
|
||||
{ isActive: true },
|
||||
);
|
||||
assert.equal(undo.status, 200);
|
||||
});
|
||||
|
||||
test("staff bookings must match a slot but ignore the lead time", async () => {
|
||||
// Restore a large lead time; staff must NOT be blocked by it.
|
||||
const patch = await api(adminCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 10080,
|
||||
});
|
||||
assert.equal(patch.status, 200);
|
||||
|
||||
const offSlot = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Staff off-slot",
|
||||
requesterName: "Staff",
|
||||
startsAt: isoAt("16:03"),
|
||||
endsAt: isoAt("16:03", DURATION),
|
||||
});
|
||||
assert.equal(offSlot.status, 400, "staff off-slot booking should 400");
|
||||
assert.equal((await offSlot.json()).code, "invalid_slot");
|
||||
|
||||
const onSlot = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Staff on-slot inside lead window",
|
||||
requesterName: "Staff",
|
||||
startsAt: isoAt(SLOT_B),
|
||||
endsAt: isoAt(SLOT_B, DURATION),
|
||||
});
|
||||
assert.equal(
|
||||
onSlot.status,
|
||||
201,
|
||||
"staff on-slot booking should succeed despite lead time",
|
||||
);
|
||||
created.bookingIds.push((await onSlot.json()).id);
|
||||
});
|
||||
|
||||
// Authz decision locked in: any protocol.access holder may READ slots and
|
||||
// settings (the booking dialog needs them), but WRITES require
|
||||
// protocol.rooms.manage.
|
||||
test("protocol viewer can read slots/settings but cannot write them", async () => {
|
||||
const username = uniqueName("slot_viewer");
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Slot Test Viewer', 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
||||
);
|
||||
created.userIds.push(rows[0].id);
|
||||
for (const r of ["user", "protocol_viewer"]) {
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = $2
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[rows[0].id, r],
|
||||
);
|
||||
}
|
||||
const viewerCookie = await login(username, TEST_PASSWORD);
|
||||
|
||||
const getSlots = await api(viewerCookie, "GET", "/protocol/booking-slots");
|
||||
assert.equal(getSlots.status, 200, "viewer should read slots");
|
||||
const getSettings = await api(viewerCookie, "GET", "/protocol/settings");
|
||||
assert.equal(getSettings.status, 200, "viewer should read settings");
|
||||
|
||||
const post = await api(viewerCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: "20:20",
|
||||
});
|
||||
assert.equal(post.status, 403, "viewer must not create slots");
|
||||
const patch = await api(viewerCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 5,
|
||||
});
|
||||
assert.equal(patch.status, 403, "viewer must not change settings");
|
||||
const del = await api(
|
||||
viewerCookie,
|
||||
"DELETE",
|
||||
`/protocol/booking-slots/${slotAId}`,
|
||||
);
|
||||
assert.equal(del.status, 403, "viewer must not delete slots");
|
||||
});
|
||||
|
||||
test("deleting a slot removes it from the admin list", async () => {
|
||||
const del = await api(
|
||||
adminCookie,
|
||||
"DELETE",
|
||||
`/protocol/booking-slots/${slotBId}`,
|
||||
);
|
||||
assert.equal(del.status, 204);
|
||||
const list = await api(adminCookie, "GET", "/protocol/booking-slots");
|
||||
const rows = await list.json();
|
||||
assert.ok(!rows.some((s) => s.id === slotBId));
|
||||
});
|
||||
@@ -1894,6 +1894,23 @@
|
||||
},
|
||||
"audit": {
|
||||
"empty": "لا يوجد نشاط."
|
||||
},
|
||||
"slots": {
|
||||
"title": "أوقات الحجز",
|
||||
"hint": "عند إضافة أوقات، يقتصر الحجز (الداخلي والعام) على هذه الأوقات فقط. اتركها فارغة للسماح بأي وقت.",
|
||||
"startTime": "وقت البداية",
|
||||
"duration": "المدة (دقيقة)",
|
||||
"add": "إضافة",
|
||||
"empty": "لا توجد أوقات محددة — الحجز متاح في أي وقت.",
|
||||
"activate": "تفعيل",
|
||||
"deactivate": "تعطيل",
|
||||
"leadTitle": "مهلة الحجز المسبق",
|
||||
"leadHint": "الحد الأدنى بالدقائق قبل بداية الموعد لقبول الطلبات العامة (0 لتعطيل الشرط). لا تنطبق المهلة على موظفي المراسم.",
|
||||
"leadLabel": "المهلة (دقيقة)",
|
||||
"leadSaved": "تم حفظ المهلة",
|
||||
"dateLabel": "التاريخ",
|
||||
"slotLabel": "الوقت",
|
||||
"pickSlot": "اختر الوقت"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1747,6 +1747,23 @@
|
||||
},
|
||||
"audit": {
|
||||
"empty": "No activity yet."
|
||||
},
|
||||
"slots": {
|
||||
"title": "Booking time slots",
|
||||
"hint": "When slots are added, bookings (internal and public) are limited to these times only. Leave empty to allow any time.",
|
||||
"startTime": "Start time",
|
||||
"duration": "Duration (min)",
|
||||
"add": "Add",
|
||||
"empty": "No slots defined — booking is allowed at any time.",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"leadTitle": "Advance booking lead time",
|
||||
"leadHint": "Minimum minutes before the start time for public requests to be accepted (0 disables the rule). Does not apply to protocol staff.",
|
||||
"leadLabel": "Lead time (min)",
|
||||
"leadSaved": "Lead time saved",
|
||||
"dateLabel": "Date",
|
||||
"slotLabel": "Time",
|
||||
"pickSlot": "Pick a time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,50 @@ const API = `${import.meta.env.BASE_URL}api`;
|
||||
|
||||
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
||||
|
||||
// Availability for one room + Riyadh calendar day: the admin-configured slot
|
||||
// grid with per-slot booked / too-soon flags. An empty `slots` array means
|
||||
// the admin hasn't configured slots — the form falls back to free date-time
|
||||
// pickers (legacy mode).
|
||||
type AvailabilitySlot = {
|
||||
id: number;
|
||||
startTime: string; // "HH:mm" Riyadh wall clock
|
||||
durationMinutes: number;
|
||||
booked: boolean;
|
||||
tooSoon: boolean;
|
||||
available: boolean;
|
||||
};
|
||||
type Availability = { minLeadMinutes: number; slots: AvailabilitySlot[] };
|
||||
|
||||
function fmtSlotLabel(startTime: string, durationMinutes: number): string {
|
||||
const [h, m] = startTime.split(":").map(Number);
|
||||
const endTotal = h * 60 + m + durationMinutes;
|
||||
const eh = Math.floor(endTotal / 60) % 24;
|
||||
const em = endTotal % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const to12 = (hh: number, mm: number) => {
|
||||
const period = hh < 12 ? "ص" : "م";
|
||||
let h12 = hh % 12;
|
||||
if (h12 === 0) h12 = 12;
|
||||
return `${h12}:${pad(mm)} ${period}`;
|
||||
};
|
||||
return `${to12(h, m)} – ${to12(eh, em)}`;
|
||||
}
|
||||
|
||||
// Slot start/end as absolute instants. Riyadh has no DST so the fixed
|
||||
// +03:00 offset is always correct.
|
||||
function slotIso(date: string, startTime: string, durationMinutes?: number) {
|
||||
const start = new Date(`${date}T${startTime}:00+03:00`);
|
||||
if (durationMinutes === undefined) return start.toISOString();
|
||||
return new Date(start.getTime() + durationMinutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function todayRiyadh(): string {
|
||||
// en-CA gives YYYY-MM-DD directly.
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Riyadh",
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
type MeetingType = "internal" | "external";
|
||||
type AttendeeSide = "internal" | "external";
|
||||
type AttendeeRow = { name: string; position: string };
|
||||
@@ -39,7 +83,9 @@ type FormState = {
|
||||
attendeeCount: string;
|
||||
purpose: string;
|
||||
roomId: string;
|
||||
startsAt: string;
|
||||
date: string; // YYYY-MM-DD (Riyadh calendar day) — slot mode
|
||||
slotId: string; // selected slot id — slot mode
|
||||
startsAt: string; // legacy free mode (no slots configured)
|
||||
endsAt: string;
|
||||
notes: string;
|
||||
website: string; // honeypot
|
||||
@@ -55,6 +101,8 @@ const EMPTY: FormState = {
|
||||
attendeeCount: "",
|
||||
purpose: "",
|
||||
roomId: "",
|
||||
date: "",
|
||||
slotId: "",
|
||||
startsAt: "",
|
||||
endsAt: "",
|
||||
notes: "",
|
||||
@@ -93,6 +141,44 @@ export default function ProtocolRequestPage() {
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
// Availability (slot grid) for the chosen room + day. When the admin has
|
||||
// configured slots, guests pick a day then one of the returned slots; the
|
||||
// legacy free start/end pickers only render when no slots exist at all.
|
||||
const availability = useQuery<Availability>({
|
||||
queryKey: ["protocol-public", "availability", form.roomId, form.date],
|
||||
enabled: form.roomId !== "" && form.date !== "",
|
||||
queryFn: async () => {
|
||||
const r = await fetch(
|
||||
`${API}/protocol/public/availability?roomId=${encodeURIComponent(
|
||||
form.roomId,
|
||||
)}&date=${encodeURIComponent(form.date)}`,
|
||||
);
|
||||
if (!r.ok) throw new Error("availability");
|
||||
return r.json();
|
||||
},
|
||||
});
|
||||
// Probe with today's date before a day is picked, so we know whether slot
|
||||
// mode is on (the slot list itself is per-day, but its existence is global).
|
||||
const slotProbe = useQuery<Availability>({
|
||||
queryKey: ["protocol-public", "availability-probe", form.roomId],
|
||||
enabled: form.roomId !== "",
|
||||
queryFn: async () => {
|
||||
const r = await fetch(
|
||||
`${API}/protocol/public/availability?roomId=${encodeURIComponent(
|
||||
form.roomId,
|
||||
)}&date=${todayRiyadh()}`,
|
||||
);
|
||||
if (!r.ok) throw new Error("availability");
|
||||
return r.json();
|
||||
},
|
||||
});
|
||||
const slotMode =
|
||||
form.roomId !== "" &&
|
||||
slotProbe.data !== undefined &&
|
||||
slotProbe.data.slots.length > 0;
|
||||
const daySlots = availability.data?.slots ?? [];
|
||||
const selectedSlot = daySlots.find((s) => String(s.id) === form.slotId);
|
||||
// Key attendees ("أبرز الحضور") are two separate lists: من الهيئة (internal)
|
||||
// and خارج الهيئة (external). Each row is a name + position.
|
||||
const [internalAttendees, setInternalAttendees] = useState<AttendeeRow[]>([
|
||||
@@ -139,17 +225,20 @@ export default function ProtocolRequestPage() {
|
||||
const isExternal = form.meetingType === "external";
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
const timeOk = slotMode
|
||||
? form.date !== "" && selectedSlot !== undefined && selectedSlot.available
|
||||
: form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) < new Date(form.endsAt);
|
||||
return (
|
||||
form.requesterName.trim().length > 0 &&
|
||||
form.title.trim().length > 0 &&
|
||||
form.requesterPhone.trim().length >= 3 &&
|
||||
form.roomId !== "" &&
|
||||
form.startsAt !== "" &&
|
||||
form.endsAt !== "" &&
|
||||
new Date(form.startsAt) < new Date(form.endsAt) &&
|
||||
timeOk &&
|
||||
(!isExternal || form.requesterOrg.trim().length > 0)
|
||||
);
|
||||
}, [form, isExternal]);
|
||||
}, [form, isExternal, slotMode, selectedSlot]);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -190,8 +279,18 @@ export default function ProtocolRequestPage() {
|
||||
? null
|
||||
: Number(form.attendeeCount),
|
||||
keyAttendees,
|
||||
startsAt: localToIso(form.startsAt),
|
||||
endsAt: localToIso(form.endsAt),
|
||||
startsAt:
|
||||
slotMode && selectedSlot
|
||||
? slotIso(form.date, selectedSlot.startTime)
|
||||
: localToIso(form.startsAt),
|
||||
endsAt:
|
||||
slotMode && selectedSlot
|
||||
? slotIso(
|
||||
form.date,
|
||||
selectedSlot.startTime,
|
||||
selectedSlot.durationMinutes,
|
||||
)
|
||||
: localToIso(form.endsAt),
|
||||
notes: form.notes.trim() || null,
|
||||
website: form.website,
|
||||
}),
|
||||
@@ -450,6 +549,74 @@ export default function ProtocolRequestPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{slotMode ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookingDate">
|
||||
تاريخ الحجز <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="bookingDate"
|
||||
type="date"
|
||||
min={todayRiyadh()}
|
||||
value={form.date}
|
||||
onChange={(e) => {
|
||||
set("date", e.target.value);
|
||||
set("slotId", "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{form.date !== "" && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
الوقت المتاح <span className="text-rose-500">*</span>
|
||||
</Label>
|
||||
{availability.isLoading && (
|
||||
<p className="text-sm text-slate-500">
|
||||
جارٍ تحميل الأوقات المتاحة…
|
||||
</p>
|
||||
)}
|
||||
{availability.isError && (
|
||||
<p className="text-xs text-rose-500">
|
||||
تعذّر تحميل الأوقات، يرجى المحاولة مرة أخرى.
|
||||
</p>
|
||||
)}
|
||||
{availability.data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{daySlots.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
disabled={!s.available}
|
||||
onClick={() => set("slotId", String(s.id))}
|
||||
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||
form.slotId === String(s.id)
|
||||
? "border-sky-500 bg-sky-50 text-sky-700"
|
||||
: s.available
|
||||
? "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
|
||||
: "border-slate-100 bg-slate-50 text-slate-300 cursor-not-allowed line-through"
|
||||
}`}
|
||||
dir="ltr"
|
||||
>
|
||||
{fmtSlotLabel(s.startTime, s.durationMinutes)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{daySlots.length > 0 &&
|
||||
daySlots.every((s) => !s.available) && (
|
||||
<p className="text-xs text-amber-600">
|
||||
لا توجد أوقات متاحة في هذا اليوم، يرجى اختيار يوم
|
||||
آخر.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="startsAt">
|
||||
@@ -482,6 +649,8 @@ export default function ProtocolRequestPage() {
|
||||
يجب أن يكون وقت البداية قبل وقت النهاية.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>أبرز الحضور</Label>
|
||||
|
||||
@@ -99,6 +99,14 @@ type Room = {
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type Slot = {
|
||||
id: number;
|
||||
startTime: string; // "HH:mm" Riyadh wall clock
|
||||
durationMinutes: number;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
|
||||
type MeetingType = "internal" | "external";
|
||||
type KeyAttendee = {
|
||||
@@ -276,6 +284,45 @@ const BOOKING_BLOCK_STYLE: Record<string, string> = {
|
||||
completed: "bg-emerald-100 border-emerald-500 text-emerald-900",
|
||||
};
|
||||
|
||||
// --- Booking slots (أوقات الحجز) helpers. Slot times are Riyadh wall-clock
|
||||
// "HH:mm"; Riyadh has no DST so a fixed +03:00 offset is always correct.
|
||||
const riyadhTimeFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Riyadh",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
function riyadhHHmm(iso: string): string {
|
||||
return riyadhTimeFmt.format(new Date(iso));
|
||||
}
|
||||
function riyadhYmd(iso: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Riyadh" }).format(
|
||||
new Date(iso),
|
||||
);
|
||||
}
|
||||
function slotToIso(date: string, startTime: string, plusMinutes = 0): string {
|
||||
const start = new Date(`${date}T${startTime}:00+03:00`);
|
||||
return new Date(start.getTime() + plusMinutes * 60_000).toISOString();
|
||||
}
|
||||
function fmtSlotRange(
|
||||
startTime: string,
|
||||
durationMinutes: number,
|
||||
isAr: boolean,
|
||||
): string {
|
||||
const [h, m] = startTime.split(":").map(Number);
|
||||
const endTotal = h * 60 + m + durationMinutes;
|
||||
const eh = Math.floor(endTotal / 60) % 24;
|
||||
const em = endTotal % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const to12 = (hh: number, mm: number) => {
|
||||
const period = hh < 12 ? (isAr ? "ص" : "AM") : isAr ? "م" : "PM";
|
||||
let h12 = hh % 12;
|
||||
if (h12 === 0) h12 = 12;
|
||||
return `${h12}:${pad(mm)} ${period}`;
|
||||
};
|
||||
return `${to12(h, m)} – ${to12(eh, em)}`;
|
||||
}
|
||||
|
||||
function sameYmd(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
@@ -562,6 +609,13 @@ export default function ProtocolPage() {
|
||||
queryKey: ["protocol", "rooms"],
|
||||
queryFn: () => apiJson<Room[]>(`${API}/protocol/rooms`),
|
||||
});
|
||||
// Admin-managed booking slots (أوقات الحجز). Used by the rooms tab admin
|
||||
// section and by the booking dialog (which restricts times to active slots
|
||||
// whenever at least one is configured).
|
||||
const slots = useQuery<Slot[]>({
|
||||
queryKey: ["protocol", "slots"],
|
||||
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
|
||||
});
|
||||
const [bookingSearch, setBookingSearch] = useState("");
|
||||
const [debouncedBookingSearch, setDebouncedBookingSearch] = useState("");
|
||||
useEffect(() => {
|
||||
@@ -1611,6 +1665,14 @@ export default function ProtocolPage() {
|
||||
<EmptyState text={t("protocol.rooms.empty")} />
|
||||
)}
|
||||
</div>
|
||||
{c?.canManageRooms && (
|
||||
<SlotsAdminSection
|
||||
slots={slots.data ?? []}
|
||||
isAr={isAr}
|
||||
onChanged={() => invalidate("slots")}
|
||||
onError={onApiError}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1905,6 +1967,7 @@ export default function ProtocolPage() {
|
||||
{bookingDialog.open && (
|
||||
<BookingDialog
|
||||
rooms={rooms.data ?? []}
|
||||
slots={(slots.data ?? []).filter((s) => s.isActive)}
|
||||
edit={bookingDialog.edit}
|
||||
initialDay={bookingDialog.day}
|
||||
initialStart={bookingDialog.start}
|
||||
@@ -2325,6 +2388,214 @@ function formatDuration(
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// Admin management of booking slots (أوقات الحجز) + the minimum lead-time
|
||||
// setting for public requests. Shown in the rooms tab for canManageRooms.
|
||||
function SlotsAdminSection({
|
||||
slots,
|
||||
isAr,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
slots: Slot[];
|
||||
isAr: boolean;
|
||||
onChanged: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [newTime, setNewTime] = useState("");
|
||||
const [newDuration, setNewDuration] = useState("30");
|
||||
|
||||
const settings = useQuery<{ minLeadMinutes: number }>({
|
||||
queryKey: ["protocol", "settings"],
|
||||
queryFn: () =>
|
||||
apiJson<{ minLeadMinutes: number }>(`${API}/protocol/settings`),
|
||||
});
|
||||
const [leadInput, setLeadInput] = useState<string | null>(null);
|
||||
const leadValue =
|
||||
leadInput ??
|
||||
(settings.data ? String(settings.data.minLeadMinutes) : "");
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiJson(`${API}/protocol/booking-slots`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startTime: newTime,
|
||||
durationMinutes: Number(newDuration),
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewTime("");
|
||||
onChanged();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: (s: Slot) =>
|
||||
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isActive: !s.isActive }),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
});
|
||||
const leadMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiJson(`${API}/protocol/settings`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ minLeadMinutes: Number(leadValue) }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setLeadInput(null);
|
||||
settings.refetch();
|
||||
toast({ title: t("protocol.slots.leadSaved") });
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5;
|
||||
const leadDirty =
|
||||
leadInput !== null &&
|
||||
settings.data !== undefined &&
|
||||
leadInput !== String(settings.data.minLeadMinutes) &&
|
||||
/^\d+$/.test(leadInput);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-700">
|
||||
{t("protocol.slots.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{t("protocol.slots.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">{t("protocol.slots.startTime")}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
dir="ltr"
|
||||
className="w-32"
|
||||
value={newTime}
|
||||
onChange={(e) => setNewTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={5}
|
||||
max={480}
|
||||
step={5}
|
||||
className="w-24"
|
||||
value={newDuration}
|
||||
onChange={(e) => setNewDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canAdd || addMut.isPending}
|
||||
onClick={() => addMut.mutate()}
|
||||
>
|
||||
<Plus size={16} className="me-1" />
|
||||
{t("protocol.slots.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{slots.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-slate-700" dir="ltr">
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
|
||||
s.isActive
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{s.isActive
|
||||
? t("protocol.rooms.active")
|
||||
: t("protocol.rooms.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title={
|
||||
s.isActive
|
||||
? t("protocol.slots.deactivate")
|
||||
: t("protocol.slots.activate")
|
||||
}
|
||||
onClick={() => toggleMut.mutate(s)}
|
||||
>
|
||||
{s.isActive ? <Ban size={14} /> : <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMut.mutate(s.id)}
|
||||
>
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-2">
|
||||
<h3 className="font-semibold text-slate-700">
|
||||
{t("protocol.slots.leadTitle")}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">{t("protocol.slots.leadHint")}</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">{t("protocol.slots.leadLabel")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={10080}
|
||||
className="w-28"
|
||||
value={leadValue}
|
||||
onChange={(e) => setLeadInput(e.target.value)}
|
||||
disabled={settings.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!leadDirty || leadMut.isPending}
|
||||
onClick={() => leadMut.mutate()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="text-center text-slate-400 py-10 text-sm">{text}</div>
|
||||
@@ -2568,6 +2839,7 @@ function DialogShell({
|
||||
|
||||
function BookingDialog({
|
||||
rooms,
|
||||
slots,
|
||||
edit,
|
||||
initialDay,
|
||||
initialStart,
|
||||
@@ -2577,6 +2849,10 @@ function BookingDialog({
|
||||
onError,
|
||||
}: {
|
||||
rooms: Room[];
|
||||
// Active booking slots. When non-empty the dialog restricts times to the
|
||||
// slot grid (same rule the server enforces); when empty it keeps the
|
||||
// legacy free start/end inputs.
|
||||
slots: Slot[];
|
||||
edit?: Booking;
|
||||
initialDay?: Date;
|
||||
initialStart?: Date;
|
||||
@@ -2585,7 +2861,8 @@ function BookingDialog({
|
||||
onSaved: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isAr = i18n.language === "ar";
|
||||
const dayAt = (hour: number) => {
|
||||
if (!initialDay) return "";
|
||||
const d = new Date(initialDay);
|
||||
@@ -2610,6 +2887,25 @@ function BookingDialog({
|
||||
);
|
||||
return dayAt(10);
|
||||
});
|
||||
const slotMode = slots.length > 0;
|
||||
// Slot-mode state: a Riyadh calendar day + one of the active slots.
|
||||
const [slotDate, setSlotDate] = useState(() => {
|
||||
if (edit) return riyadhYmd(edit.startsAt);
|
||||
if (initialStart) return riyadhYmd(initialStart.toISOString());
|
||||
if (initialDay) return riyadhYmd(initialDay.toISOString());
|
||||
return "";
|
||||
});
|
||||
const [slotId, setSlotId] = useState<string>(() => {
|
||||
if (!slotMode) return "";
|
||||
const wanted = edit
|
||||
? riyadhHHmm(edit.startsAt)
|
||||
: initialStart
|
||||
? riyadhHHmm(initialStart.toISOString())
|
||||
: "";
|
||||
const match = slots.find((s) => s.startTime === wanted);
|
||||
return match ? String(match.id) : "";
|
||||
});
|
||||
const selectedSlot = slots.find((s) => String(s.id) === slotId);
|
||||
const [notes, setNotes] = useState(edit?.notes ?? "");
|
||||
const [requesterPhone, setRequesterPhone] = useState(
|
||||
edit?.requesterPhone ?? "",
|
||||
@@ -2668,8 +2964,18 @@ function BookingDialog({
|
||||
attendeeCount.trim() === "" ? null : Number(attendeeCount),
|
||||
purpose: purpose.trim() || null,
|
||||
keyAttendees,
|
||||
startsAt: fromLocalInput(startsAt),
|
||||
endsAt: fromLocalInput(endsAt),
|
||||
startsAt:
|
||||
slotMode && selectedSlot
|
||||
? slotToIso(slotDate, selectedSlot.startTime)
|
||||
: fromLocalInput(startsAt),
|
||||
endsAt:
|
||||
slotMode && selectedSlot
|
||||
? slotToIso(
|
||||
slotDate,
|
||||
selectedSlot.startTime,
|
||||
selectedSlot.durationMinutes,
|
||||
)
|
||||
: fromLocalInput(endsAt),
|
||||
notes: notes || null,
|
||||
};
|
||||
return edit
|
||||
@@ -2690,7 +2996,12 @@ function BookingDialog({
|
||||
<DialogShell
|
||||
title={edit ? t("protocol.bookings.edit") : t("protocol.bookings.new")}
|
||||
onClose={onClose}
|
||||
onSubmit={() => mut.mutate()}
|
||||
onSubmit={() => {
|
||||
// Slot mode: both a date and a slot are required; block the submit
|
||||
// client-side instead of round-tripping to a server invalid_slot.
|
||||
if (slotMode && (!slotDate || !selectedSlot)) return;
|
||||
mut.mutate();
|
||||
}}
|
||||
submitLabel={t("common.save")}
|
||||
saving={mut.isPending}
|
||||
>
|
||||
@@ -2785,6 +3096,38 @@ function BookingDialog({
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{slotMode ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>{t("protocol.slots.dateLabel")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={slotDate}
|
||||
onChange={(e) => setSlotDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("protocol.slots.slotLabel")}</Label>
|
||||
<Select value={slotId} onValueChange={setSlotId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("protocol.slots.pickSlot")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{slots.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
<span dir="ltr">
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>{t("protocol.bookings.startsAt")}</Label>
|
||||
@@ -2805,6 +3148,7 @@ function BookingDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("protocol.bookings.keyAttendees")}</Label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
|
||||
@@ -344,6 +344,66 @@ export const protocolPhotosTable = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bookable time slots (أوقات الحجز).
|
||||
//
|
||||
// Slots are GLOBAL — they define the daily time grid available for booking in
|
||||
// every room (per-room availability still comes from the conflict rule).
|
||||
// Keeping them global was a deliberate simplicity choice: the admin manages
|
||||
// one list of times instead of one list per room, matching how the team
|
||||
// actually schedules. `startTime` is a wall-clock "HH:mm" in Asia/Riyadh.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolBookingSlotsTable = pgTable(
|
||||
"protocol_booking_slots",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
// Wall-clock start time in Asia/Riyadh, canonical "HH:mm" (24h, zero
|
||||
// padded). Uniqueness is enforced so two slots can't start together.
|
||||
startTime: varchar("start_time", { length: 5 }).notNull(),
|
||||
durationMinutes: integer("duration_minutes").notNull().default(30),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdBy: integer("created_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
activeIdx: index("protocol_slots_active_idx").on(t.isActive),
|
||||
startUnique: uniqueIndex("protocol_slots_start_unique").on(t.startTime),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module settings (key/value). Currently only `booking.minLeadMinutes` — the
|
||||
// minimum advance notice (in minutes) required before a PUBLIC booking's
|
||||
// start time. Stored as a string value to keep the table generic.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const protocolSettingsTable = pgTable(
|
||||
"protocol_settings",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
key: varchar("key", { length: 100 }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
updatedBy: integer("updated_by").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
keyUnique: uniqueIndex("protocol_settings_key_unique").on(t.key),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit log (scoped to the protocol module only)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -424,3 +484,13 @@ export type InsertProtocolPhoto = z.infer<typeof insertProtocolPhotoSchema>;
|
||||
export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
|
||||
|
||||
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
|
||||
|
||||
export const insertProtocolBookingSlotSchema = createInsertSchema(
|
||||
protocolBookingSlotsTable,
|
||||
).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export type InsertProtocolBookingSlot = z.infer<
|
||||
typeof insertProtocolBookingSlotSchema
|
||||
>;
|
||||
export type ProtocolBookingSlot = typeof protocolBookingSlotsTable.$inferSelect;
|
||||
|
||||
export type ProtocolSetting = typeof protocolSettingsTable.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user