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:
Replit Agent
2026-07-08 14:43:44 +00:00
parent d48e434400
commit f3fe741314
7 changed files with 1539 additions and 61 deletions
+208 -39
View File
@@ -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,38 +549,108 @@ export default function ProtocolRequestPage() {
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startsAt">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
{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">
من <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="startsAt"
value={form.startsAt}
onChange={(v) => set("startsAt", v)}
placeholder="اختر تاريخ ووقت البداية"
/>
</div>
<div className="space-y-2">
<Label htmlFor="endsAt">
إلى <span className="text-rose-500">*</span>
</Label>
<DateTimePicker
id="endsAt"
value={form.endsAt}
onChange={(v) => set("endsAt", v)}
placeholder="اختر تاريخ ووقت النهاية"
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
/>
</div>
</div>
{form.startsAt !== "" &&
form.endsAt !== "" &&
new Date(form.startsAt) >= new Date(form.endsAt) && (
<p className="text-xs text-rose-500 -mt-2">
يجب أن يكون وقت البداية قبل وقت النهاية.
</p>
)}
</>
)}
<div className="space-y-3">
<Label>أبرز الحضور</Label>