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:
@@ -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,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user