View | Details | Raw Unified | Return to bug 29002
Collapse All | Expand All

(-)a/Koha/Biblio.pm (-3 / +18 lines)
Lines 200-205 sub can_article_request { Link Here
200
    return q{};
200
    return q{};
201
}
201
}
202
202
203
203
=head3 check_booking
204
=head3 check_booking
204
205
205
  my $bookable =
206
  my $bookable =
Lines 218-224 sub check_booking { Link Here
218
    my $end_date   = dt_from_string( $params->{end_date} );
219
    my $end_date   = dt_from_string( $params->{end_date} );
219
    my $booking_id = $params->{booking_id};
220
    my $booking_id = $params->{booking_id};
220
221
221
    my $bookable_items = $self->items;
222
    my $bookable_items = $self->bookable_items;
222
    my $total_bookable = $bookable_items->count;
223
    my $total_bookable = $bookable_items->count;
223
224
224
    my $dtf = Koha::Database->new->schema->storage->datetime_parser;
225
    my $dtf = Koha::Database->new->schema->storage->datetime_parser;
Lines 285-292 sub place_booking { Link Here
285
        {
286
        {
286
            start_date     => $params->{start_date},
287
            start_date     => $params->{start_date},
287
            end_date       => $params->{end_date},
288
            end_date       => $params->{end_date},
288
            borrowernumber => $patron->borrowernumber,
289
            patron_id      => $patron->borrowernumber,
289
            biblionumber   => $self->biblionumber
290
            biblio_id      => $self->biblionumber
290
        }
291
        }
291
    )->store();
292
    )->store();
292
    return $booking;
293
    return $booking;
Lines 570-575 sub items { Link Here
570
    return Koha::Items->_new_from_dbic( $items_rs );
571
    return Koha::Items->_new_from_dbic( $items_rs );
571
}
572
}
572
573
574
=head3 bookable_items
575
576
  my $bookable_items = $biblio->bookable_items;
577
578
Returns the related Koha::Items resultset filtered to those items that can be booked.
579
580
=cut
581
582
sub bookable_items {
583
    my ($self) = @_;
584
    return $self->items->filter_by_bookable;
585
}
586
587
573
=head3 host_items
588
=head3 host_items
574
589
575
my $host_items = $biblio->host_items();
590
my $host_items = $biblio->host_items();
(-)a/Koha/Items.pm (+18 lines)
Lines 158-163 sub filter_out_lost { Link Here
158
    return $self->search( $params );
158
    return $self->search( $params );
159
}
159
}
160
160
161
=head3 filter_by_bookable
162
163
  my $filterd_items = $items->filter_by_bookable;
