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
+366 -22
View File
@@ -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,26 +3096,59 @@ function BookingDialog({
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
{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>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<Label>{t("protocol.bookings.startsAt")}</Label>
<Input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.bookings.endsAt")}</Label>
<Input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
required
/>
</div>
</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">