2026-07-06 11:18:14 +00:00
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
2026-07-07 12:26:19 +00:00
|
|
|
|
import {
|
|
|
|
|
|
CalendarClock,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
|
} from "lucide-react";
|
2026-07-06 11:18:14 +00:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select";
|
2026-07-06 11:40:36 +00:00
|
|
|
|
import { DateTimePicker } from "@/components/date-time-picker";
|
2026-07-09 06:03:40 +00:00
|
|
|
|
import {
|
|
|
|
|
|
AttendeeTableEditor,
|
|
|
|
|
|
emptyAttendeeRow,
|
|
|
|
|
|
syncAttendeeRows,
|
|
|
|
|
|
type EditableAttendeeRow,
|
|
|
|
|
|
} from "@/components/attendee-table-editor";
|
2026-07-06 11:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
const API = `${import.meta.env.BASE_URL}api`;
|
|
|
|
|
|
|
|
|
|
|
|
type PublicRoom = { id: number; nameAr: string; nameEn: string };
|
|
|
|
|
|
|
2026-07-08 14:43:44 +00:00
|
|
|
|
// 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 12:26:19 +00:00
|
|
|
|
type MeetingType = "internal" | "external";
|
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
|
type FormState = {
|
|
|
|
|
|
requesterName: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
requesterPhone: string;
|
2026-07-07 12:26:19 +00:00
|
|
|
|
department: string;
|
|
|
|
|
|
meetingType: MeetingType;
|
2026-07-06 11:18:14 +00:00
|
|
|
|
requesterOrg: string;
|
2026-07-06 11:33:58 +00:00
|
|
|
|
attendeeCount: string;
|
2026-07-07 12:26:19 +00:00
|
|
|
|
purpose: string;
|
2026-07-06 11:18:14 +00:00
|
|
|
|
roomId: string;
|
2026-07-08 14:43:44 +00:00
|
|
|
|
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)
|
2026-07-06 11:18:14 +00:00
|
|
|
|
endsAt: string;
|
|
|
|
|
|
notes: string;
|
|
|
|
|
|
website: string; // honeypot
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const EMPTY: FormState = {
|
|
|
|
|
|
requesterName: "",
|
|
|
|
|
|
title: "",
|
|
|
|
|
|
requesterPhone: "",
|
2026-07-07 12:26:19 +00:00
|
|
|
|
department: "",
|
|
|
|
|
|
meetingType: "internal",
|
2026-07-06 11:18:14 +00:00
|
|
|
|
requesterOrg: "",
|
2026-07-06 11:33:58 +00:00
|
|
|
|
attendeeCount: "",
|
2026-07-07 12:26:19 +00:00
|
|
|
|
purpose: "",
|
2026-07-06 11:18:14 +00:00
|
|
|
|
roomId: "",
|
2026-07-08 14:43:44 +00:00
|
|
|
|
date: "",
|
|
|
|
|
|
slotId: "",
|
2026-07-06 11:18:14 +00:00
|
|
|
|
startsAt: "",
|
|
|
|
|
|
endsAt: "",
|
|
|
|
|
|
notes: "",
|
|
|
|
|
|
website: "",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function localToIso(v: string): string {
|
|
|
|
|
|
return new Date(v).toISOString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function ProtocolRequestPage() {
|
|
|
|
|
|
// This page is public (no auth). Force RTL Arabic regardless of any
|
|
|
|
|
|
// stored language preference so a guest always sees the intended form.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const prevDir = document.documentElement.dir;
|
|
|
|
|
|
const prevLang = document.documentElement.lang;
|
|
|
|
|
|
document.documentElement.dir = "rtl";
|
|
|
|
|
|
document.documentElement.lang = "ar";
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
// Restore the app's direction/language so navigating away from this
|
|
|
|
|
|
// public page doesn't leave the rest of the app forced into RTL Arabic.
|
|
|
|
|
|
document.documentElement.dir = prevDir;
|
|
|
|
|
|
document.documentElement.lang = prevLang;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const rooms = useQuery<PublicRoom[]>({
|
|
|
|
|
|
queryKey: ["protocol-public", "rooms"],
|
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
|
const r = await fetch(`${API}/protocol/public/rooms`);
|
|
|
|
|
|
if (!r.ok) throw new Error("rooms");
|
|
|
|
|
|
return r.json();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const [form, setForm] = useState<FormState>(EMPTY);
|
2026-07-08 14:43:44 +00:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-07-09 06:03:40 +00:00
|
|
|
|
// Key attendees ("أبرز الحضور"): one numbered table where each row carries
|
|
|
|
|
|
// its own side (من الهيئة / خارج الهيئة). Row count follows the expected
|
|
|
|
|
|
// attendee count field.
|
|
|
|
|
|
const [attendees, setAttendees] = useState<EditableAttendeeRow[]>([
|
|
|
|
|
|
emptyAttendeeRow(),
|
2026-07-07 12:26:19 +00:00
|
|
|
|
]);
|
2026-07-06 11:18:14 +00:00
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
const [done, setDone] = useState(false);
|
2026-07-07 12:26:19 +00:00
|
|
|
|
const [reference, setReference] = useState<string | null>(null);
|
2026-07-06 11:18:14 +00:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
|
|
|
|
|
setForm((f) => ({ ...f, [k]: v }));
|
|
|
|
|
|
|
2026-07-09 06:03:40 +00:00
|
|
|
|
// Auto-generate table rows to match "العدد المتوقع للحضور".
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const n =
|
|
|
|
|
|
form.attendeeCount.trim() === "" ? null : Number(form.attendeeCount);
|
|
|
|
|
|
setAttendees((rs) => syncAttendeeRows(rs, n));
|
|
|
|
|
|
}, [form.attendeeCount]);
|
2026-07-07 12:26:19 +00:00
|
|
|
|
|
|
|
|
|
|
const isExternal = form.meetingType === "external";
|
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
|
const canSubmit = useMemo(() => {
|
2026-07-08 14:43:44 +00:00
|
|
|
|
const timeOk = slotMode
|
|
|
|
|
|
? form.date !== "" && selectedSlot !== undefined && selectedSlot.available
|
|
|
|
|
|
: form.startsAt !== "" &&
|
|
|
|
|
|
form.endsAt !== "" &&
|
|
|
|
|
|
new Date(form.startsAt) < new Date(form.endsAt);
|
2026-07-06 11:18:14 +00:00
|
|
|
|
return (
|
|
|
|
|
|
form.requesterName.trim().length > 0 &&
|
|
|
|
|
|
form.title.trim().length > 0 &&
|
|
|
|
|
|
form.requesterPhone.trim().length >= 3 &&
|
|
|
|
|
|
form.roomId !== "" &&
|
2026-07-08 14:43:44 +00:00
|
|
|
|
timeOk &&
|
2026-07-07 12:26:19 +00:00
|
|
|
|
(!isExternal || form.requesterOrg.trim().length > 0)
|
2026-07-06 11:18:14 +00:00
|
|
|
|
);
|
2026-07-08 14:43:44 +00:00
|
|
|
|
}, [form, isExternal, slotMode, selectedSlot]);
|
2026-07-06 11:18:14 +00:00
|
|
|
|
|
|
|
|
|
|
const onSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
if (!canSubmit || submitting) return;
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
2026-07-09 06:03:40 +00:00
|
|
|
|
const keyAttendees = attendees
|
|
|
|
|
|
.filter((r) => r.name.trim() !== "")
|
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
|
name: r.name.trim(),
|
|
|
|
|
|
position: r.position.trim(),
|
|
|
|
|
|
side: r.side,
|
|
|
|
|
|
}));
|
2026-07-06 11:18:14 +00:00
|
|
|
|
const res = await fetch(`${API}/protocol/public/booking-requests`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
roomId: Number(form.roomId),
|
|
|
|
|
|
title: form.title.trim(),
|
|
|
|
|
|
requesterName: form.requesterName.trim(),
|
|
|
|
|
|
requesterPhone: form.requesterPhone.trim(),
|
2026-07-07 12:26:19 +00:00
|
|
|
|
department: form.department.trim() || null,
|
|
|
|
|
|
meetingType: form.meetingType,
|
|
|
|
|
|
requesterOrg: isExternal ? form.requesterOrg.trim() : null,
|
|
|
|
|
|
purpose: form.purpose.trim() || null,
|
2026-07-06 11:33:58 +00:00
|
|
|
|
attendeeCount:
|
|
|
|
|
|
form.attendeeCount.trim() === ""
|
|
|
|
|
|
? null
|
|
|
|
|
|
: Number(form.attendeeCount),
|
2026-07-07 12:26:19 +00:00
|
|
|
|
keyAttendees,
|
2026-07-08 14:43:44 +00:00
|
|
|
|
startsAt:
|
|
|
|
|
|
slotMode && selectedSlot
|
|
|
|
|
|
? slotIso(form.date, selectedSlot.startTime)
|
|
|
|
|
|
: localToIso(form.startsAt),
|
|
|
|
|
|
endsAt:
|
|
|
|
|
|
slotMode && selectedSlot
|
|
|
|
|
|
? slotIso(
|
|
|
|
|
|
form.date,
|
|
|
|
|
|
selectedSlot.startTime,
|
|
|
|
|
|
selectedSlot.durationMinutes,
|
|
|
|
|
|
)
|
|
|
|
|
|
: localToIso(form.endsAt),
|
2026-07-06 11:18:14 +00:00
|
|
|
|
notes: form.notes.trim() || null,
|
|
|
|
|
|
website: form.website,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.ok) {
|
2026-07-07 12:26:19 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data?.bookingReference && typeof data.bookingReference === "string") {
|
|
|
|
|
|
setReference(data.bookingReference);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// no reference in body — still a success
|
|
|
|
|
|
}
|
2026-07-06 11:18:14 +00:00
|
|
|
|
setDone(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let msg = "تعذّر إرسال الطلب، يرجى المحاولة مرة أخرى.";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (data?.error && typeof data.error === "string") msg = data.error;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// keep default message
|
|
|
|
|
|
}
|
|
|
|
|
|
if (res.status === 429) {
|
|
|
|
|
|
msg = "لقد أرسلت طلبات كثيرة، يرجى المحاولة لاحقاً.";
|
|
|
|
|
|
}
|
|
|
|
|
|
setError(msg);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError("تعذّر الاتصال بالخادم، تحقق من الاتصال وحاول مجدداً.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-07 12:26:19 +00:00
|
|
|
|
const resetForm = () => {
|
|
|
|
|
|
setForm(EMPTY);
|
2026-07-09 06:03:40 +00:00
|
|
|
|
setAttendees([emptyAttendeeRow()]);
|
2026-07-07 12:26:19 +00:00
|
|
|
|
setReference(null);
|
|
|
|
|
|
setDone(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
|
if (done) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
dir="rtl"
|
|
|
|
|
|
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="w-full max-w-md bg-white rounded-2xl border border-slate-200 shadow-sm p-8 text-center">
|
|
|
|
|
|
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto mb-4" />
|
|
|
|
|
|
<h1 className="text-xl font-bold text-slate-800 mb-2">
|
|
|
|
|
|
تم استلام طلبك
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="text-slate-500 leading-relaxed">
|
|
|
|
|
|
سيقوم فريق المراسم بمراجعة طلب الحجز والتواصل معك على رقم الهاتف
|
|
|
|
|
|
المُدخل لتأكيد الموعد.
|
|
|
|
|
|
</p>
|
2026-07-07 12:26:19 +00:00
|
|
|
|
{reference && (
|
|
|
|
|
|
<div className="mt-5 rounded-xl bg-sky-50 border border-sky-200 px-4 py-4">
|
|
|
|
|
|
<p className="text-sm text-slate-500 mb-1">رقم الحجز</p>
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="text-2xl font-bold tracking-wider text-sky-700"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
>
|
|
|
|
|
|
{reference}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="text-xs text-slate-500 mt-2">
|
|
|
|
|
|
احتفظ بهذا الرقم لمتابعة حالة طلبك.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button className="mt-6" variant="outline" onClick={resetForm}>
|
2026-07-06 11:18:14 +00:00
|
|
|
|
إرسال طلب آخر
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div dir="rtl" className="min-h-screen bg-slate-50 py-8 px-4">
|
|
|
|
|
|
<div className="w-full max-w-xl mx-auto">
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-6">
|
|
|
|
|
|
<div className="w-11 h-11 rounded-xl bg-sky-500/10 text-sky-600 flex items-center justify-center">
|
|
|
|
|
|
<CalendarClock className="w-6 h-6" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-xl font-bold text-slate-800">طلب حجز قاعة</h1>
|
|
|
|
|
|
<p className="text-sm text-slate-500">
|
|
|
|
|
|
يرجى تعبئة البيانات وسيتواصل معك فريق المراسم للتأكيد.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<form
|
|
|
|
|
|
onSubmit={onSubmit}
|
|
|
|
|
|
className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 space-y-5"
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Honeypot: hidden from real users, catches bots. */}
|
|
|
|
|
|
<div className="hidden" aria-hidden="true">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
Website
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
value={form.website}
|
|
|
|
|
|
onChange={(e) => set("website", e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="requesterName">
|
2026-07-07 12:26:19 +00:00
|
|
|
|
الاسم <span className="text-rose-500">*</span>
|
2026-07-06 11:18:14 +00:00
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="requesterName"
|
|
|
|
|
|
value={form.requesterName}
|
|
|
|
|
|
onChange={(e) => set("requesterName", e.target.value)}
|
|
|
|
|
|
placeholder="الاسم الثلاثي"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="requesterPhone">
|
|
|
|
|
|
رقم الهاتف للتواصل <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="requesterPhone"
|
|
|
|
|
|
type="tel"
|
|
|
|
|
|
inputMode="tel"
|
|
|
|
|
|
dir="ltr"
|
|
|
|
|
|
className="text-right"
|
|
|
|
|
|
value={form.requesterPhone}
|
|
|
|
|
|
onChange={(e) => set("requesterPhone", e.target.value)}
|
|
|
|
|
|
placeholder="05xxxxxxxx"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="room">
|
|
|
|
|
|
القاعة <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
2026-07-08 15:04:12 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
value={form.roomId}
|
|
|
|
|
|
onValueChange={(v) =>
|
|
|
|
|
|
// Reset the time selection when the room changes so no stale
|
|
|
|
|
|
// date/slot/free-time values survive a room switch (the slot
|
|
|
|
|
|
// grid is per-room and slot mode itself may flip).
|
|
|
|
|
|
setForm((f) => ({
|
|
|
|
|
|
...f,
|
|
|
|
|
|
roomId: v,
|
|
|
|
|
|
date: "",
|
|
|
|
|
|
slotId: "",
|
|
|
|
|
|
startsAt: "",
|
|
|
|
|
|
endsAt: "",
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-07-06 11:18:14 +00:00
|
|
|
|
<SelectTrigger id="room">
|
|
|
|
|
|
<SelectValue
|
|
|
|
|
|
placeholder={
|
|
|
|
|
|
rooms.isLoading ? "جارٍ التحميل…" : "اختر القاعة"
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{(rooms.data ?? []).map((r) => (
|
|
|
|
|
|
<SelectItem key={r.id} value={String(r.id)}>
|
|
|
|
|
|
{r.nameAr || r.nameEn}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
{rooms.isError && (
|
|
|
|
|
|
<p className="text-xs text-rose-500">
|
|
|
|
|
|
تعذّر تحميل قائمة القاعات، يرجى تحديث الصفحة.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-07 12:26:19 +00:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="department">الإدارة</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="department"
|
|
|
|
|
|
value={form.department}
|
|
|
|
|
|
onChange={(e) => set("department", e.target.value)}
|
|
|
|
|
|
placeholder="الإدارة أو القسم (اختياري)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="title">
|
|
|
|
|
|
عنوان الاجتماع <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="title"
|
|
|
|
|
|
value={form.title}
|
|
|
|
|
|
onChange={(e) => set("title", e.target.value)}
|
|
|
|
|
|
placeholder="موضوع أو عنوان الاجتماع"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>
|
|
|
|
|
|
نوع الاجتماع <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ value: "internal", label: "اجتماع داخلي" },
|
|
|
|
|
|
{ value: "external", label: "اجتماع مع جهة خارجية" },
|
|
|
|
|
|
] as const
|
|
|
|
|
|
).map((opt) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => set("meetingType", opt.value)}
|
|
|
|
|
|
className={`rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors ${
|
|
|
|
|
|
form.meetingType === opt.value
|
|
|
|
|
|
? "border-sky-500 bg-sky-50 text-sky-700"
|
|
|
|
|
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{opt.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isExternal && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="requesterOrg">
|
|
|
|
|
|
اسم الجهة <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="requesterOrg"
|
|
|
|
|
|
value={form.requesterOrg}
|
|
|
|
|
|
onChange={(e) => set("requesterOrg", e.target.value)}
|
|
|
|
|
|
placeholder="اسم الجهة الخارجية"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="attendeeCount">العدد المتوقع للحضور</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="attendeeCount"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
value={form.attendeeCount}
|
|
|
|
|
|
onChange={(e) => set("attendeeCount", e.target.value)}
|
|
|
|
|
|
placeholder="العدد المتوقع للحضور (اختياري)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="purpose">الغرض من الاجتماع</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="purpose"
|
|
|
|
|
|
value={form.purpose}
|
|
|
|
|
|
onChange={(e) => set("purpose", e.target.value)}
|
|
|
|
|
|
placeholder="اشرح الغرض من الاجتماع (اختياري)"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-08 15:04:12 +00:00
|
|
|
|
{form.roomId === "" ? (
|
|
|
|
|
|
// No room chosen yet → we don't know whether slot mode is on, so
|
|
|
|
|
|
// show a hint instead of the (possibly wrong) free pickers.
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>
|
|
|
|
|
|
موعد الحجز <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-500">
|
|
|
|
|
|
اختر القاعة أولًا لعرض المواعيد المتاحة.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : slotProbe.isLoading ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>
|
|
|
|
|
|
موعد الحجز <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-sm text-slate-500">
|
|
|
|
|
|
جارٍ تحميل المواعيد المتاحة…
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : slotProbe.isError ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>
|
|
|
|
|
|
موعد الحجز <span className="text-rose-500">*</span>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<p className="text-xs text-rose-500">
|
|
|
|
|
|
تعذّر تحميل المواعيد، يرجى المحاولة مرة أخرى.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : slotMode ? (
|
2026-07-08 14:43:44 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<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"
|
|
|
|
|
|
}`}
|
2026-07-08 17:41:09 +00:00
|
|
|
|
dir="rtl"
|
2026-07-08 14:43:44 +00:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-06 11:18:14 +00:00
|
|
|
|
|
2026-07-07 12:26:19 +00:00
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<Label>أبرز الحضور</Label>
|
2026-07-09 06:03:40 +00:00
|
|
|
|
<AttendeeTableEditor
|
|
|
|
|
|
rows={attendees}
|
|
|
|
|
|
setRows={setAttendees}
|
|
|
|
|
|
labels={{
|
|
|
|
|
|
name: "الاسم",
|
|
|
|
|
|
position: "المنصب",
|
|
|
|
|
|
side: "الجهة",
|
|
|
|
|
|
sideInternal: "من الهيئة",
|
|
|
|
|
|
sideExternal: "خارج الهيئة",
|
|
|
|
|
|
addRow: "إضافة صف",
|
|
|
|
|
|
removeRow: "حذف الصف",
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-07-07 12:26:19 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="notes">ملاحظات</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="notes"
|
|
|
|
|
|
value={form.notes}
|
|
|
|
|
|
onChange={(e) => set("notes", e.target.value)}
|
|
|
|
|
|
placeholder="أي تفاصيل إضافية (اختياري)"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-07 12:26:19 +00:00
|
|
|
|
<div className="flex items-start gap-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-3">
|
|
|
|
|
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
|
|
|
|
<span>
|
|
|
|
|
|
للإحاطة: في حال الحاجة للقاعة سيتم إلغاء الاجتماع أو تعديل موقعه.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-06 11:18:14 +00:00
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
disabled={!canSubmit || submitting}
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
|
|
|
|
إرسال الطلب
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|