Task #699: الحجز بالنقر على وقت فارغ في تقويم اليوم

- BookingDayGrid (artifacts/tx-os/src/pages/protocol.tsx) now accepts
  onSlotClick?: (start: Date) => void. Clicking the empty grid background
  computes the time from click Y (getBoundingClientRect + clientY), snaps
  down to the nearest 30 minutes, clamps within grid hours (7–22, keeping
  a full hour inside), and calls onSlotClick with the resulting Date on
  the selected day. Cursor becomes pointer when handler is set.
- Booking blocks now stopPropagation on click so existing behavior
  (opening the details dialog) is unchanged and never double-triggers.
- ProtocolPage passes onSlotClick only when capabilities.canRequest,
  opening the existing BookingDialog (same form as the "حجز +" button)
  with the new start prefilled.
- BookingDialog: new optional initialStart prop; startsAt = initialStart,
  endsAt = initialStart + 1h (falls back to prior 9–10 defaults).
- Verified: tsc --noEmit clean in artifacts/tx-os; architect review Pass.
This commit is contained in:
Replit Agent
2026-07-08 13:27:35 +00:00
parent 8105c487ef
commit 0fc96b544b
+51 -8
View File
@@ -355,6 +355,7 @@ function BookingDayGrid({
emptyText, emptyText,
scrollSignal = 0, scrollSignal = 0,
onSelect, onSelect,
onSlotClick,
}: { }: {
bookings: Booking[]; bookings: Booking[];
selectedDay: Date; selectedDay: Date;
@@ -364,6 +365,7 @@ function BookingDayGrid({
emptyText: string; emptyText: string;
scrollSignal?: number; scrollSignal?: number;
onSelect?: (b: Booking) => void; onSelect?: (b: Booking) => void;
onSlotClick?: (start: Date) => void;
}) { }) {
const hours: number[] = []; const hours: number[] = [];
for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h); for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h);
@@ -438,8 +440,30 @@ function BookingDayGrid({
))} ))}
</div> </div>
<div <div
className="relative flex-1 border-s border-slate-200" className={cn(
"relative flex-1 border-s border-slate-200",
onSlotClick && "cursor-pointer",
)}
style={{ height: gridHeight }} style={{ height: gridHeight }}
onClick={(e) => {
if (!onSlotClick) return;
// Only clicks on the empty grid itself — booking blocks stop
// propagation in their own handler.
const rect = e.currentTarget.getBoundingClientRect();
const offsetY = e.clientY - rect.top;
const rawMin =
GRID_HOUR_START * 60 + (offsetY / GRID_PX_PER_HOUR) * 60;
// Snap down to the nearest 30 minutes, keep a full hour inside
// the visible grid range.
const snapped = Math.floor(rawMin / 30) * 30;
const startMin = Math.min(
Math.max(snapped, GRID_HOUR_START * 60),
GRID_HOUR_END * 60 - 60,
);
const start = new Date(selectedDay);
start.setHours(Math.floor(startMin / 60), startMin % 60, 0, 0);
onSlotClick(start);
}}
> >
{hours.map((h) => ( {hours.map((h) => (
<div <div
@@ -463,7 +487,10 @@ function BookingDayGrid({
<button <button
key={ev.b.id} key={ev.b.id}
type="button" type="button"
onClick={() => onSelect?.(ev.b)} onClick={(e) => {
e.stopPropagation();
onSelect?.(ev.b);
}}
className={cn( className={cn(
"absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-start text-[11px] leading-tight shadow-sm transition-shadow", "absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-start text-[11px] leading-tight shadow-sm transition-shadow",
"cursor-pointer hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", "cursor-pointer hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500",
@@ -628,6 +655,7 @@ export default function ProtocolPage() {
open: boolean; open: boolean;
edit?: Booking; edit?: Booking;
day?: Date; day?: Date;
start?: Date;
}>({ open: false }); }>({ open: false });
const [rejectTarget, setRejectTarget] = useState<{ const [rejectTarget, setRejectTarget] = useState<{
kind: "booking" | "issue" | "external"; kind: "booking" | "issue" | "external";
@@ -1504,6 +1532,11 @@ export default function ProtocolPage() {
emptyText={t("protocol.bookings.emptyDay")} emptyText={t("protocol.bookings.emptyDay")}
scrollSignal={todayScrollSignal} scrollSignal={todayScrollSignal}
onSelect={(b) => setDetailsBooking(b)} onSelect={(b) => setDetailsBooking(b)}
onSlotClick={
c?.canRequest
? (start) => setBookingDialog({ open: true, start })
: undefined
}
/> />
</div> </div>
</div> </div>
@@ -1874,6 +1907,7 @@ export default function ProtocolPage() {
rooms={rooms.data ?? []} rooms={rooms.data ?? []}
edit={bookingDialog.edit} edit={bookingDialog.edit}
initialDay={bookingDialog.day} initialDay={bookingDialog.day}
initialStart={bookingDialog.start}
nameOf={nameOf} nameOf={nameOf}
onClose={() => setBookingDialog({ open: false })} onClose={() => setBookingDialog({ open: false })}
onSaved={() => { onSaved={() => {
@@ -2529,6 +2563,7 @@ function BookingDialog({
rooms, rooms,
edit, edit,
initialDay, initialDay,
initialStart,
nameOf, nameOf,
onClose, onClose,
onSaved, onSaved,
@@ -2537,6 +2572,7 @@ function BookingDialog({
rooms: Room[]; rooms: Room[];
edit?: Booking; edit?: Booking;
initialDay?: Date; initialDay?: Date;
initialStart?: Date;
nameOf: (ar: string, en: string) => string; nameOf: (ar: string, en: string) => string;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
@@ -2554,12 +2590,19 @@ function BookingDialog({
edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "", edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "",
); );
const [requesterName, setRequesterName] = useState(edit?.requesterName ?? ""); const [requesterName, setRequesterName] = useState(edit?.requesterName ?? "");
const [startsAt, setStartsAt] = useState( const [startsAt, setStartsAt] = useState(() => {
edit ? toLocalInput(edit.startsAt) : dayAt(9), if (edit) return toLocalInput(edit.startsAt);
); if (initialStart) return toLocalInput(initialStart.toISOString());
const [endsAt, setEndsAt] = useState( return dayAt(9);
edit ? toLocalInput(edit.endsAt) : dayAt(10), });
); const [endsAt, setEndsAt] = useState(() => {
if (edit) return toLocalInput(edit.endsAt);
if (initialStart)
return toLocalInput(
new Date(initialStart.getTime() + 60 * 60 * 1000).toISOString(),
);
return dayAt(10);
});
const [notes, setNotes] = useState(edit?.notes ?? ""); const [notes, setNotes] = useState(edit?.notes ?? "");
const mut = useMutation({ const mut = useMutation({