5 [% USE AuthorisedValues %]
10 [% INCLUDE 'doc-head-open.inc' %]
11 <title>Curbside pickups › Circulation › Koha</title>
16 .pickup_time input[type='radio'] {
23 background-color: #ffffcc;
24 display: inline-block;
29 .pickup_time input[type='radio']:checked + label {
30 background-color: #bcdb89;
32 .pickup_time input[type='radio']:disabled+ label {
33 background-color: #ff9090;
36 [% INCLUDE 'doc-head-close.inc' %]
39 [% SET today_iso = date.format(date.now, format = '%Y-%m-%d') %]
41 <body id="circ_curbside-pickups" class="circ">
42 [% INCLUDE 'header.inc' %]
43 [% INCLUDE 'cat-search.inc' %]
45 [% WRAPPER 'sub-header.inc' %]
46 [% WRAPPER breadcrumbs %]
47 [% WRAPPER breadcrumb_item %]
48 <a href="/cgi-bin/koha/circ/circulation-home.pl">Circulation</a>
50 [% WRAPPER breadcrumb_item bc_active = 1 %]
51 <span>Curbside pickups</span>
53 [% END #/ WRAPPER breadcrumbs %]
54 [% END #/ WRAPPER sub-header.inc %]
56 [% BLOCK waiting_holds %]
57 [% SET waiting_holds = cp.patron.holds.search( found => 'W', branchcode => Branches.GetLoggedInBranchcode ) %]
58 [% FOREACH h IN waiting_holds %]
59 <a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% h.biblionumber | uri %]">[% h.biblio.title | html %]</a> ([% h.biblio.author | html %], <a href="/cgi-bin/koha/catalogue/moredetail.pl?itemnumber=[% h.itemnumber | html %]&biblionumber=[% h.biblionumber | html %]#item[% h.itemnumber | html %]">[% h.item.barcode | html %]</a>)<br/>
63 [% BLOCK patron_info %]
64 <a href="/cgi-bin/koha/members/moremember.pl?borrowernumber=[% cp.borrowernumber | uri %]">[% cp.patron.firstname | html %] [% cp.patron.surname | html %] ([% cp.patron.cardnumber | html %])</a>
67 <span>Notes: </span>[% cp.notes | html %]
69 [% IF cp.patron.debarred %]
71 <span class="patron_restricted">Patron's account is restricted</span>
73 [% IF cp.patron.has_overdues %]
75 <span class="patron_overdues">Patron has items overdue</span>
79 <div class="main container-fluid">
81 <div class="col-sm-12">
84 [% IF Koha.Preference('CircSidebar') %]
85 <div class="col-sm-10 col-sm-push-2">
87 <div class="col-sm-12">
90 <h1>Curbside pickups</h1>
92 [% UNLESS policy.enabled %]
93 <div class="dialog alert">
94 Curbside pickups are not enabled for your library.
96 [% INCLUDE 'intranet-bottom.inc' %]
100 [% FOR m IN messages %]
101 <div class="dialog [% m.type | html %]">
103 [% CASE 'not_enabled' %]
104 <span>The curbside pickup feature is not enabled for this library.</span>
105 [% CASE 'library_is_closed' %]
106 <span>Cannot create a curbside pickup for this day, it is a holiday.</span>
107 [% CASE 'no_waiting_holds' %]
108 <span>This patron does not have waiting holds.</span>
109 [% CASE 'too_many_pickups' %]
110 <span>This patron already has a scheduled pickup for this library.</span>
111 [% CASE 'no_matching_slots' %]
112 <span>Wrong slot selected.</span>
113 [% CASE 'no_more_pickups_available' %]
114 <span>There are no more pickups available for this slot. Please choose another one.</span>
115 [% CASE 'cannot_checkout' %]
116 <span>Unable to check the items out to [% INCLUDE 'patron-title.inc' patron=m.patron %]</span>
118 <span>[% m.code | html %]</span>
123 <form method="post" class="refresh-form">
125 <input type="hidden" name="tab" id="current-tab" value="[% tab | html %]" />
126 <button type="submit" class="btn btn-default"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Refresh</button>
129 [% IF auto_refresh %]
130 <input type="checkbox" id="auto_refresh" name="auto_refresh" checked="checked" />
132 <input type="checkbox" id="auto_refresh" name="auto_refresh" />
134 Refresh automatically every <input type="text" inputmode="numeric" pattern="[0-9]*" id="refresh_delay" name="refresh_delay" value="[% refresh_delay || 60 | html %]" size="3" /> seconds.
137 <span id="refresh_info"></span>
141 [% SET to_be_staged = curbside_pickups.filter_by_to_be_staged %]
142 [% SET staged_and_ready = curbside_pickups.filter_by_staged_and_ready %]
143 [% SET patron_outside = curbside_pickups.filter_by_patron_outside %]
144 [% SET delivered_today = curbside_pickups.filter_by_delivered %]
145 <div id="pickup-tabs" class="toptabs">
146 <ul class="nav nav-tabs" role="tablist">
147 [% IF !tab OR tab == 'to-be-staged' %]
148 <li role="presentation" class="active">
150 <li role="presentation">
152 <a id="to-be-staged-tab" href="#to-be-staged" role="tab" data-toggle="tab">To be staged ([% to_be_staged.count | html %])</a>
154 [% IF tab == 'staged-and-ready' %]
155 <li role="presentation" class="active">
157 <li role="presentation">
159 <a id="staged-and-ready-tab" href="#staged-and-ready" role="tab" data-toggle="tab">Staged & ready ([% staged_and_ready.count | html %])</a>
161 [% IF tab == 'patron-is-outside' %]
162 <li role="presentation" class="active">
164 <li role="presentation">
166 <a id="patron-is-outside-tab" href="#patron-is-outside" role="tab" data-toggle="tab">Patron is outside ([% patron_outside.count | html %])</a>
168 [% IF tab == 'delivered-today' %]
169 <li role="presentation" class="active">
171 <li role="presentation">
173 <a id="delivered-today-tab" href="#delivered-today" role="tab" data-toggle="tab">Delivered today ([% delivered_today.count | html %])</a>
175 [% IF tab == 'schedule-pickup' %]
176 <li role="presentation" class="active">
178 <li role="presentation">
180 <a id="schedule-pickup-tab" href="#schedule-pickup" role="tab" data-toggle="tab">Schedule pickup</a>
184 <div class="tab-content">
185 [% IF !tab OR tab == 'to-be-staged' %]
186 <div id="to-be-staged" role="tabpanel" class="tab-pane active">
188 <div id="to-be-staged" role="tabpanel" class="tab-pane">
190 [% IF to_be_staged.count %]
191 <table class="table table-striped">
194 <th>Pickup date/time</th>
196 <th>Items for pickup</th>
201 [% FOREACH cp IN to_be_staged %]
202 [% UNLESS cp.staged_datetime %]
203 <tr class="[% class | html %]">
204 <td>[% cp.scheduled_pickup_datetime | $KohaDates with_hours = 1 %]</td>
206 [% PROCESS patron_info %]
209 [% PROCESS waiting_holds %]
212 <form method="post" class="form">
213 <input type="hidden" name="op" value="mark-as-staged"/>
214 <input type="hidden" name="tab" value="to-be-staged"/>
215 <input type="hidden" name="id" value="[% cp.id | html %]"/>
217 <button type="submit" class="btn btn-default mark-as-staged-and-ready-btn"><i class="fa fa-check" aria-hidden="true"></i> Mark as <i>staged & ready</i></button>
221 <form method="post" class="form">
222 <input type="hidden" name="op" value="cancel"/>
223 <input type="hidden" name="tab" value="to-be-staged"/>
224 <input type="hidden" name="id" value="[% cp.id | html %]"/>
226 <button type="submit" class="btn btn-default cancel-btn"><i class="fa fa-ban" aria-hidden="true"></i> Cancel</button>
236 <span>There are no pickups to be staged.</span>
240 [% IF tab == "staged-and-ready" %]
241 <div id="staged-and-ready" role="tabpanel" class="tab-pane active">
243 <div id="staged-and-ready" role="tabpanel" class="tab-pane">
245 [% IF staged_and_ready.count %]
246 <table class="table table-striped">
249 <th>Pickup date/time</th>
251 <th>Items for pickup</th>
257 [% FOREACH cp IN staged_and_ready %]
258 [% IF cp.staged_datetime && !cp.arrival_datetime %]
259 <tr class="[% class | html %]">
260 <td>[% cp.scheduled_pickup_datetime | $KohaDates with_hours = 1 %]</td>
262 [% PROCESS patron_info %]
265 [% PROCESS waiting_holds %]
268 [% cp.staged_by_staff.firstname | html %] [% cp.staged_by_staff.surname | html %]
271 <form method="post" class="form">
272 <input type="hidden" name="op" value="mark-patron-has-arrived"/>
273 <input type="hidden" name="tab" value="staged-and-ready"/>
274 <input type="hidden" name="id" value="[% cp.id | html %]"/>
276 <button type="submit" class="btn btn-default patron-has-arrived-btn"><i class="fa-solid fa-location-dot" aria-hidden="true"></i> Patron has arrived</button>
280 <form method="post" class="form">
281 <input type="hidden" name="op" value="mark-as-delivered"/>
282 <input type="hidden" name="tab" value="staged-and-ready"/>
283 <input type="hidden" name="id" value="[% cp.id | html %]"/>
285 <button type="submit" class="btn btn-default mark-as-delivered-btn"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Mark as <i>delivered</i></button>
289 <form method="post" class="form">
290 <input type="hidden" name="op" value="mark-as-unstaged"/>
291 <input type="hidden" name="tab" value="staged-and-ready"/>
292 <input type="hidden" name="id" value="[% cp.id | html %]"/>
294 <button type="submit" class="btn btn-default mark-as-to-be-staged-btn"><i class="fa fa-undo" aria-hidden="true"></i> Mark as <i>to be staged</i></button>
304 <span>There are no pickups staged and ready.</span>
308 [% IF tab == "patron-is-outside" %]
309 <div id="patron-is-outside" role="tabpanel" class="tab-pane active">
311 <div id="patron-is-outside" role="tabpanel" class="tab-pane">
313 [% IF patron_outside.count %]
314 <table class="table table-striped">
317 <th>Pickup date/time</th>
319 <th>Items for pickup</th>
325 [% FOREACH cp IN patron_outside %]
326 [% IF cp.arrival_datetime && !cp.delivered_datetime %]
327 <tr class="[% class | html %]">
328 <td>[% cp.scheduled_pickup_datetime | $KohaDates with_hours = 1 %]</td>
330 [% PROCESS patron_info %]
333 [% PROCESS waiting_holds %]
336 [% cp.staged_by_staff.firstname | html %] [% cp.staged_by_staff.surname | html %]
339 <form method="post" class="form">
340 <input type="hidden" name="op" value="mark-as-delivered"/>
341 <input type="hidden" name="tab" value="patron-is-outside"/>
342 <input type="hidden" name="id" value="[% cp.id | html %]"/>
344 <button type="submit" class="btn btn-default mark-as-delivered-btn"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Mark as delivered</button>
348 <form method="post" class="form">
349 <input type="hidden" name="op" value="mark-as-staged"/>
350 <input type="hidden" name="tab" value="patron-is-outside"/>
351 <input type="hidden" name="id" value="[% cp.id | html %]"/>
353 <button type="submit" class="btn btn-default mark-as-staged-and-ready-btn"><i class="fa fa-undo" aria-hidden="true"></i> Mark as <i>staged & ready</i></button>
357 <form method="post" class="form">
358 <input type="hidden" name="op" value="mark-as-unstaged"/>
359 <input type="hidden" name="tab" value="patron-is-outside"/>
360 <input type="hidden" name="id" value="[% cp.id | html %]"/>
362 <button type="submit" class="btn btn-default mark-as-to-be-staged-btn"><i class="fa fa-undo" aria-hidden="true"></i> Mark as <i>to be staged</i></button>
372 <span>There are no patrons waiting outside.</span>
376 [% IF tab == "delivered-today" %]
377 <div id="delivered-today" role="tabpanel" class="tab-pane active">
379 <div id="delivered-today" role="tabpanel" class="tab-pane">
381 [% IF delivered_today.count %]
382 <table class="table table-striped">
385 <th>Deliver date/time</th>
387 <th>Items checked out</th>
391 [% FOREACH cp IN delivered_today %]
392 [% IF cp.delivered_datetime %]
393 <tr class="[% class | html %]">
394 <td>[% cp.delivered_datetime | $KohaDates with_hours = 1 %]</td>
396 [% PROCESS patron_info %]
399 [% FOREACH c IN cp.checkouts %]
400 [% IF date.format(c.issuedate, format = '%Y-%m-%d') == today_iso %]
401 <a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% c.item.biblionumber | uri %]">[% c.item.biblio.title | html %]</a> ([% c.item.biblio.author | html %], <a href="/cgi-bin/koha/catalogue/moredetail.pl?itemnumber=[% c.itemnumber | html %]&biblionumber=[% c.item.biblionumber | html %]#item[% c.itemnumber | html %]">[% c.item.barcode | html %]</a>)<br/>
411 <span>No pickups have been delivered today.</span>
415 [% IF tab == "schedule-pickup" %]
416 <div id="schedule-pickup" role="tabpanel" class="tab-pane active">
418 <div id="schedule-pickup" role="tabpanel" class="tab-pane">
420 [% IF !patron || ( patron && existing_curbside_pickups.count >= 1 ) %]
421 [% IF existing_curbside_pickups.count >= 1 %]
422 <div class="dialog alert">
423 [% patron.firstname | html %] [% patron.surname | html %] ([% patron.cardnumber | html %]) already has a scheduled pickup for this library.
426 <div class="form-group">
427 <label class="sr-only" for="input-patron-cardnumber">Cardnumber</label>
428 <div class="input-group">
429 <div class="input-group-addon">Search a patron</div>
430 <input autocomplete="off" id="find-patron" class="form-control" type="text" style="width:25%" class="noEnterSubmit" placeholder="Enter patron cardnumber or name"//>
434 [% SET waiting_holds = patron.holds.search( found => 'W', branchcode => Branches.GetLoggedInBranchcode ) %]
435 [% IF !policy.enable_waiting_holds_only || waiting_holds.count > 0 %]
436 <form id="create-pickup" method="post">
437 <fieldset class="rows" style="float: none;">
438 <input type="hidden" name="borrowernumber" value="[% patron.id | html %]"/>
439 <input type="hidden" name="op" value="create-pickup"/>
440 <input type="hidden" name="tab" value="schedule-pickup"/>
443 <label>Patron: </label>
444 <span>[% INCLUDE 'patron-title.inc' patron=patron %]</span>
445 <a title="Search for another patron" href="/cgi-bin/koha/circ/curbside_pickups.pl?tab=schedule-pickup"><i class="fa fa-search"></i></a>
449 <label>Items ready for pickup: </label>
451 [% IF waiting_holds.count %]
452 [% FOREACH h IN waiting_holds %]
454 <a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% h.biblionumber | uri %]">[% h.biblio.title | html %]</a> ([% h.biblio.author | html %], <a href="/cgi-bin/koha/catalogue/moredetail.pl?itemnumber=[% h.itemnumber | html %]&biblionumber=[% h.biblionumber | html %]#item[% h.itemnumber | html %]">[% h.item.barcode | html %]</a>)
458 <span>There are no waiting holds for this patron at this library.</span>
464 <label for="pickup_date">Pickup date: </label>
465 <input id="pickup_date" name="pickup_date" required="required" class="flatpickr" data-flatpickr-futureinclusive="true" />
468 <li id="pickup-times" class="radio"></li>
471 <label for="notes">Notes: </label>
472 <input id="notes" name="notes" type="text" />
477 <fieldset class="action">
478 <input type="submit" id="schedule-pickup-button" class="btn btn-primary" value="Submit" />
482 <div class="dialog alert">The patron does not have waitings holds.</div>
488 [% IF Koha.Preference('CircSidebar') %]
489 </div> <!-- /.col-sm-10.col-sm-push-2 -->
490 <div class="col-sm-2 col-sm-pull-10">
492 [% INCLUDE 'circ-nav.inc' %]
494 </div> <!-- /.col-sm-2.col-sm-pull-10 -->
495 </div> <!-- /.row -->
499 </div> <!-- /.row -->
501 [% MACRO jsinclude BLOCK %]
502 [% Asset.js("lib/dayjs/dayjs.min.js") | $raw %]
503 [% Asset.js("lib/dayjs/plugin/isSameOrAfter.js") | $raw %]
504 [% Asset.js("lib/dayjs/plugin/customParseFormat.js") | $raw %]
505 <script>dayjs.extend(window.dayjs_plugin_isSameOrAfter)</script>
506 <script>dayjs.extend(window.dayjs_plugin_customParseFormat)</script>
507 [% INCLUDE 'calendar.inc' %]
508 [% INCLUDE 'js-patron-format.inc' %]
510 let pickups = [% To.json(curbside_pickups.unblessed) | $raw %];
511 let policy = [% To.json(policy.unblessed) | $raw %];
513 let existingPickupMoments = [];
514 pickups.forEach(function(pickup){
515 let scheduled_pickup_datetime = pickup.scheduled_pickup_datetime;
516 let pickupMoment = dayjs(scheduled_pickup_datetime);
518 existingPickupMoments.push(pickupMoment);
521 let opening_slots = [% To.json(policy.opening_slots.unblessed) | $raw %];
522 let slots_per_day = {};
523 opening_slots.forEach(function(slot){
525 if(!slots_per_day[day]) slots_per_day[day] = [];
526 slots_per_day[day].push(slot);
529 $(document).ready(function() {
531 $('#schedule-pickup-tab').on('click', function() {
532 $('#input-patron-cardnumber').focus();
535 const pickup_date = document.querySelector("#pickup_date");
537 const fp = pickup_date._flatpickr;
538 fp.set('disable', [function(date) {
539 return !slots_per_day.hasOwnProperty(date.getDay());
543 $("#pickup_date").on('change', function() {
545 $('#pickup-times').empty();
546 $('#schedule-pickup-button').prop( 'disabled', 1 );
548 var currentDate = $(this).val();
550 let selectedDate = dayjs(currentDate);
552 let pickupSlots = [];
553 let available_count = 0;
554 let dow = selectedDate.day(); // Sunday is 0 (at least for now)
555 if (!slots_per_day[dow]){
556 $('#pickup-times').html("<div>"+_("No pickup time defined for this day.")+"</div>");
560 slots_per_day[dow].sort((a, b) => a.start_hour - b.start_hour).forEach(function(slot){
561 let pickup_interval = policy.pickup_interval;
562 if (!pickup_interval) {
563 $('#pickup-times').html("<div>"+_("No pickup time defined for this day.")+"</div>");
567 let listStartMoment = selectedDate.hour(slot.start_hour).minute(slot.start_minute);
568 let listEndMoment = selectedDate.hour(slot.end_hour).minute(slot.end_minute);
570 let keep_going = true;
573 // Initialize pickup slots starting at opening time
574 let pickupIntervalStartMoment = listStartMoment;
575 let pickupIntervalEndMoment = listStartMoment.add(pickup_interval, 'minutes');
577 let available = true;
578 let display_slot = true
580 if (pickupIntervalStartMoment.isBefore(now)) {
581 // Slots in the past are unavailable
583 display_slot = false;
586 if (pickupIntervalEndMoment.isAfter(listEndMoment)) {
587 // Slots after the end of pickup times for the day are unavailable
591 let pickups_scheduled = 0;
592 existingPickupMoments.forEach(function(pickupMoment){
593 // An existing pickup time
594 if (pickupMoment.isSameOrAfter(pickupIntervalStartMoment) && pickupMoment.isBefore(pickupIntervalEndMoment)) {
595 // This calculated pickup is in use by another scheduled pickup
600 if (pickups_scheduled >= policy.patrons_per_interval) {
604 if ( display_slot ) {
607 "available": available,
608 "moment": pickupIntervalStartMoment,
609 "pickups_scheduled": pickups_scheduled
618 pickupIntervalStartMoment = pickupIntervalEndMoment;
619 pickupIntervalEndMoment = pickupIntervalStartMoment.add(pickup_interval, 'minutes');
620 if (pickupIntervalEndMoment.isAfter(listEndMoment)) {
621 // This latest slot is after the end of pickup times for the day, so we can stop
627 for (let i = 0; i < pickupSlots.length; i++) {
628 let pickupSlot = pickupSlots[i];
629 let optText = pickupSlot.moment.format("HH:mm");
630 let optValue = pickupSlot.moment.format("YYYY-MM-DD HH:mm:ss");
631 let pickups_scheduled = pickupSlot.pickups_scheduled;
632 let disabled = pickupSlot.available ? "" : "disabled";
633 $("#pickup-times").append(`<span class="pickup_time"><input type="radio" id="slot_${i}" name="pickup_time" value="${optValue}" ${disabled} /> <label for="slot_${i}">${optText} (${pickups_scheduled})</label></span>`);
636 $('#pickup-times').show();
637 $('#schedule-pickup-button').prop( 'disabled', available_count <= 0 );
640 $("#create-pickup").on('submit', function(){
641 if ( ! $("input[type='radio']:checked").length ) {
642 alert(_("Please select a date and a pickup time"))
648 if ( $("#find-patron").length ) {
649 patron_autocomplete($("#find-patron"), { 'on-select-callback': function( event, ui ) {
650 window.location.href = "/cgi-bin/koha/circ/curbside_pickups.pl?op=find-patron&borrowernumber=" + ui.item.patron_id;
656 $("#pickup-tabs a[data-toggle='tab']").on("shown.bs.tab", function (e) {
657 $("#current-tab").val($(this).attr('href').substring(1)); // Remove #
659 $("#auto_refresh,#refresh_delay").on("change", function(){
660 set_interval_if_needed();
663 set_interval_if_needed();
667 let refresh_interval_id = 0;
668 let countdown_interval_id = 0;
669 function set_interval(refresh_delay_ms){
671 let next_refresh = new Date();
672 next_refresh.setSeconds(next_refresh.getSeconds() + refresh_delay_ms / 1000);
674 countdown_interval_id = setInterval(function() {
675 const now = new Date().getTime();
676 const seconds = Math.floor(( next_refresh - now + 1 ) / 1000);
678 $("#refresh_info").text(_("Refresh in %s seconds").format(seconds));
680 $("#refresh_info").text(""); // In case something is going wrong
684 setInterval(function() {
685 $(".refresh-form:visible").submit();
686 }, refresh_delay_ms);
688 function clear_intervals(){
689 if (refresh_interval_id) {
690 clearInterval(refresh_interval_id);
691 refresh_interval_id = 0;
693 if (countdown_interval_id) {
694 clearInterval(countdown_interval_id);
695 countdown_interval_id = 0;
699 function set_interval_if_needed(){
700 const refresh_delay = $("#refresh_delay").val();
701 const auto_refresh = $("#auto_refresh").is(":checked");
703 if (auto_refresh && refresh_delay){
704 set_interval(refresh_delay * 1000);
713 [% INCLUDE 'intranet-bottom.inc' %]