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

(-)a/Koha/Booking.pm (-14 / +21 lines)
Lines 27-32 use Koha::Libraries; Link Here
27
27
28
use C4::Letters;
28
use C4::Letters;
29
29
30
use List::Util qw(any);
31
30
use base qw(Koha::Object);
32
use base qw(Koha::Object);
31
33
32
=head1 NAME
34
=head1 NAME
Lines 259-280 sub delete { Link Here
259
261
260
=head3 edit
262
=head3 edit
261
263
262
This method adds possibility to edit a booking
264
This method allows patching a booking
263
265
264
=cut
266
=cut
265
267
266
sub edit {
268
sub edit {
267
    my ( $self, $params ) = @_;
269
    my ( $self, $params ) = @_;
268
270
269
    if ( $params->{status} ) {
271
    my $new_status = $params->{'status'};
270
        if ( $params->{status} eq 'cancelled' ) {
272
    unless ($new_status) {
271
            $self->cancel( { send_letter => 1 } );
273
        return $self->store;
272
        }
273
        $self->_set_status( $params->{status} );
274
    }
274
    }
275
    $self->store();
276
275
277
    return $self;
276
    $self->_set_status($new_status);
277
278
    my $status = $self->status;
279
    if ( $status eq 'cancelled' ) {
280
        $self->cancel( { send_letter => 1 } );
281
    }
282
283
    return $self->store;
278
}
284
}
279
285
280
=head3 cancel
286
=head3 cancel
Lines 290-296 sub cancel { Link Here
290
    my $patron         = $self->patron;
296
    my $patron         = $self->patron;
291
    my $pickup_library = $self->pickup_library;
297
    my $pickup_library = $self->pickup_library;
292
298
293
    if ( $params->{send_letter} ) {
299
    if ( $params->{'send_letter'} ) {
294
        my $letter = C4::Letters::GetPreparedLetter(
300
        my $letter = C4::Letters::GetPreparedLetter(
295
            module                 => 'bookings',
301
            module                 => 'bookings',
296
            letter_code            => 'BOOKING_CANCELLATION',
302
            letter_code            => 'BOOKING_CANCELLATION',
Lines 313-319 sub cancel { Link Here
313
}
319
}
314
320
315
321
316
=head3 set_status
322
=head2 Internal methods
323
324
=head3 _set_status
317
325
318
This method changes the status of a booking
326
This method changes the status of a booking
319
327
Lines 322-337 This method changes the status of a booking Link Here
322
sub _set_status {
330
sub _set_status {
323
    my ( $self, $new_status ) = @_;
331
    my ( $self, $new_status ) = @_;
324
332
325
    my @valid_statuses = qw(pending completed cancelled);
333
    my @valid_statuses = qw(new completed cancelled);
326
    unless ( grep { $_ eq $new_status } @valid_statuses ) {
334
    my $is_valid       = any { $new_status eq $_ } @valid_statuses;
335
    unless ($is_valid) {
327
        die "Invalid status: $new_status";
336
        die "Invalid status: $new_status";
328
    }
337
    }
329
338
330
    $self->status($new_status);
339
    $self->status($new_status);
331
}
340
}
332
341
333
=head2 Internal methods
334
335
=head3 _type
342
=head3 _type
336
343
337
=cut
344
=cut
(-)a/Koha/Bookings.pm (-3 / +3 lines)
Lines 36-42 Koha::Bookings - Koha Booking object set class Link Here
36
36
37
    $bookings->filter_by_active;
37
    $bookings->filter_by_active;
38
38
39
Will return the bookings that have not ended and without "CANCELLED" status.
39
Will return the bookings that have not ended, were cancelled or are completed.
40
40
41
=cut
41
=cut
42
42
Lines 44-51 sub filter_by_active { Link Here
44
    my ($self) = @_;
44
    my ($self) = @_;
45
    return $self->search(
45
    return $self->search(
46
        {
46
        {
47
            end_date => { '>=' => \'NOW()' },
47
            end_date => { '>='  => \'NOW()' },
48
            status   => { '!=' => 'cancelled' }
48
            status   => { q{!=} => [ -and => qw(cancelled completed) ] }
49
        }
49
        }
50
    );
50
    );
51
}
51
}
(-)a/Koha/REST/V1/Bookings.pm (-5 / +4 lines)
Lines 150-169 sub delete { Link Here
150
150
151
=head3 edit
151
=head3 edit
152
152
153
Controller function that editing an existing booking
153
Controller function that handles editing an existing booking
154
154
155
=cut
155
=cut
156
156
157
sub edit {
157
sub edit {
158
    my $c    = shift->openapi->valid_input or return;
158
    my $c = shift->openapi->valid_input or return;
159
    my $body = $c->req->json;
160
159
161
    my $booking = Koha::Bookings->find( $c->param('booking_id') );
160
    my $booking = $c->objects->find_rs( Koha::Bookings->new, $c->param('booking_id') );
162
    return $c->render_resource_not_found("Booking")
161
    return $c->render_resource_not_found("Booking")
163
        unless $booking;
162
        unless $booking;
164
163
165
    return try {
164
    return try {
166
        $booking->edit($body);
165
        $booking->edit( $c->req->json );
167
        return $c->render(
166
        return $c->render(
168
            status  => 200,
167
            status  => 200,
169
            openapi => $c->objects->to_api($booking),
168
            openapi => $c->objects->to_api($booking),
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt (-47 / +76 lines)
Lines 50-58 Link Here
50
                    <h1>Bookings for [% INCLUDE 'biblio-title-head.inc' %]</h1>
50
                    <h1>Bookings for [% INCLUDE 'biblio-title-head.inc' %]</h1>
51
                    <div class="page-section" id="bookings-timeline"></div>
51
                    <div class="page-section" id="bookings-timeline"></div>
52
                    <div class="page-section">
52
                    <div class="page-section">
53
                        <fieldset class="action filters" style="cursor:pointer; display: flex; flex-direction: column;">
53
                        <fieldset class="action filters d-flex gap-2" style="cursor: pointer;">
54
                            <a id="expired_filter" class="filtered"><i class="fa fa-bars"></i> Show expired</a>
54
                            <a id="expired_filter" data-filter="expired"><i class="fa fa-bars"></i> Show expired</a>
55
                            <a id="cancelled_filter" class="filtered"><i class="fa fa-bars"></i> Show cancelled</a>
55
                            <a id="cancelled_filter" data-filter="cancelled"><i class="fa fa-bars"></i> Show cancelled</a>
56
                        </fieldset>
56
                        </fieldset>
57
57
58
                        <table id="bookings_table"></table>
58
                        <table id="bookings_table"></table>
Lines 118-123 Link Here
118
118
119
                var bookingsSet = new vis.DataSet();
119
                var bookingsSet = new vis.DataSet();
120
                for (booking of bookings[0]){
120
                for (booking of bookings[0]){
121
                    const isActive = ["new", "pending", "active"].includes(booking.status);
122
                    const patronContent = $patron_to_html(booking.patron, {
123
                        display_cardnumber: true,
124
                        url: false
125
                    });
121
                    bookingsSet.add({
126
                    bookingsSet.add({
122
                        id: booking.booking_id,
127
                        id: booking.booking_id,
123
                        booking: booking.booking_id,
128
                        booking: booking.booking_id,
Lines 125-142 Link Here
125
                        pickup_library: booking.pickup_library_id,
130
                        pickup_library: booking.pickup_library_id,
126
                        start: dayjs(booking.start_date).toDate(),
131
                        start: dayjs(booking.start_date).toDate(),
127
                        end: dayjs(booking.end_date).toDate(),
132
                        end: dayjs(booking.end_date).toDate(),
128
                        content: $patron_to_html(booking.patron, {
133
                        content: !isActive ? `<s>${patronContent}</s>` : patronContent,
129
                            display_cardnumber: true,
130
                            url: false
131
                        }),
132
                        [% IF CAN_user_circulate_manage_bookings %]
134
                        [% IF CAN_user_circulate_manage_bookings %]
133
                        editable: booking.status != 'cancelled' ? { remove: true, updateTime: true } : false,
135
                        editable: booking.status !== "cancelled" ? { remove: true, updateTime: true } : false,
134
                        [% ELSE %]
136
                        [% ELSE %]
135
                        editable: false,
137
                        editable: false,
136
                        [% END %]
138
                        [% END %]
137
                        type: 'range',
139
                        type: 'range',
138
                        group: booking.item_id ? booking.item_id : 0,
140
                        group: booking.item_id ?? 0,
139
                        className: booking.status,
140
                    });
141
                    });
141
                }
142
                }
142
143
Lines 225-248 Link Here
225
            }
226
            }
226
        );
227
        );
227
228
228
        let filter_expired = true;
229
        const filterStates = { expired: true, cancelled: false };
229
        let filter_cancelled = false;
230
        const additional_filters = {
230
        let additional_filters = {
231
            end_date: () => {
231
            end_date: function(){
232
                if (filterStates.expired) {
232
                if ( filter_expired ) {
233
                    let today = new Date();
233
                    let today = new Date();
234
                    return { ">=": today.toISOString() }
234
                    return { ">=": today.toISOString() };
235
                } else {
236
                    return;
237
                }
235
                }
238
            },
236
            },
239
            status: function(){
237
            status: () => {
240
                if ( filter_cancelled ) {
238
                const defaults = ["new", "pending", "active"];
241
                    return { "=": 'cancelled' }
239
                if (filterStates.cancelled) {
242
                } else {
240
                    const filtered = [...defaults, "cancelled"];
243
                    return;
241
                    return { "-in": filtered };
244
                }
242
                }
245
            }
243
244
                return { "-in": defaults };
245
            },
246
        };
246
        };
247
247
248
        var bookings_table_url = "/api/v1/biblios/[% biblionumber | uri %]/bookings";
248
        var bookings_table_url = "/api/v1/biblios/[% biblionumber | uri %]/bookings";
Lines 268-282 Link Here
268
                orderable: false,
268
                orderable: false,
269
                visible: false,
269
                visible: false,
270
                render: function (data, type, row, meta) {
270
                render: function (data, type, row, meta) {
271
                    let is_expired = dayjs(row.end_date).isBefore(new Date());
271
                    const is_expired = dayjs(row.end_date).isBefore(new Date());
272
                    let is_cancelled = row.status == 'cancelled' ? 1 : 0;
273
                    if (is_expired) {
272
                    if (is_expired) {
274
                        return '<span class="badge rounded-pill bg-secondary">' + _("Expired") + '</span>';
273
                        return `<span class="badge rounded-pill bg-secondary">
274
                            ${_("Expired")}
275
                        </span>`;
275
                    }
276
                    }
277
278
                    const is_cancelled = row.status === "cancelled";
276
                    if (is_cancelled) {
279
                    if (is_cancelled) {
277
                        return '<span class="badge rounded-pill bg-secondary">' + _("Cancelled") + '</span>';
280
                        return `<span class="badge rounded-pill bg-secondary">
281
                            ${_("Cancelled")}
282
                        </span>`;
278
                    }
283
                    }
279
                    return '<span class="badge rounded-pill bg-success">' + _("Active") + '</span>';
284
285
                    return `<span class="badge rounded-pill bg-success">
286
                        ${_("New")}
287
                    </span>`;
280
                }
288
                }
281
            },
289
            },
282
            {
290
            {
Lines 340-348 Link Here
340
                "orderable": false,
348
                "orderable": false,
341
                "render": function(data, type, row, meta) {
349
                "render": function(data, type, row, meta) {
342
                    let result = "";
350
                    let result = "";
343
                    let is_cancelled = row.status == 'cancelled' ? 1 : 0;
351
                    let is_cancelled = row.status === "cancelled";
344
                    [% IF CAN_user_circulate_manage_bookings %]
352
                    [% IF CAN_user_circulate_manage_bookings %]
345
                    if( !is_cancelled ){
353
                    if (!is_cancelled) {
346
                        result += '<button type="button" class="btn btn-default btn-xs edit-action" data-bs-toggle="modal" data-bs-target="#placeBookingModal" data-booking="'+row.booking_id+'" data-biblionumber="[% biblionumber | uri %]" data-itemnumber="'+row.item_id+'" data-patron="'+row.patron_id+'" data-pickup_library="'+row.pickup_library_id+'" data-start_date="'+row.start_date+'" data-end_date="'+row.end_date+'"><i class="fa fa-pencil" aria-hidden="true"></i> '+_("Edit")+'</button>';
354
                        result += '<button type="button" class="btn btn-default btn-xs edit-action" data-bs-toggle="modal" data-bs-target="#placeBookingModal" data-booking="'+row.booking_id+'" data-biblionumber="[% biblionumber | uri %]" data-itemnumber="'+row.item_id+'" data-patron="'+row.patron_id+'" data-pickup_library="'+row.pickup_library_id+'" data-start_date="'+row.start_date+'" data-end_date="'+row.end_date+'"><i class="fa fa-pencil" aria-hidden="true"></i> '+_("Edit")+'</button>';
347
                        result += '<button type="button" class="btn btn-default btn-xs cancel-action" data-bs-toggle="modal" data-bs-target="#cancelBookingModal" data-booking="'+row.booking_id+'"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Cancel")+'</button>';
355
                        result += '<button type="button" class="btn btn-default btn-xs cancel-action" data-bs-toggle="modal" data-bs-target="#cancelBookingModal" data-booking="'+row.booking_id+'"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Cancel")+'</button>';
348
                    }
356
                    }
Lines 352-376 Link Here
352
            }]
360
            }]
353
        }, [], 0, additional_filters);
361
        }, [], 0, additional_filters);
354
362
355
        function setupFilter(filterId, activeText, inactiveText, filterVariable) {
363
        document
356
            $(filterId).on("click", function() {
364
            .getElementById("expired_filter")
357
                if ($(this).hasClass('filtered')) {
365
            .addEventListener("click", (e) =>
358
                    filterVariable = false;
366
                handleFilter(
359
                    $(this).html('<i class="fa fa-filter"></i> ' + inactiveText);
367
                    e,
360
                } else {
368
                    { active: _("Hide expired"), inactive: _("Show expired") },
361
                    filterVariable = true;
369
                    filterStates,
362
                    $(this).html('<i class="fa fa-bars"></i> ' + activeText);
370
                ),
363
                }
371
            );
364
372
365
                bookings_table.DataTable().ajax.reload(() => {
373
        document
366
                    bookings_table.DataTable().column("status:name").visible(!filterVariable, false);
374
            .getElementById("cancelled_filter")
367
                });
375
            .addEventListener("click", (e) =>
368
                $(this).toggleClass('filtered');
376
                handleFilter(
377
                    e,
378
                    { active: _("Hide cancelled"), inactive: _("Show cancelled") },
379
                    filterStates,
380
                ),
381
            );
382
383
        function handleFilter(e, text, filterStates) {
384
            const target = e.target;
385
            const { filter } = target.dataset;
386
            if (target.classList.contains("filtered")) {
387
                filterStates[filter] = false;
388
                target.innerHTML = `<i class="fa fa-bars"></i> ${text.inactive}`;
389
            } else {
390
                filterStates[filter] = true;
391
                target.innerHTML = `<i class="fa fa-filter"></i> ${text.active}`;
392
            }
393
394
            bookings_table.DataTable().ajax.reload(() => {
395
                bookings_table
396
                    .DataTable()
397
                    .column("status:name")
398
                    .visible(filterStates[filter], false);
369
            });
399
            });
370
        }
371
400
372
        setupFilter("#expired_filter", _("Show expired"), _("Hide expired"), filter_expired);
401
            target.classList.toggle("filtered");
373
        setupFilter("#cancelled_filter", _("Show cancelled"), _("Hide cancelled"), filter_cancelled);
402
        }
374
403
375
    });
