diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..cabf43b5dd --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 \ No newline at end of file diff --git a/app/Http/Controllers/Site/BookingsPagesController.php b/app/Http/Controllers/Site/BookingsPagesController.php new file mode 100644 index 0000000000..d92198db8e --- /dev/null +++ b/app/Http/Controllers/Site/BookingsPagesController.php @@ -0,0 +1,72 @@ +bookingRepo = $bookingRepo; + } + + public function show($id) + { + $booking = Booking::findOrFail($id); + + return response()->json([ + 'id' => $booking->id, + 'position' => $booking->position, + 'controller_name' => $booking->member->name ?? 'Unknown', + 'from' => $booking->from, + 'to' => $booking->to, + ]); + } + + /** + * Display the bookings calendar for a given month and year. + * + * @param int|null $year + * @param int|null $month + * @return \Illuminate\View\View + */ + public function index($year = null, $month = null) + { + $date = Carbon::createFromDate($year ?? now()->year, $month ?? now()->month, 1)->startOfMonth(); + + $start = $date->copy()->startOfWeek(); + $end = $date->copy()->endOfMonth()->endOfWeek(); + + $calendar = []; + $current = $start->copy(); + + // Fetch Bookings for the entire Calendar Range + $bookings = $this->bookingRepo->getBookingsBetween($start, $end); + + while ($current <= $end) { + $week = []; + for ($i = 0; $i < 7; $i++) { + $dayBookings = $bookings->filter(function ($booking) use ($current) { + return $booking->date->isSameDay($current); + }); + + $week[] = [ + 'date' => $current->copy(), + 'bookings' => $dayBookings, + ]; + $current->addDay(); + } + $calendar[] = $week; + } + + $prevMonth = $date->copy()->subMonth(); + $nextMonth = $date->copy()->addMonth(); + + return view('site.bookings.index', compact('calendar', 'date', 'prevMonth', 'nextMonth')); + } +} diff --git a/app/Libraries/Bookings.php b/app/Libraries/Bookings.php new file mode 100644 index 0000000000..1088db41e7 --- /dev/null +++ b/app/Libraries/Bookings.php @@ -0,0 +1,31 @@ +copy()->setTimezone('UTC'); + $endUtc = Carbon::parse($bookingEnd, 'UTC')->setTimezone('UTC'); + + // 1️⃣ If the booking date is before today (UTC), it’s past + if ($dayDate->lt($nowUtc->copy()->startOfDay())) { + return true; + } + + // 2️⃣ If the booking is today and has already ended, it’s past + if ($dayDate->isSameDay($nowUtc)) { + return $endUtc->lessThanOrEqualTo($nowUtc); + } + + // 3️⃣ Otherwise it’s upcoming + return false; + } +} diff --git a/app/Models/Cts/Booking.php b/app/Models/Cts/Booking.php index 39178f22b6..dadbf8f5ca 100644 --- a/app/Models/Cts/Booking.php +++ b/app/Models/Cts/Booking.php @@ -64,4 +64,19 @@ public function isMentoring() { return $this->type == 'ME'; } + + public function isSeminar() + { + return $this->type == 'GS'; + } + + public function session() + { + return $this->belongsTo(\App\Models\Cts\Session::class, 'type_id', 'id'); + } + + public function exams() + { + return $this->belongsTo(\App\Models\Cts\Exams::class, 'type_id', 'id'); + } } diff --git a/app/Models/Cts/Exams.php b/app/Models/Cts/Exams.php new file mode 100644 index 0000000000..a4cb57d68c --- /dev/null +++ b/app/Models/Cts/Exams.php @@ -0,0 +1,24 @@ +belongsTo(Member::class, 'exmr_id', 'id'); + } +} diff --git a/app/Repositories/Cts/BookingRepository.php b/app/Repositories/Cts/BookingRepository.php index d56400a386..c2ecb927a3 100644 --- a/app/Repositories/Cts/BookingRepository.php +++ b/app/Repositories/Cts/BookingRepository.php @@ -8,10 +8,19 @@ class BookingRepository { + public function getBookingsBetween($startDate, $endDate) + { + return Booking::with(['member', 'session.mentor']) + ->whereBetween('date', [$startDate->toDateString(), $endDate->toDateString()]) + ->get()->each(function ($booking) { + $booking->date = Carbon::parse($booking->date); + }); + } + public function getBookings(Carbon $date) { $bookings = Booking::where('date', '=', $date->toDateString()) - ->with('member') + ->with(['member', 'session.mentor']) ->orderBy('from') ->get(); @@ -21,7 +30,7 @@ public function getBookings(Carbon $date) public function getTodaysBookings() { $bookings = Booking::where('date', '=', Carbon::now()->toDateString()) - ->with('member') + ->with(['member', 'session.mentor']) ->orderBy('from') ->get(); @@ -32,7 +41,7 @@ public function getTodaysLiveAtcBookings() { $bookings = Booking::where('date', '=', Carbon::now()->toDateString()) ->networkAtc() - ->with('member') + ->with(['member', 'session.mentor']) ->orderBy('from') ->get(); @@ -44,7 +53,7 @@ public function getTodaysLiveAtcBookingsWithoutEvents() $bookings = Booking::where('date', '=', Carbon::now()->toDateString()) ->notEvent() ->networkAtc() - ->with('member') + ->with(['member', 'session.mentor']) ->orderBy('from') ->get(); @@ -60,6 +69,50 @@ private function formatBookings(Collection $bookings) $booking->member = $this->formatMember($booking); $booking->unsetRelation('member'); + if ($booking->type === 'ME' && $booking->session) { + $mentorName = 'Unknown'; + + // Safely get mentor name and ID + if ($booking->session->mentor) { + $mentorName = $booking->session->mentor->name.' ('.$booking->session->mentor->cid.')'; + } + + $booking->session_details = [ + 'id' => $booking->session->id, + 'position' => $booking->session->position, + 'student_id' => $booking->session->student_id, + 'mentor_id' => $booking->session->mentor_id, + 'mentor' => $mentorName, + 'date' => $booking->session->date_1, + 'from' => $booking->session->from_1, + 'to' => $booking->session->to_1, + 'request_time' => $booking->session->request_time, + 'taken_time' => $booking->session->taken_time, + ]; + } + + if ($booking->type === 'EX' && $booking->exams) { + $examinerName = 'Unknown'; + + // Safely get mentor name and ID + if ($booking->exams->mentor) { + $examinerName = $booking->exams->examiner->name.' ('.$booking->exams->examiner->cid.')'; + } + + $booking->exams_details = [ + 'id' => $booking->exams->id, + 'position' => $booking->exams->position, + 'student_id' => $booking->exams->student_id, + 'exmr_id' => $booking->exams->exmr_id, + 'examiner' => $examinerName, + 'date' => $booking->exams->date_1, + 'from' => $booking->exams->from_1, + 'to' => $booking->exams->to_1, + 'time_book' => $booking->exams->time_book, + 'taken_time' => $booking->exams->time_taken, + ]; + } + return $booking; }); diff --git a/resources/assets/js/bookings.js b/resources/assets/js/bookings.js new file mode 100644 index 0000000000..f6e043460e --- /dev/null +++ b/resources/assets/js/bookings.js @@ -0,0 +1,213 @@ +document.addEventListener('DOMContentLoaded', function () { + // ---- Display old bookings: toggle a CSS class on ---- + const oldCb = document.getElementById('filter-old'); + if (oldCb) { + function syncOldToggle() { + document.body.classList.toggle('show-old', !!oldCb.checked); + } + syncOldToggle(); + oldCb.addEventListener('change', syncOldToggle); + } + + // ---- Type filters ---- + const typeCheckboxes = Array.from(document.querySelectorAll('input[type="checkbox"][data-filter]')); + function applyTypeFilters() { + const allowed = new Set(typeCheckboxes.filter(cb => cb.checked).map(cb => cb.getAttribute('data-filter'))); + document.querySelectorAll('.booking-entry').forEach(el => { + const kind = el.getAttribute('data-kind') || 'normal'; + el.style.display = allowed.has(kind) ? '' : 'none'; + }); + } + typeCheckboxes.forEach(cb => cb.addEventListener('change', applyTypeFilters)); + applyTypeFilters(); + + // ---- Live clocks + TZ message ---- + function updateTimes() { + const now = new Date(); + const local = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const utc = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: 'UTC' }); + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const offsetMinutes = -now.getTimezoneOffset(); // + ahead, - behind + const absMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absMinutes / 60); + const minutes = absMinutes % 60; + + let relation, hint; + if (offsetMinutes === 0) { + relation = "the same as Zulu."; + hint = "No conversion is needed."; + } else if (offsetMinutes > 0) { + relation = `${hours}h${minutes ? ` ${minutes}m` : ""} ahead of Zulu.`; + hint = `To convert any time on Bookings to your local time, add ${hours}h${minutes ? ` ${minutes}m` : ""}.`; + } else { + relation = `${hours}h${minutes ? ` ${minutes}m` : ""} behind Zulu.`; + hint = `To convert any time on Bookings to your local time, subtract ${hours}h${minutes ? ` ${minutes}m` : ""}.`; + } + + document.querySelectorAll('#local-time').forEach(el => el.textContent = local); + document.querySelectorAll('#utc-time').forEach(el => el.textContent = utc); + document.querySelectorAll('#tz-message').forEach(el => el.innerHTML = + `Your local time (${tz}) is ${relation}
${hint}`); + } + updateTimes(); + setInterval(updateTimes, 1000); + + // ---- Promote bookings to .is-past as time passes ---- + function rollPastOverTime() { + const now = new Date(); + document.querySelectorAll('.booking-entry:not(.is-past)').forEach(el => { + const span = el.querySelector('.booking-time'); + if (!span) return; + const endISO = span.dataset.end; + if (!endISO) return; + const end = new Date(endISO); + if (!isNaN(end) && end <= now) el.classList.add('is-past'); + }); + } + rollPastOverTime(); + setInterval(rollPastOverTime, 60000); + + // ---- Local time toggle ---- + const toggleLocalTime = document.getElementById('toggle-localtime'); + if (toggleLocalTime) { + function updateBookingTimes() { + const useLocal = toggleLocalTime.checked; + document.querySelectorAll('.booking-time').forEach(span => { + const startISO = span.dataset.start; + const endISO = span.dataset.end; + if (!startISO || !endISO) return; + + const startUtc = new Date(startISO); + const endUtc = new Date(endISO); + if (isNaN(startUtc) || isNaN(endUtc)) return; + + if (useLocal) { + const startLocal = startUtc.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const endLocal = endUtc.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone; + span.textContent = `${startLocal} - ${endLocal} (${tzName})`; + } else { + const startZulu = startUtc.toISOString().substr(11, 5); + const endZulu = endUtc.toISOString().substr(11, 5); + span.textContent = `${startZulu}z - ${endZulu}z`; + } + }); + } + updateBookingTimes(); + toggleLocalTime.addEventListener('change', updateBookingTimes); + } + + // ---- Tooltip containers ---- + const items = Array.from(document.querySelectorAll('.tooltip-container')); + + // Hide all except optional "keep" element + function hideAll(keep = null) { + items.forEach(el => { + if (el !== keep) { + el.classList.remove('is-open'); + if (el === document.activeElement) el.blur(); + } + }); + } + + items.forEach(el => { + // Open on hover, but ensure only one is active + el.addEventListener('mouseenter', () => { + hideAll(el); + // purely hover should still show via your CSS .tooltip-container:hover .tooltip-content + // we don't set is-open here to keep hover behavior unchanged + }); + + // Keyboard focus: keep only one open + el.addEventListener('focus', () => hideAll(el)); + + // Click to "pin" (toggle). Only one pinned at a time. + el.addEventListener('click', (e) => { + const willOpen = !el.classList.contains('is-open'); + hideAll(willOpen ? el : null); + el.classList.toggle('is-open', willOpen); + // Prevent the click from bubbling to the document "outside click" handler + e.stopPropagation(); + }); + + // If you leave with the mouse and it's not pinned (no .is-open), let it close + el.addEventListener('mouseleave', () => { + if (!el.classList.contains('is-open') && !el.matches(':focus')) { + // hover-out: your CSS already hides, nothing needed + } + }); + + // When focus is lost, unpin + el.addEventListener('blur', () => el.classList.remove('is-open')); + }); + + // Click outside → close any pinned tooltip + document.addEventListener('click', () => hideAll()); + + // ESC to close + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hideAll(); + }); + + const containers = Array.from(document.querySelectorAll('.tooltip-container')); + + function positionTip(container) { + const tip = container.querySelector('.tooltip-content'); + if (!tip) return; + + // Reset any previous inline transforms/positions + tip.style.transform = ''; + tip.classList.remove('pos-above', 'pos-below', 'pos-left', 'pos-right'); + + // Start with a sensible default (to the right, below) + tip.classList.add('pos-right', 'pos-below'); + + // Wait a frame so the browser can measure with classes applied + requestAnimationFrame(() => { + const vw = window.innerWidth; + const vh = window.innerHeight; + const tipRect = tip.getBoundingClientRect(); + const contRect = container.getBoundingClientRect(); + + // Decide vertical placement + let vertical = 'below'; + if (tipRect.bottom > vh && contRect.top > tipRect.height) vertical = 'above'; + tip.classList.toggle('pos-below', vertical === 'below'); + tip.classList.toggle('pos-above', vertical === 'above'); + + // Re-measure if we changed vertical + let r = tip.getBoundingClientRect(); + + // Decide horizontal placement + let horizontal = 'right'; + if (r.right > vw && contRect.left > r.width) horizontal = 'left'; + tip.classList.toggle('pos-right', horizontal === 'right'); + tip.classList.toggle('pos-left', horizontal === 'left'); + + // Final nudge to keep fully on-screen + r = tip.getBoundingClientRect(); + let dx = 0, dy = 0; + if (r.left < 0) dx += -r.left + 8; + if (r.right > vw) dx += vw - r.right - 8; + if (r.top < 0) dy += -r.top + 8; + if (r.bottom > vh) dy += vh - r.bottom - 8; + if (dx || dy) tip.style.transform = `translate(${dx}px, ${dy}px)`; + }); + } + + // Position when tip becomes visible (hover/focus/pin) + containers.forEach(c => { + c.addEventListener('mouseenter', () => positionTip(c)); + c.addEventListener('focus', () => positionTip(c), true); + c.addEventListener('click', () => positionTip(c)); // if you support click-to-pin + }); + + // Reposition any open ones on resize/scroll + const repack = () => { + document.querySelectorAll('.tooltip-container.is-open, .tooltip-container:hover, .tooltip-container:focus-within') + .forEach(c => positionTip(c)); + }; + window.addEventListener('resize', repack, { passive: true }); + window.addEventListener('scroll', repack, { passive: true }); +}); diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss index d047a86a64..830d97f359 100644 --- a/resources/assets/sass/app.scss +++ b/resources/assets/sass/app.scss @@ -11,7 +11,8 @@ $screen-sm: 802px; @import "_top-notification"; @import "_markdown"; -body, html { +body, +html { margin: 0 auto; font-size: 14px; font-family: Calibri, Tahoma, Geneva, sans-serif; @@ -34,15 +35,16 @@ body, html { height: 70%; float: left; margin: 10px 15px 0 5.0%; + img { height: 100%; } } .dev_environment_notification { - height: 20px; - background-color: red; - text-align: center; + height: 20px; + background-color: red; + text-align: center; } .sys_notification { @@ -51,13 +53,13 @@ body, html { position: relative; .text { - margin: 0; - position: absolute; - top: 50%; - -ms-transform: translateY(-50%); - transform: translateY(-50%); - text-align: center; - width: 100%; + margin: 0; + position: absolute; + top: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); + text-align: center; + width: 100%; } } @@ -69,6 +71,7 @@ body, html { transform-style: preserve-3d; transform: perspective(auto) translateZ(-30vw) scale(1.4); perspective: 1000; + img { margin-top: 90px; height: 260px; @@ -78,8 +81,9 @@ body, html { .fuzzy_date { color: #337ab7; text-decoration: none; - &:hover{ - cursor:pointer; + + &:hover { + cursor: pointer; } } @@ -87,11 +91,11 @@ body, html { color: white !important; } -.vertical-center{ +.vertical-center { vertical-align: middle !important; } -.table.table-borders >tbody > tr > td { +.table.table-borders>tbody>tr>td { border: 1px solid grey; } @@ -110,7 +114,8 @@ body, html { width: 90%; } -.breadcrumb_content_left, .breadcrumb_content_right { +.breadcrumb_content_left, +.breadcrumb_content_right { margin: 0 auto; padding-top: 8px; height: 100%; @@ -167,6 +172,7 @@ body, html { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; + line-clamp: 3; -webkit-box-orient: vertical; } @@ -206,7 +212,9 @@ body, html { border: 0; border-top-left-radius: 0; border-top-right-radius: 0; - > .panel-heading, > .panel-heading-link > .panel-heading { + + >.panel-heading, + >.panel-heading-link>.panel-heading { background-color: #17375e; border-color: #17375E; color: white; @@ -227,36 +235,42 @@ body, html { padding: 5px; } - > .panel-title a { + >.panel-title a { color: white; } } + .panel-body .row-text-contain { word-break: break-word; } - .panel-footer.panel-footer-primary { - color: white; - background-color: $brand-primary; - a { - color: white; - } - a:hover { - color: $gray-lighter; - } + + .panel-footer.panel-footer-primary { + color: white; + background-color: $brand-primary; + + a { + color: white; } + + a:hover { + color: $gray-lighter; + } + } } .panel-uk-danger { border: 0; border-top-left-radius: 0; border-top-right-radius: 0; - > .panel-heading { + + >.panel-heading { background-color: #c30c1d; border-color: #840712; color: white; border-top-left-radius: 0; border-top-right-radius: 0; } + .panel-body .row-text-contain { word-break: break-word; } @@ -266,20 +280,24 @@ body, html { border: 0; border-top-left-radius: 0; border-top-right-radius: 0; - > .panel-heading { + + >.panel-heading { background-color: #009700; border-color: #006900; color: white; border-top-left-radius: 0; border-top-right-radius: 0; } + .panel-body .row-text-contain { word-break: break-word; } } -.panel-group#unreadNotifications, .panel-group#readNotifications { +.panel-group#unreadNotifications, +.panel-group#readNotifications { padding: 10px; + .panel { margin-bottom: 15px; } @@ -310,18 +328,181 @@ body, html { .profile-picture { object-fit: cover; border-radius: 50px; - height:50px; + height: 50px; width: 50px; } .equal { - display: flex; - display: -webkit-flex; - flex-wrap: wrap; + display: flex; + display: -webkit-flex; + flex-wrap: wrap; } .flex-centered { - display: flex; - justify-content: center; - align-items: center; + display: flex; + justify-content: center; + align-items: center; +} + +.calendar { + width: 100%; + max-width: 850px; + margin: 0 auto; + border-collapse: collapse; + table-layout: fixed; +} + +.calendar th, +.calendar td { + width: 14.28%; + height: 100px; + text-align: center; + font-weight: bold; + vertical-align: top; + padding: 5px; + border: 1px solid #ccc; + box-sizing: border-box; +} + +.bg-gray-100 { + background-color: #f0f0f0; +} + +.booking-entry { + cursor: pointer; + text-decoration: none; + font-size: 15px; + margin-top: 5px; +} + +.booking-entry:hover { + text-decoration: underline; +} + +.today-cell { + background-color: #FFFFCC !important; + border: 2px solid #FFCC00 !important; +} + +.booking { + color: #336633; + border-color: #336633; + padding: 4px; + border: 2px solid transparent; + border-radius: 4px; +} + +.booking-mentoring { + color: #7429C7; + border-color: #7429C7; +} + +.booking-event { + color: #FF0000; + border-color: #FF0000; +} + +.booking-exam { + color: #993300; + border-color: #993300; +} + +.booking-seminar { + color: #FFA500; + border-color: #FFA500; +} + +.tooltip-container { + position: relative; + display: inline-block; +} + +.booking-entry.is-past { + display: none !important; +} + +.show-old .booking-entry.is-past { + display: block !important; +} + + +.tooltip-content { + display: none; + position: absolute; + z-index: 1000; + left: 100%; + top: 0; + min-width: 250px; + background: #fff; + color: #222; + border: 1px solid #888; + border-radius: 6px; + padding: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + white-space: normal; + font-size: 13px; +} + +.tooltip-container:hover .tooltip-content, +.tooltip-container:focus-within .tooltip-content { + display: block; +} + +.tooltip-container.is-open .tooltip-content { + display: block; +} + +/* keep your existing tooltip styles; add these utility positions */ +.tooltip-container { + position: relative; +} + +.tooltip-content { + will-change: transform; +} + +.tooltip-content.pos-right { + left: 100%; + top: 0; + right: auto; + bottom: auto; + margin-left: 8px; +} + +.tooltip-content.pos-left { + right: 100%; + left: auto; + top: 0; + bottom: auto; + margin-right: 8px; +} + +.tooltip-content.pos-below { + top: 100%; + bottom: auto; + left: 0; + right: auto; + margin-top: 8px; } + +.tooltip-content.pos-above { + bottom: 100%; + top: auto; + left: 0; + right: auto; + margin-bottom: 8px; +} + +/* ensure pinned tips (from your click-to-pin code) also show */ +.tooltip-container.is-open .tooltip-content { + display: block; +} + +.no-x-overflow { + overflow-x: visible !important; +} + +.legend-table td { + vertical-align: middle !important; + padding: 6px 10px !important; +} \ No newline at end of file diff --git a/resources/views/components/nav.blade.php b/resources/views/components/nav.blade.php index c66af76c31..318de24ad2 100644 --- a/resources/views/components/nav.blade.php +++ b/resources/views/components/nav.blade.php @@ -101,7 +101,6 @@ -