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