404
    });
376
</script>
405
</script>
(-)a/koha-tmpl/intranet-tmpl/prog/js/cancel_booking_modal.js (-34 / +78 lines)
Lines 1-36 Link Here
1
$('#cancelBookingModal').on('show.bs.modal', function(e) {
1
(() => {
2
    var button = $(e.relatedTarget);
2
    document
3
    var booking = button.data('booking');
3
        .getElementById("cancelBookingModal")
4
    $('#cancel_booking_id').val(booking);
4
        ?.addEventListener("show.bs.modal", handleShowBsModal);
5
});
5
    document
6
6
        .getElementById("cancelBookingForm")
7
$("#cancelBookingForm").on('submit', function(e) {
7
        ?.addEventListener("submit", handleSubmit);
8
    e.preventDefault();
8
9
9
    async function handleSubmit(e) {
10
    var booking_id = $('#cancel_booking_id').val();
10
        e.preventDefault();
11
    var url = '/api/v1/bookings/'+booking_id;
11
12
12
        const bookingIdInput = document.getElementById("cancel_booking_id");
13
    var cancelling = $.ajax({
13
        if (!bookingIdInput) {
14
        'method': "PATCH",
14
            return;
15
        'url': url,
15
        }
16
        'data': JSON.stringify({"status": "cancelled"}),
16
17
        'contentType': "application/json"
17
        const bookingId = bookingIdInput.value;
18
    });
18
        if (!bookingId) {
19
19
            return;
20
20
        }
21
    cancelling.done(function(data) {
21
22
        cancel_success = 1;
22
        let [error, response] = await catchError(
23
        if (bookings_table) {
23
            fetch(`/api/v1/bookings/${bookingId}`, {
24
            bookings_table.api().ajax.reload();
24
                method: "PATCH",
25
                body: JSON.stringify({ status: "cancelled" }),
26
                headers: {
27
                    "Content-Type": "application/json",
28
                },
29
            })
30
        );
31
        if (error || !response.ok) {
32
            const alertContainer = document.getElementById(
33
                "cancel_booking_result"
34
            );
35
            alertContainer.outerHTML = `
36
                <div id="booking_result" class="alert alert-danger">
37
                    ${__("Failure")}
38
                </div> 
39
            `;
40
41
            return;
25
        }
42
        }
26
        if (typeof timeline !== 'undefined') {
43
27
            timeline.itemsData.remove(Number(booking_id));
44
        cancel_success = true;
45
        bookings_table?.api().ajax.reload();
46
        timeline?.itemsData.remove(Number(booking_id));
47
48
        $("#cancelBookingModal").modal("hide");
49
50
        const bookingsCount = document.querySelector(".bookings_count");
51
        if (!bookingsCount) {
52
            return;
28
        }
53
        }
29
        $('.bookings_count').html(parseInt($('.bookings_count').html(), 10)-1);
54
30
        $('#cancelBookingModal').modal('hide');
55
        bookingsCount.innerHTML = parseInt(bookingsCount.innerHTML, 10) - 1;
31
    });
56
    }
32
57
33
    cancelling.fail(function(data) {
58
    function handleShowBsModal(e) {
34
        $('#cancel_booking_result').replaceWith('<div id="booking_result" class="alert alert-danger">'+__("Failure")+'</div>');
59
        const button = e.relatedTarget;
35
    });
60
        if (!button) {
36
});
61
            return;
62
        }
63
64
        const booking = button.dataset.booking;
65
        if (!booking) {
66
            return;
67
        }
68
69
        const bookingIdInput = document.getElementById("cancel_booking_id");
70
        if (!bookingIdInput) {
71
            return;
72
        }
73
74
        bookingIdInput.value = booking;
75
    }
76
77
    function catchError(promise) {
78
        return promise.then(data => [undefined, data]).catch(error => [error]);
79
    }
80
})();
(-)a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js (-1 / +5 lines)
Lines 301-307 $("#placeBookingModal").on("show.bs.modal", function (e) { Link Here
301
301
302
        // Fetch list of existing bookings
302
        // Fetch list of existing bookings
303
        let bookingsFetch = $.ajax({
303
        let bookingsFetch = $.ajax({
304
            url: "/api/v1/bookings?biblio_id=" + biblionumber + "&_per_page=-1",
304
            url:
305
                "/api/v1/bookings?biblio_id=" +
306
                biblionumber +
307
                "&_per_page=-1" +
308
                '&q={"status":{"-in":["new","pending","active"]}}',
305
            dataType: "json",
309
            dataType: "json",
306
            type: "GET",
310
            type: "GET",
307
        });
311
        });
(-)a/t/db_dependent/Koha/Booking.t (-4 / +3 lines)
Lines 481-496 subtest 'set_status() tests' => sub { Link Here
481
    )->store;
481
    )->store;
482
482
483
    my $booking_with_old_status = Koha::Bookings->find( $booking->booking_id );
483
    my $booking_with_old_status = Koha::Bookings->find( $booking->booking_id );
484
    $booking_with_old_status->set_status('completed');
484
    $booking_with_old_status->_set_status('completed');
485
    is( $booking_with_old_status->unblessed->{status}, 'completed', 'Booking status is now "completed"' );
485
    is( $booking_with_old_status->unblessed->{status}, 'completed', 'Booking status is now "completed"' );
486
486
487
    $booking_with_old_status->set_status('cancelled');
487
    $booking_with_old_status->_set_status('cancelled');
488
    is( $booking_with_old_status->unblessed->{status}, 'cancelled', 'Booking status is now "cancelled"' );
488
    is( $booking_with_old_status->unblessed->{status}, 'cancelled', 'Booking status is now "cancelled"' );
489
489
490
    subtest 'unauthorized status' => sub {
490
    subtest 'unauthorized status' => sub {
491
        plan tests => 2;
491
        plan tests => 2;
492
492
493
        eval { $booking_with_old_status->set_status('blah'); };
493
        eval { $booking_with_old_status->_set_status('blah'); };
494
494
495
        if ($@) {
495
        if ($@) {
496
            like(
496
            like(
497
- 

Return to bug 38175