Bug 29002: Add ability to book material from the staff client
This patch introduces a new modal to the biblio details page to allow booking of materials. Test plan 1) Navigate to the details page of a biblio 2) Note the new 'Place booking' button in the toolbar 3) Click the new button and note the new modal dialogue 4) Enter part of a patron name or cardnumber and then select from the presented results 5) Optionally pick an item from the select list 6) Select a start date and end date from the calender 7) Submit 8) Attempt to book the same item to another user, note that the dates previously selected are now greyed out. 9) Experiment with different items and all items options to confirm the available slots in the datepicker update as expected. 10) Sign off Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com> Signed-off-by: Janet McGowan <janet.mcgowan@ptfs-europe.com> Signed-off-by: Caroline Cyr La Rose <caroline.cyr-la-rose@inlibro.com> Signed-off-by: Laurence Rault <laurence.rault@biblibre.com> Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com> Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
This commit is contained in:
parent
e8d5219547
commit
9fff2e1f4e
|
|
@ -218,6 +218,7 @@ sub can_article_request {
|
|||
return q{};
|
||||
}
|
||||
|
||||
|
||||
=head3 check_booking
|
||||
|
||||
my $bookable =
|
||||
|
|
@ -236,7 +237,7 @@ sub check_booking {
|
|||
my $end_date = dt_from_string( $params->{end_date} );
|
||||
my $booking_id = $params->{booking_id};
|
||||
|
||||
my $bookable_items = $self->items;
|
||||
my $bookable_items = $self->bookable_items;
|
||||
my $total_bookable = $bookable_items->count;
|
||||
|
||||
my $dtf = Koha::Database->new->schema->storage->datetime_parser;
|
||||
|
|
@ -303,8 +304,8 @@ sub place_booking {
|
|||
{
|
||||
start_date => $params->{start_date},
|
||||
end_date => $params->{end_date},
|
||||
borrowernumber => $patron->borrowernumber,
|
||||
biblionumber => $self->biblionumber
|
||||
patron_id => $patron->borrowernumber,
|
||||
biblio_id => $self->biblionumber
|
||||
}
|
||||
)->store();
|
||||
return $booking;
|
||||
|
|
@ -595,6 +596,20 @@ sub items {
|
|||
return Koha::Items->search({ "me.itemnumber" => { -in => \@itemnumbers } });
|
||||
}
|
||||
|
||||
=head3 bookable_items
|
||||
|
||||
my $bookable_items = $biblio->bookable_items;
|
||||
|
||||
Returns the related Koha::Items resultset filtered to those items that can be booked.
|
||||
|
||||
=cut
|
||||
|
||||
sub bookable_items {
|
||||
my ($self) = @_;
|
||||
return $self->items->filter_by_bookable;
|
||||
}
|
||||
|
||||
|
||||
=head3 host_items
|
||||
|
||||
my $host_items = $biblio->host_items();
|
||||
|
|
|
|||
|
|
@ -158,6 +158,24 @@ sub filter_out_lost {
|
|||
return $self->search( $params );
|
||||
}
|
||||
|
||||
=head3 filter_by_bookable
|
||||
|
||||
my $filterd_items = $items->filter_by_bookable;
|
||||
|
||||
Returns a new resultset, containing only those items that are allowed to be booked.
|
||||
|
||||
=cut
|
||||
|
||||
sub filter_by_bookable {
|
||||
my ($self) = @_;
|
||||
|
||||
return $self->search(
|
||||
{
|
||||
notforloan => [ 0, undef ],
|
||||
withdrawn => [ 0, undef ]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
=head3 move_to_biblio
|
||||
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ Controller function that handles retrieving biblio's bookings
|
|||
sub get_bookings {
|
||||
my $c = shift->openapi->valid_input or return;
|
||||
|
||||
my $biblio = Koha::Biblios->find( { biblionumber => $c->validation->param('biblio_id') }, { prefetch => ['bookings'] } );
|
||||
my $biblio = Koha::Biblios->find( { biblionumber => $c->param('biblio_id') }, { prefetch => ['bookings'] } );
|
||||
|
||||
unless ( $biblio ) {
|
||||
return $c->render(
|
||||
|
|
@ -290,6 +290,7 @@ sub get_items {
|
|||
my $c = shift->openapi->valid_input or return;
|
||||
|
||||
my $biblio = Koha::Biblios->find( { biblionumber => $c->param('biblio_id') }, { prefetch => ['items'] } );
|
||||
my $bookable_only = $c->param('bookable');
|
||||
|
||||
unless ( $biblio ) {
|
||||
return $c->render(
|
||||
|
|
@ -302,7 +303,9 @@ sub get_items {
|
|||
|
||||
return try {
|
||||
|
||||
my $items = $c->objects->search( $biblio->items );
|
||||
my $items_rs = $biblio->items;
|
||||
$items_rs = $items_rs->filter_by_bookable if $bookable_only;
|
||||
my $items = $c->objects->search( $items_rs );
|
||||
return $c->render(
|
||||
status => 200,
|
||||
openapi => $items
|
||||
|
|
|
|||
|
|
@ -431,6 +431,11 @@
|
|||
- $ref: "../swagger.yaml#/parameters/q_param"
|
||||
- $ref: "../swagger.yaml#/parameters/q_body"
|
||||
- $ref: "../swagger.yaml#/parameters/request_id_header"
|
||||
- name: bookable
|
||||
in: query
|
||||
description: Limit to items that are bookable
|
||||
required: false
|
||||
type: boolean
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
|
|
|
|||
|
|
@ -194,6 +194,8 @@
|
|||
[% END %]
|
||||
[% END %]
|
||||
|
||||
<div class="btn-group"><button id="placbooking" class="btn btn-default" data-toggle="modal" data-target="#placeBookingModal" data-biblionumber="[% biblionumber | html %]"><i class="fa fa-calendar"></i> Place booking</button></div>
|
||||
|
||||
[% IF Koha.Preference('ArticleRequests') %]
|
||||
<div class="btn-group"><a id="placehold" class="btn btn-default" href="/cgi-bin/koha/circ/request-article.pl?biblionumber=[% biblionumber | html %]"><i class="fa-solid fa-file-lines"></i> Request article</a></div>
|
||||
[% END %]
|
||||
|
|
@ -244,3 +246,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
[% INCLUDE modals/place_booking.inc %]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<!-- Place booking modal -->
|
||||
<div class="modal" id="placeBookingModal" tabindex="-1" role="dialog" aria-labelledby="placeBookingLabel">
|
||||
<form id="placeBookingForm">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title" id="placeBookingLabel">Place booking</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="booking_result"></div>
|
||||
<fieldset class="rows">
|
||||
<input type="hidden" name="biblio_id" id="booking_biblio_id">
|
||||
<input type="hidden" name="start_date" id="booking_start_date">
|
||||
<input type="hidden" name="end_date" id="booking_end_date">
|
||||
<ol>
|
||||
<li>
|
||||
<div class="hint">Enter patron card number or partial name:</div>
|
||||
<label class="required" for="booking_patron_id">Patron: </label>
|
||||
<select name="booking_patron_id" id="booking_patron_id" required="required">
|
||||
<option></option>
|
||||
[% IF patron %]
|
||||
<option value="[% borrowernumber | uri %]" selected="selected">[% patron.firstname | html %] [% patron.surname | html %] ([% patron.cardnumber | html %] )</option>
|
||||
[% END %]
|
||||
</select>
|
||||
</li>
|
||||
<label for="booking_item_id">Item: </label>
|
||||
<select name="booking_item_id" id="booking_item_id" disabled="true">
|
||||
<option value="0">Any item</option>
|
||||
</select>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<div id="period_fields">
|
||||
<label class="required" for="period">Period: </label>
|
||||
<input type="text" id="period" name="period" class="flatpickr" data-flatpickr-futuredate="true" required="required" disabled="true" autocomplete="off">
|
||||
<span class="required">Required</span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div> <!-- /.modal-body -->
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" aria-hidden="true">Cancel</button>
|
||||
</div> <!-- /.modal-footer -->
|
||||
</div> <!-- /.modal-content -->
|
||||
</div> <!-- /.modal-dialog -->
|
||||
</form>
|
||||
</div> <!-- /#placeBookingModal -->
|
||||
|
|
@ -1353,6 +1353,9 @@
|
|||
|
||||
[% MACRO jsinclude BLOCK %]
|
||||
[% INCLUDE 'catalog-strings.inc' %]
|
||||
[% INCLUDE 'calendar.inc' %]
|
||||
[% INCLUDE 'select2.inc' %]
|
||||
[% INCLUDE 'js-date-format.inc' %]
|
||||
[% Asset.js("js/catalog.js") | $raw %]
|
||||
[% Asset.js("js/recalls.js") | $raw %]
|
||||
[% Asset.js("js/coce.js") | $raw %]
|
||||
|
|
@ -1822,6 +1825,7 @@
|
|||
[% INCLUDE 'js-biblio-format.inc' %]
|
||||
[% Asset.js("js/browser.js") | $raw %]
|
||||
[% Asset.js("js/table_filters.js") | $raw %]
|
||||
[% Asset.js("js/place_booking_modal.js") | $raw %]
|
||||
<script>
|
||||
var browser;
|
||||
browser = KOHA.browser('[% searchid | html %]', parseInt(biblionumber, 10));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
$('#placeBookingModal').on('show.bs.modal', function(e) {
|
||||
var button = $(e.relatedTarget);
|
||||
var biblionumber = button.data('biblionumber');
|
||||
var itemnumber = button.data('itemnumber') || 0;
|
||||
$('#booking_biblio_id').val(biblionumber);
|
||||
|
||||
// Patron select2
|
||||
$("#booking_patron_id").kohaSelect({
|
||||
dropdownParent: $(".modal-content", "#placeBookingModal"),
|
||||
width: '30%',
|
||||
dropdownAutoWidth: true,
|
||||
allowClear: true,
|
||||
minimumInputLength: 3,
|
||||
ajax: {
|
||||
url: '/api/v1/patrons',
|
||||
delay: 250,
|
||||
dataType: 'json',
|
||||
data: function(params) {
|
||||
var search_term = (params.term === undefined) ? '' : params.term;
|
||||
var query = {
|
||||
'q': JSON.stringify({
|
||||
"-or": [{
|
||||
"firstname": {
|
||||
"-like": search_term + '%'
|
||||
}
|
||||
},
|
||||
{
|
||||
"surname": {
|
||||
"-like": search_term + '%'
|
||||
}
|
||||
},
|
||||
{
|
||||
"cardnumber": {
|
||||
"-like": search_term + '%'
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
'_order_by': 'firstname',
|
||||
'_page': params.page,
|
||||
};
|
||||
return query;
|
||||
},
|
||||
processResults: function(data, params) {
|
||||
var results = [];
|
||||
data.results.forEach(function(patron) {
|
||||
results.push({
|
||||
"id": patron.patron_id,
|
||||
"text": escape_str(patron.firstname) + " " + escape_str(patron.surname) + " (" + escape_str(patron.cardnumber) + ")"
|
||||
});
|
||||
});
|
||||
return {
|
||||
"results": results, "pagination": { "more": data.pagination.more }
|
||||
};
|
||||
},
|
||||
},
|
||||
placeholder: "Search for a patron"
|
||||
});
|
||||
|
||||
// If passed patron, pre-select
|
||||
var patron_id = button.data('patron') || 0;
|
||||
if (patron_id) {
|
||||
var patron = $.ajax({
|
||||
url: '/api/v1/patrons/' + patron_id,
|
||||
dataType: 'json',
|
||||
type: 'GET'
|
||||
});
|
||||
|
||||
$.when(patron).then(
|
||||
function(patron){
|
||||
var newOption = new Option(escape_str(patron.firstname) + " " + escape_str(patron.surname) + " (" + escape_str(patron.cardnumber) + ")", patron.patron_id, true, true);
|
||||
$('#booking_patron_id').append(newOption).trigger('change');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Item select2
|
||||
$("#booking_item_id").select2({
|
||||
dropdownParent: $(".modal-content", "#placeBookingModal"),
|
||||
width: '30%',
|
||||
dropdownAutoWidth: true,
|
||||
minimumResultsForSearch: 20,
|
||||
placeholder: "Select item"
|
||||
});
|
||||
|
||||
// Adopt flatpickr and update mode
|
||||
var periodPicker = $("#period").get(0)._flatpickr;
|
||||
periodPicker.set('mode', 'range');
|
||||
|
||||
// Fetch list of bookable items
|
||||
var items = $.ajax({
|
||||
url: '/api/v1/biblios/' + biblionumber + '/items?bookable=1' + '&_per_page=-1',
|
||||
dataType: 'json',
|
||||
type: 'GET'
|
||||
});
|
||||
|
||||
// Fetch list of existing bookings
|
||||
var bookings = $.ajax({
|
||||
url: '/api/v1/bookings?biblio_id=' + biblionumber,
|
||||
dataType: 'json',
|
||||
type: 'GET'
|
||||
});
|
||||
|
||||
// Update item select2 and period flatpickr
|
||||
$.when(items, bookings).then(
|
||||
function(items,bookings){
|
||||
|
||||
// Total bookable items
|
||||
var bookable = 0;
|
||||
|
||||
for (item of items[0]) {
|
||||
bookable++;
|
||||
// Populate item select
|
||||
if (!($('#booking_item_id').find("option[value='" + item.item_id + "']").length)) {
|
||||
if (itemnumber && itemnumber == item.item_id) {
|
||||
// Create a DOM Option and pre-select by default
|
||||
var newOption = new Option(escape_str(item.external_id), item.item_id, true, true);
|
||||
// Append it to the select
|
||||
$('#booking_item_id').append(newOption);
|
||||
} else {
|
||||
// Create a DOM Option and de-select by default
|
||||
var newOption = new Option(escape_str(item.external_id), item.item_id, false, false);
|
||||
// Append it to the select
|
||||
$('#booking_item_id').append(newOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw select with new options and enable
|
||||
$('#booking_item_id').trigger('change');
|
||||
$("#booking_item_id").prop("disabled", false);
|
||||
|
||||
// Set disabled dates in datepicker
|
||||
periodPicker.config.disable.push( function(date) {
|
||||
|
||||
// set local copy of selectedDates
|
||||
let selectedDates = periodPicker.selectedDates;
|
||||
|
||||
// set booked counter
|
||||
let booked = 0;
|
||||
|
||||
// reset the unavailable items array
|
||||
let unavailable_items = [];
|
||||
|
||||
// reset the biblio level bookings array
|
||||
let biblio_bookings = [];
|
||||
|
||||
// disable dates before selected date
|
||||
if (selectedDates[0] && selectedDates[0] > date) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// iterate existing bookings
|
||||
for (booking of bookings[0]) {
|
||||
var start_date = flatpickr.parseDate(booking.start_date);
|
||||
var end_date = flatpickr.parseDate(booking.end_date);
|
||||
|
||||
// patron has selected a start date (end date checks)
|
||||
if (selectedDates[0]) {
|
||||
|
||||
// new booking start date is between existing booking start and end dates
|
||||
if (selectedDates[0] >= start_date && selectedDates[0] <= end_date) {
|
||||
if (booking.item_id) {
|
||||
if (unavailable_items.indexOf(booking.item_id) === -1) {
|
||||
unavailable_items.push(booking.item_id);
|
||||
}
|
||||
} else {
|
||||
if (biblio_bookings.indexOf(booking.booking_id) === -1) {
|
||||
biblio_bookings.push(booking.booking_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// new booking end date would be between existing booking start and end dates
|
||||
else if (date >= start_date && date <= end_date) {
|
||||
if (booking.item_id) {
|
||||
if (unavailable_items.indexOf(booking.item_id) === -1) {
|
||||
unavailable_items.push(booking.item_id);
|
||||
}
|
||||
} else {
|
||||
if (biblio_bookings.indexOf(booking.booking_id) === -1) {
|
||||
biblio_bookings.push(booking.booking_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// new booking would span existing booking
|
||||
else if (selectedDates[0] <= start_date && date >= end_date) {
|
||||
if (booking.item_id) {
|
||||
if (unavailable_items.indexOf(booking.item_id) === -1) {
|
||||
unavailable_items.push(booking.item_id);
|
||||
}
|
||||
} else {
|
||||
if (biblio_bookings.indexOf(booking.booking_id) === -1) {
|
||||
biblio_bookings.push(booking.booking_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// new booking would not conflict
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check that there are available items
|
||||
// available = all bookable items - booked items - booked biblios
|
||||
let total_available = items[0].length - unavailable_items.length - biblio_bookings.length;
|
||||
if (total_available === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// patron has not yet selected a start date (start date checks)
|
||||
else if (date <= end_date && date >= start_date) {
|
||||
|
||||
// same item, disable date
|
||||
if (booking.item_id && booking.item_id == itemnumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// count all clashes, both item and biblio level
|
||||
booked++;
|
||||
if (booked == bookable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enable flatpickr now we have date function populated
|
||||
periodPicker.redraw();
|
||||
$("#period_fields :input").prop('disabled', false);
|
||||
|
||||
// Setup listener for item select2
|
||||
$('#booking_item_id').on('select2:select', function(e) {
|
||||
itemnumber = e.params.data.id ? e.params.data.id : null;
|
||||
|
||||
// redraw pariodPicker taking selected item into account
|
||||
periodPicker.redraw();
|
||||
});
|
||||
|
||||
// Set onClose for flatpickr
|
||||
periodPicker.config.onClose.push(function(selectedDates, dateStr, instance) {
|
||||
|
||||
if ( selectedDates[0] && selectedDates[1] ) {
|
||||
// set form fields from picker
|
||||
let picker_start = new Date(selectedDates[0]);
|
||||
let picker_end = new Date(selectedDates[1]);
|
||||
picker_end.setHours(picker_end.getHours()+23);
|
||||
picker_end.setMinutes(picker_end.getMinutes()+59);
|
||||
$('#booking_start_date').val(picker_start.toISOString());
|
||||
$('#booking_end_date').val(picker_end.toISOString());
|
||||
|
||||
// set available items in select2
|
||||
var booked_items = bookings[0].filter(function(booking) {
|
||||
let start_date = flatpickr.parseDate(booking.start_date);
|
||||
let end_date = flatpickr.parseDate(booking.end_date);
|
||||
// This booking ends before the start of the new booking
|
||||
if ( end_date <= selectedDates[0] ) {
|
||||
return false;
|
||||
}
|
||||
// This booking starts after then end of the new booking
|
||||
if ( start_date >= selectedDates[1] ) {
|
||||
return false;
|
||||
}
|
||||
// This booking overlaps
|
||||
return true;
|
||||
});
|
||||
$("#booking_item_id > option").each(function() {
|
||||
let option = $(this);
|
||||
if ( itemnumber && itemnumber == option.val() ) {
|
||||
console.log("itemnumber defined and equal to value");
|
||||
} else if ( booked_items.some(function(booked_item){
|
||||
return option.val() == booked_item.item_id;
|
||||
}) ) {
|
||||
option.prop('disabled',true);
|
||||
} else {
|
||||
option.prop('disabled',false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
function(jqXHR, textStatus, errorThrown){
|
||||
console.log("Fetch failed");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#placeBookingForm").on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var url = '/api/v1/bookings';
|
||||
|
||||
var start_date = $('#booking_start_date').val();
|
||||
var end_date = $('#booking_end_date').val();
|
||||
var item_id = $('#booking_item_id').val();
|
||||
|
||||
var posting = $.post(
|
||||
url,
|
||||
JSON.stringify({
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"biblio_id": $('#booking_biblio_id').val(),
|
||||
"item_id": item_id != 0 ? item_id : null,
|
||||
"patron_id": $('#booking_patron_id').find(':selected').val()
|
||||
})
|
||||
);
|
||||
|
||||
posting.done(function(data) {
|
||||
$('#placeBookingModal').modal('hide');
|
||||
|
||||
// Reset modal form
|
||||
$('#booking_patron_id').val(null).trigger('change');
|
||||
$('#booking_item_id').val(null).trigger('change');
|
||||
$("#period").get(0)._flatpickr.clear();
|
||||
$('#booking_start_date').val('');
|
||||
$('#booking_end_date').val('');
|
||||
});
|
||||
|
||||
posting.fail(function(data) {
|
||||
$('#booking_result').replaceWith('<div id="booking_result" class="alert alert-danger">Failure</div>');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue