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,
scrollSignal = 0,
onSelect,
onSlotClick,
}: {
bookings: Booking[];
selectedDay: Date;
@@ -364,6 +365,7 @@ function BookingDayGrid({
emptyText: string;
scrollSignal?: number;
onSelect?: (b: Booking) => void;
onSlotClick?: (start: Date) => void;
}) {
const hours: number[] = [];
for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h);
@@ -438,8 +440,30 @@ function BookingDayGrid({
))}
</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 }}
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) => (
<div
@@ -463,7 +487,10 @@ function BookingDayGrid({
<button
key={ev.b.id}
type="button"
onClick={() => onSelect?.(ev.b)}
onClick={(e) => {
e.stopPropagation();
onSelect?.(ev.b);
}}
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",
"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;
edit?: Booking;
day?: Date;
start?: Date;
}>({ open: false });
const [rejectTarget, setRejectTarget] = useState<{
kind: "booking" | "issue" | "external";
@@ -1504,6 +1532,11 @@ export default function ProtocolPage() {
emptyText={t("protocol.bookings.emptyDay")}
scrollSignal={todayScrollSignal}
onSelect={(b) => setDetailsBooking(b)}
onSlotClick={
c?.canRequest
? (start) => setBookingDialog({ open: true, start })
: undefined
}
/>
</div>
</div>
@@ -1874,6 +1907,7 @@ export default function ProtocolPage() {
rooms={rooms.data ?? []}
edit={bookingDialog.edit}
initialDay={bookingDialog.day}
initialStart={bookingDialog.start}
nameOf={nameOf}
onClose={() => setBookingDialog({ open: false })}
onSaved={() => {
@@ -2529,6 +2563,7 @@ function BookingDialog({
rooms,
edit,
initialDay,
initialStart,
nameOf,
onClose,
onSaved,
@@ -2537,6 +2572,7 @@ function BookingDialog({
rooms: Room[];
edit?: Booking;
initialDay?: Date;
initialStart?: Date;
nameOf: (ar: string, en: string) => string;
onClose: () => void;
onSaved: () => void;
@@ -2554,12 +2590,19 @@ function BookingDialog({
edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "",
);
const [requesterName, setRequesterName] = useState(edit?.requesterName ?? "");
const [startsAt, setStartsAt] = useState(
edit ? toLocalInput(edit.startsAt) : dayAt(9),
);
const [endsAt, setEndsAt] = useState(
edit ? toLocalInput(edit.endsAt) : dayAt(10),
);
const [startsAt, setStartsAt] = useState(() => {
if (edit) return toLocalInput(edit.startsAt);
if (initialStart) return toLocalInput(initialStart.toISOString());
return dayAt(9);
});
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 mut = useMutation({