diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 6fae747d..7afc5cfe 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1723,6 +1723,7 @@ "endsAt": "إلى", "viewList": "قائمة", "viewCalendar": "تقويم", + "emptyDay": "لا توجد حجوزات في هذا اليوم.", "shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)", "copyLink": "نسخ الرابط", "linkCopied": "تم نسخ الرابط", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index fb2da3ba..75c2c6e3 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1596,6 +1596,7 @@ "endsAt": "To", "viewList": "List", "viewCalendar": "Calendar", + "emptyDay": "No bookings on this day.", "shareLinkTitle": "Public booking link (share with guests)", "copyLink": "Copy link", "linkCopied": "Link copied", diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index 3ebcee7b..7051cb21 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -57,6 +57,8 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { AppDock } from "@/components/app-dock"; +import { Calendar } from "@/components/ui/calendar"; +import { useNow } from "@/components/clock"; import { useToast } from "@/hooks/use-toast"; import { apiJson, ApiError } from "@/lib/api-json"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; @@ -226,36 +228,6 @@ function fromLocalInput(v: string): string { return new Date(v).toISOString(); } -// Group bookings by calendar day (ascending) for the calendar view. -function groupBookingsByDay( - items: Booking[], - isAr: boolean, -): Array<{ key: string; label: string; items: Booking[] }> { - const map = new Map(); - for (const b of items) { - const d = new Date(b.startsAt); - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart( - 2, - "0", - )}-${String(d.getDate()).padStart(2, "0")}`; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(b); - } - return Array.from(map.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, group]) => ({ - key, - label: new Date(`${key}T00:00:00`).toLocaleDateString( - isAr ? "ar" : "en", - { weekday: "long", year: "numeric", month: "long", day: "numeric" }, - ), - items: group.sort( - (a, b) => - new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(), - ), - })); -} - const STATUS_PILL: Record = { pending: "bg-amber-100 text-amber-800", approved: "bg-emerald-100 text-emerald-700", @@ -266,6 +238,225 @@ const STATUS_PILL: Record = { completed: "bg-emerald-100 text-emerald-700", }; +// ---- Day time-grid (Google-Calendar style) constants & helpers ---- +const GRID_HOUR_START = 7; // 7 AM +const GRID_HOUR_END = 22; // 10 PM +const GRID_PX_PER_HOUR = 56; + +// Block fill + inline-start accent border, colored by booking status. +const BOOKING_BLOCK_STYLE: Record = { + pending: "bg-amber-100 border-amber-400 text-amber-900", + approved: "bg-emerald-100 border-emerald-500 text-emerald-900", + issued: "bg-emerald-100 border-emerald-500 text-emerald-900", + rejected: "bg-rose-100 border-rose-400 text-rose-900", + cancelled: "bg-slate-100 border-slate-300 text-slate-500", + scheduled: "bg-sky-100 border-sky-400 text-sky-900", + completed: "bg-emerald-100 border-emerald-500 text-emerald-900", +}; + +function sameYmd(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function minutesOfDay(iso: string): number { + const d = new Date(iso); + return d.getHours() * 60 + d.getMinutes(); +} + +type PlacedBooking = { + b: Booking; + s: number; // start minutes-of-day + e: number; // end minutes-of-day (>= s + 15) + col: number; + cols: number; +}; + +// Assign side-by-side columns to overlapping bookings (interval +// partitioning), clustering chained overlaps so each cluster shares a +// column count. View-only layout — no drag/resize. +function layoutDayBookings(items: Booking[]): PlacedBooking[] { + const evs = items + .map((b) => { + const s = minutesOfDay(b.startsAt); + const e = Math.max(minutesOfDay(b.endsAt), s + 15); + return { b, s, e, col: 0, cols: 1 }; + }) + .sort((a, b) => a.s - b.s || a.e - b.e); + + const out: PlacedBooking[] = []; + let i = 0; + while (i < evs.length) { + let j = i + 1; + let clusterEnd = evs[i].e; + const cluster = [evs[i]]; + while (j < evs.length && evs[j].s < clusterEnd) { + cluster.push(evs[j]); + clusterEnd = Math.max(clusterEnd, evs[j].e); + j++; + } + const colEnds: number[] = []; + for (const ev of cluster) { + let placed = false; + for (let c = 0; c < colEnds.length; c++) { + if (colEnds[c] <= ev.s) { + ev.col = c; + colEnds[c] = ev.e; + placed = true; + break; + } + } + if (!placed) { + ev.col = colEnds.length; + colEnds.push(ev.e); + } + } + const cols = colEnds.length; + for (const ev of cluster) { + ev.cols = cols; + out.push(ev); + } + i = j; + } + return out; +} + +function BookingDayGrid({ + bookings, + selectedDay, + now, + isAr, + roomName, + emptyText, +}: { + bookings: Booking[]; + selectedDay: Date; + now: Date; + isAr: boolean; + roomName: (id: number) => string; + emptyText: string; +}) { + const hours: number[] = []; + for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h); + + const dayItems = bookings.filter((b) => + sameYmd(new Date(b.startsAt), selectedDay), + ); + const placed = layoutDayBookings(dayItems); + + const isToday = sameYmd(now, selectedDay); + const nowMin = now.getHours() * 60 + now.getMinutes(); + const showNow = + isToday && nowMin >= GRID_HOUR_START * 60 && nowMin <= GRID_HOUR_END * 60; + const nowTop = ((nowMin - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR; + const gridHeight = (GRID_HOUR_END - GRID_HOUR_START) * GRID_PX_PER_HOUR; + + const hourLabel = (h: number) => { + const d = new Date(); + d.setHours(h, 0, 0, 0); + return new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", { + hour: "numeric", + hour12: true, + }).format(d); + }; + const timeLabel = (iso: string) => + new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(new Date(iso)); + + return ( +
+ {dayItems.length === 0 && ( +
+ {emptyText} +
+ )} +
+
+ {hours.map((h) => ( +
+ {hourLabel(h)} +
+ ))} +
+
+ {hours.map((h) => ( +
+ ))} + {placed.map((ev) => { + const topM = Math.max(ev.s, GRID_HOUR_START * 60); + const botM = Math.min(ev.e, GRID_HOUR_END * 60); + // Skip bookings with no visible intersection with the grid range. + if (botM <= topM) return null; + const top = ((topM - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR; + const height = Math.max( + ((botM - topM) / 60) * GRID_PX_PER_HOUR, + 22, + ); + const widthPct = 100 / ev.cols; + return ( +
+
{ev.b.title}
+
+ {roomName(ev.b.roomId)} +
+ {height >= 44 && ( +
+ {timeLabel(ev.b.startsAt)} – {timeLabel(ev.b.endsAt)} +
+ )} +
+ ); + })} + {showNow && ( +
+
+
+
+
+ )} +
+
+
+ ); +} + export default function ProtocolPage() { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -371,6 +562,18 @@ export default function ProtocolPage() { id: number; } | null>(null); const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list"); + const [selectedDay, setSelectedDay] = useState(() => new Date()); + const now = useNow(60000); + const bookedDates = useMemo(() => { + const map = new Map(); + for (const b of bookings.data ?? []) { + const d = new Date(b.startsAt); + const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; + if (!map.has(key)) + map.set(key, new Date(d.getFullYear(), d.getMonth(), d.getDate())); + } + return Array.from(map.values()); + }, [bookings.data]); const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`; const [linkCopied, setLinkCopied] = useState(false); const linkCopiedTimer = useRef(null); @@ -757,17 +960,43 @@ export default function ProtocolPage() { {(bookings.data ?? []).map((b) => renderBookingCard(b))}
) : ( -
- {groupBookingsByDay(bookings.data ?? [], isAr).map((group) => ( -
-
- {group.label} -
-
- {group.items.map((b) => renderBookingCard(b))} -
+
+
+
+ d && setSelectedDay(d)} + defaultMonth={selectedDay} + modifiers={{ booked: bookedDates }} + modifiersClassNames={{ + booked: + "relative after:absolute after:bottom-1 after:start-1/2 after:-translate-x-1/2 after:h-1 after:w-1 after:rounded-full after:bg-sky-500", + }} + />
- ))} +
+
+
+ {selectedDay.toLocaleDateString(isAr ? "ar" : "en", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} +
+
+ +
+
)}