164
165
Returns a new resultset, containing only those items that are allowed to be booked.
166
167
=cut
168
169
sub filter_by_bookable {
170
    my ($self) = @_;
171
172
    return $self->search(
173
        {
174
            notforloan => [ 0, undef ],
175
            withdrawn  => [ 0, undef ]
176
        }
177
    );
178
}
161
179
162
=head3 move_to_biblio
180
=head3 move_to_biblio
163
181
(-)a/Koha/REST/V1/Biblios.pm (+2 lines)
Lines 290-295 sub get_items { Link Here
290
    my $c = shift->openapi->valid_input or return;
290
    my $c = shift->openapi->valid_input or return;
291
291
292
    my $biblio = Koha::Biblios->find( { biblionumber => $c->validation->param('biblio_id') }, { prefetch => ['items'] } );
292
    my $biblio = Koha::Biblios->find( { biblionumber => $c->validation->param('biblio_id') }, { prefetch => ['items'] } );
293
    my $bookable_only = delete $c->validation->output->{bookable};
293
294
294
    unless ( $biblio ) {
295
    unless ( $biblio ) {
295
        return $c->render(
296
        return $c->render(
Lines 303-308 sub get_items { Link Here
303
    return try {
304
    return try {
304
305
305
        my $items_rs = $biblio->items;
306
        my $items_rs = $biblio->items;
307
        $items_rs = $items_rs->filter_by_bookable if $bookable_only;
306
        my $items    = $c->objects->search( $items_rs );
308
        my $items    = $c->objects->search( $items_rs );
307
        return $c->render(
309
        return $c->render(
308
            status  => 200,
310
            status  => 200,
(-)a/api/v1/swagger/paths/biblios.yaml (+5 lines)
Lines 424-429 Link Here
424
      - $ref: "../swagger.yaml#/parameters/q_body"
424
      - $ref: "../swagger.yaml#/parameters/q_body"
425
      - $ref: "../swagger.yaml#/parameters/q_header"
425
      - $ref: "../swagger.yaml#/parameters/q_header"
426
      - $ref: "../swagger.yaml#/parameters/request_id_header"
426
      - $ref: "../swagger.yaml#/parameters/request_id_header"
427
      - name: bookable
428
        in: query
429
        description: Limit to items that are bookable
430
        required: false
431
        type: boolean
427
    consumes:
432
    consumes:
428
      - application/json
433
      - application/json
429
    produces:
434
    produces:
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc (+4 lines)
Lines 194-199 Link Here
194
    [% END %]
194
    [% END %]
195
[% END %]
195
[% END %]
196
196
197
<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>
198
197
[% IF Koha.Preference('ArticleRequests') %]
199
[% IF Koha.Preference('ArticleRequests') %]
198
    <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 fa-file-text-o"></i> Request article</a></div>
200
    <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 fa-file-text-o"></i> Request article</a></div>
199
[% END %]
201
[% END %]
Lines 244-246 Link Here
244
        </div>
246
        </div>
245
        </div>
247
        </div>
246
    </div>
248
    </div>
249
250
    [% INCLUDE modals/place_booking.inc %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc (+50 lines)
Line 0 Link Here
1
<!-- Place booking modal -->
2
<div class="modal" id="placeBookingModal" tabindex="-1" role="dialog" aria-labelledby="placeBookingLabel">
3
    <form id="placeBookingForm">
4
        <div class="modal-dialog" role="document">
5
            <div class="modal-content">
6
                <div class="modal-header">
7
                    <button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
8
                    <h4 class="modal-title" id="placeBookingLabel">Place booking</h4>
9
                </div>
10
                <div class="modal-body">
11
                    <div id="booking_result"></div>
12
                    <fieldset class="rows">
13
                        <input type="hidden" name="biblio_id" id="booking_biblio_id">
14
                        <input type="hidden" name="start_date" id="booking_start_date">
15
                        <input type="hidden" name="end_date" id="booking_end_date">
16
                        <ol>
17
                            <li>
18
                                <div class="hint">Enter patron card number or partial name:</div>
19
                                <label class="required" for="booking_patron_id">Patron: </label>
20
                                <select name="booking_patron_id" id="booking_patron_id" required="required">
21
                                    <option></option>
22
                                    [% IF patron %]
23
                                    <option value="[% borrowernumber | uri %]" selected="selected">[% patron.firstname | html %] [% patron.surname %] ([% patron.cardnumber %] )</option>
24
                                    [% END %]
25
                                </select>
26
                            </li>
27
                            <label for="booking_item_id">Item: </label>
28
                                <select name="booking_item_id" id="booking_item_id" disabled="true">
29
                                    <option value="0">Any item</option>
30
                                </select>
31
                            </li>
32
33
                            <li>
34
                                <div id="period_fields">
35
                                    <label class="required" for="period">Period: </label>
36
                                    <input type="text" id="period" name="period" class="flatpickr" data-flatpickr-futuredate="true" required="required" disabled="true" autocomplete="off">
37
                                    <span class="required">Required</span>
38
                                </div>
39
                            </li>
40
                        </ol>
41
                    </fieldset>
42
                </div> <!-- /.modal-body -->
43
                <div class="modal-footer">
44
                    <button type="submit" class="btn btn-primary">Submit</button>
45
                    <button type="button" class="btn btn-default" data-dismiss="modal" aria-hidden="true">Cancel</button>
46
                </div> <!-- /.modal-footer -->
47
            </div> <!-- /.modal-content -->
48
        </div> <!-- /.modal-dialog -->
49
    </form>
50
</div> <!-- /#placeBookingModal -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt (+4 lines)
Lines 1345-1350 Link Here
1345
1345
1346
[% MACRO jsinclude BLOCK %]
1346
[% MACRO jsinclude BLOCK %]
1347
    [% INCLUDE 'catalog-strings.inc' %]
1347
    [% INCLUDE 'catalog-strings.inc' %]
1348
    [% INCLUDE 'calendar.inc' %]
1349
    [% INCLUDE 'select2.inc' %]
1350
    [% INCLUDE 'js-date-format.inc' %]
1348
    [% Asset.js("js/catalog.js") | $raw %]
1351
    [% Asset.js("js/catalog.js") | $raw %]
1349
    [% Asset.js("js/recalls.js") | $raw %]
1352
    [% Asset.js("js/recalls.js") | $raw %]
1350
    [% Asset.js("js/coce.js") | $raw %]
1353
    [% Asset.js("js/coce.js") | $raw %]
Lines 1814-1819 Link Here
1814
    [% INCLUDE 'js-biblio-format.inc' %]
1817
    [% INCLUDE 'js-biblio-format.inc' %]
1815
    [% Asset.js("js/browser.js") | $raw %]
1818
    [% Asset.js("js/browser.js") | $raw %]
1816
    [% Asset.js("js/table_filters.js") | $raw %]
1819
    [% Asset.js("js/table_filters.js") | $raw %]
1820
    [% Asset.js("js/place_booking_modal.js") | $raw %]
1817
    <script>
1821
    <script>
1818
        var browser;
1822
        var browser;
1819
        browser = KOHA.browser('[% searchid | html %]', parseInt(biblionumber, 10));
1823
        browser = KOHA.browser('[% searchid | html %]', parseInt(biblionumber, 10));
(-)a/koha-tmpl/intranet-tmpl/prog/js/place_booking_modal.js (-1 / +319 lines)
Line 0 Link Here
0
- 
1
$('#placeBookingModal').on('show.bs.modal', function(e) {
2
    var button = $(e.relatedTarget);
3
    var biblionumber = button.data('biblionumber');
4
    var itemnumber = button.data('itemnumber') || 0;
5
    $('#booking_biblio_id').val(biblionumber);
6
7
    // Patron select2
8
    $("#booking_patron_id").kohaSelect({
9
        dropdownParent: $(".modal-content", "#placeBookingModal"),
10
        width: '30%',
11
        dropdownAutoWidth: true,
12
        allowClear: true,
13
        minimumInputLength: 3,
14
        ajax: {
15
            url: '/api/v1/patrons',
16
            delay: 250,
17
            dataType: 'json',
18
            data: function(params) {
19
                var search_term = (params.term === undefined) ? '' : params.term;
20
                var query = {
21
                    'q': JSON.stringify({
22
                        "-or": [{
23
                                "firstname": {
24
                                    "-like": search_term + '%'
25
                                }
26
                            },
27
                            {
28
                                "surname": {
29
                                    "-like": search_term + '%'
30
                                }
31
                            },
32
                            {
33
                                "cardnumber": {
34
                                    "-like": search_term + '%'
35
                                }
36
                            }
37
                        ]
38
                    }),
39
                    '_order_by': 'firstname',
40
                    '_page': params.page,
41
                };
42
                return query;
43
            },
44
            processResults: function(data, params) {
45
                var results = [];
46
                data.results.forEach(function(patron) {
47
                    results.push({
48
                        "id": patron.patron_id,
49
                        "text": escape_str(patron.firstname) + " " + escape_str(patron.surname) + " (" + escape_str(patron.cardnumber) + ")"
50
                    });
51
                });
52
                return {
53
                    "results": results, "pagination": { "more": data.pagination.more }
54
                };
55
            },
56
        },
57
        placeholder: "Search for a patron"
58
    });
59
60
    // If passed patron, pre-select
61
    var patron_id = button.data('patron') || 0;
62
    if (patron_id) {
63
        var patron = $.ajax({
64
            url: '/api/v1/patrons/' + patron_id,
65
            dataType: 'json',
66
            type: 'GET'
67
        });
68
69
        $.when(patron).then(
70
            function(patron){
71
                var newOption = new Option(escape_str(patron.firstname) + " " + escape_str(patron.surname) + " (" + escape_str(patron.cardnumber) + ")", patron.patron_id, true, true);
72
                $('#booking_patron_id').append(newOption).trigger('change');
73
            }
74
        );
75
    }
76
77
    // Item select2
78
    $("#booking_item_id").select2({
79
        dropdownParent: $(".modal-content", "#placeBookingModal"),
80
        width: '30%',
81
        dropdownAutoWidth: true,
82
        minimumResultsForSearch: 20,
83
        placeholder: "Select item"
84
    });
85
86
    // Adopt flatpickr and update mode
87
    var periodPicker = $("#period").get(0)._flatpickr;
88
    periodPicker.set('mode', 'range');
89
90
    // Fetch list of bookable items
91
    var items = $.ajax({
92
        url: '/api/v1/biblios/' + biblionumber + '/items?bookable=1' + '&_per_page=-1',
93
        dataType: 'json',
94
        type: 'GET'
95
    });
96
97
    // Fetch list of existing bookings
98
    var bookings = $.ajax({
99
        url: '/api/v1/bookings?biblio_id=' + biblionumber,
100
        dataType: 'json',
101
        type: 'GET'
102
    });
103
104
    // Update item select2 and period flatpickr
105
    $.when(items, bookings).then(
106
        function(items,bookings){
107
108
            // Total bookable items
109
            var bookable = 0;
110
111
            for (item of items[0]) {
112
                bookable++;
113
                // Populate item select
114
                if (!($('#booking_item_id').find("option[value='" + item.item_id + "']").length)) {
115
                    if (itemnumber && itemnumber == item.item_id) {
116
                        // Create a DOM Option and pre-select by default
117
                        var newOption = new Option(escape_str(item.external_id), item.item_id, true, true);
118
                        // Append it to the select
119
                        $('#booking_item_id').append(newOption);
120
                    } else {
121
                        // Create a DOM Option and de-select by default
122
                        var newOption = new Option(escape_str(item.external_id), item.item_id, false, false);
123
                        // Append it to the select
124
                        $('#booking_item_id').append(newOption);
125
                    }
126
                }
127
            }
128
129
            // Redraw select with new options and enable
130
            $('#booking_item_id').trigger('change');
131
            $("#booking_item_id").prop("disabled", false);
132
133
            // Set disabled dates in datepicker
134
            periodPicker.config.disable.push( function(date) {
135
136
                // set local copy of selectedDates
137
                let selectedDates = periodPicker.selectedDates;
138
139
                // set booked counter
140
                let booked = 0;
141
142
                // reset the unavailable items array
143
                let unavailable_items = [];
144
145
                // reset the biblio level bookings array
146
                let biblio_bookings = [];
147
148
                // disable dates before selected date
149
                if (selectedDates[0] && selectedDates[0] > date) {
150
                    return true;
151
                }
152
153
                // iterate existing bookings
154
                for (booking of bookings[0]) {
155
                    var start_date = flatpickr.parseDate(booking.start_date);
156
                    var end_date = flatpickr.parseDate(booking.end_date);
157
158
                    // patron has selected a start date (end date checks)
159
                    if (selectedDates[0]) {
160
161
                        // new booking start date is between existing booking start and end dates
162
                        if (selectedDates[0] >= start_date && selectedDates[0] <= end_date) {
163
                            if (booking.item_id) {
164
                                if (unavailable_items.indexOf(booking.item_id) === -1) {
165
                                    unavailable_items.push(booking.item_id);
166
                                }
167
                            } else {
168
                                if (biblio_bookings.indexOf(booking.booking_id) === -1) {
169
                                    biblio_bookings.push(booking.booking_id);
170
                                }
171
                            }
172
                        }
173
174
                        // new booking end date would be between existing booking start and end dates
175
                        else if (date >= start_date && date <= end_date) {
176
                            if (booking.item_id) {
177
                                if (unavailable_items.indexOf(booking.item_id) === -1) {
178
                                    unavailable_items.push(booking.item_id);
179
                                }
180
                            } else {
181
                                if (biblio_bookings.indexOf(booking.booking_id) === -1) {
182
                                    biblio_bookings.push(booking.booking_id);
183
                                }
184
                            }
185
                        }
186
187
                        // new booking would span existing booking
188
                        else if (selectedDates[0] <= start_date && date >= end_date) {
189
                            if (booking.item_id) {
190
                                if (unavailable_items.indexOf(booking.item_id) === -1) {
191
                                    unavailable_items.push(booking.item_id);
192
                                }
193
                            } else {
194
                                if (biblio_bookings.indexOf(booking.booking_id) === -1) {
195
                                    biblio_bookings.push(booking.booking_id);
196
                                }
197
                            }
198
                        }
199
200
                        // new booking would not conflict
201
                        else {
202
                            continue;
203
                        }
204
205
                        // check that there are available items
206
                        // available = all bookable items - booked items - booked biblios
207
                        let total_available = items[0].length - unavailable_items.length - biblio_bookings.length;
208
                        if (total_available === 0) {
209
                            return true;
210
                        }
211
                    }
212
213
                    // patron has not yet selected a start date (start date checks)
214
                    else if (date <= end_date && date >= start_date) {
215
216
                        // same item, disable date
217
                        if (booking.item_id && booking.item_id === itemnumber) {
218
                            return true;
219
                        }
220
221
                        // count all clashes, both item and biblio level
222
                        booked++;
223
                        if (booked == bookable) {
224
                            return true;
225
                        }
226
                    }
227
                }
228
            });
229
230
            // Enable flatpickr now we have date function populated
231
            periodPicker.redraw();
232
            $("#period_fields :input").prop('disabled', false);
233
234
            // Setup listener for item select2
235
            $('#booking_item_id').on('select2:select', function(e) {
236
                itemnumber = e.params.data.id ? e.params.data.id : null;
237
238
                // redraw pariodPicker taking selected item into account
239
                periodPicker.redraw();
240
            });
241
242
            // Set onClose for flatpickr
243
            periodPicker.config.onClose.push(function(selectedDates, dateStr, instance) {
244
245
                let startDate = new Date(selectedDates[0]);
246
247
                // set end datetime hours and minutes to the end of the day
248
                let endDate = new Date(selectedDates[1]);
249
                endDate.setHours(endDate.getHours()+23);
250
                endDate.setMinutes(endDate.getMinutes()+59);
251
252
                $('#booking_start_date').val(startDate.toISOString());
253
                $('#booking_end_date').val(endDate.toISOString());
254
255
                // set available items in select2
256
                if ( selectedDates[0] && selectedDates[1] ) {
257
                    var booked_items = bookings[0].filter(function(booking) {
258
                        let start_date = flatpickr.parseDate(booking.start_date);
259
                        let end_date = flatpickr.parseDate(booking.end_date);
260
                        // This booking ends before the start of the new booking
261
                        if ( end_date <= selectedDates[0] ) {
262
                            return false;
263
                        }
264
                        // This booking starts after then end of the new booking
265
                        if ( start_date >= selectedDates[1] ) {
266
                            return false;
267
                        }
268
                        // This booking overlaps
269
                        return true;
270
                    });
271
                    $("#booking_item_id > option").each(function() {
272
                        let option = $(this);
273
                        if ( itemnumber && itemnumber == option.val() ) {
274
                            console.log("itemnumber defined and equal to value");
275
                        } else if ( booked_items.some(function(booked_item){
276
                            return option.val() == booked_item.item_id;
277
                        }) ) {
278
                            option.prop('disabled',true);
279
                        } else {
280
                            option.prop('disabled',false);
281
                        }
282
                    });
283
                }
284
            });
285
        },
286
        function(jqXHR, textStatus, errorThrown){
287
            console.log("Fetch failed");
288
        }
289
    );
290
});
291
292
$("#placeBookingForm").on('submit', function(e) {
293
    e.preventDefault();
294
295
    var url = '/api/v1/bookings';
296
297
    var start_date = $('#booking_start_date').val();
298
    var end_date = $('#booking_end_date').val();
299
    var item_id = $('#booking_item_id').val();
300
301
    var posting = $.post(
302
        url,
303
        JSON.stringify({
304
            "start_date": start_date,
305
            "end_date": end_date,
306
            "biblio_id": $('#booking_biblio_id').val(),
307
            "item_id": item_id != 0 ? item_id : null,
308
            "patron_id": $('#booking_patron_id').find(':selected').val()
309
        })
310
    );
311
312
    posting.done(function(data) {
313
        $('#placeBookingModal').modal('hide');
314
    });
315
316
    posting.fail(function(data) {
317
        $('#booking_result').replaceWith('<div id="booking_result" class="alert alert-danger">Failure</div>');
318
    });
319
});

Return to bug 29002