From 9744f2da037d27c81fa1f0d7550936833b79a23d Mon Sep 17 00:00:00 2001 From: Pedro Amorim Date: Tue, 15 Jul 2025 16:02:57 +0000 Subject: [PATCH] Bug 40517: Patron holds table: Add 'group selected holds' functionality This adds the UI elements required to group selected holds from the patron pages as well as 2 new concepts: Visual group ID: The visual group ID is designed to be easy to understand from a UI/UX perspective and not show the user an indefinitely incremented value. When holds are cancelled, filled or moved to a different group, if the old group is left empty or with a single hold, the hold group is deleted. --- Koha/Hold.pm | 33 +++- Koha/Patron.pm | 8 +- .../en/includes/modals/holds_table_modals.inc | 21 +++ .../prog/en/includes/patron-detail-tabs.inc | 9 + .../prog/en/modules/circ/circulation.tt | 1 + .../prog/en/modules/members/moremember.tt | 1 + koha-tmpl/intranet-tmpl/prog/js/holds.js | 155 +++++++++++++++--- 7 files changed, 199 insertions(+), 29 deletions(-) diff --git a/Koha/Hold.pm b/Koha/Hold.pm index 998e5beda21..e4ce7dae264 100644 --- a/Koha/Hold.pm +++ b/Koha/Hold.pm @@ -179,6 +179,27 @@ sub delete { return $deleted; } +=head3 cleanup_hold_group + +$self->cleanup_hold_group; + +Check if a hold group is left with a single or zero holds. Delete hold_group if so. +Accepts an optional $hold_group_id that, if defined, uses that instead of self->hold_group_id + +=cut + +sub cleanup_hold_group { + my ( $self, $hold_group_id ) = @_; + + my $hold_group_id_to_check = $hold_group_id // $self->hold_group_id; + my $hold_group = Koha::HoldGroups->find($hold_group_id_to_check); + return unless $hold_group; + + if ( $hold_group->holds->count <= 1 ) { + $hold_group->delete(); + } +} + =head3 set_transfer =cut @@ -673,9 +694,10 @@ sub cancellation_requested { my $cancel_hold = $hold->cancel( { - [ charge_cancel_fee => 1||0, ] - [ cancellation_reason => $cancellation_reason, ] - [ skip_holds_queue => 1||0 ] + [ charge_cancel_fee => 1||0, ] + [ cancellation_reason => $cancellation_reason, ] + [ skip_holds_queue => 1||0 ] + [ skip_hold_group_cleanup => 1||0 ] } ); @@ -799,6 +821,8 @@ sub cancel { } ); + $self->cleanup_hold_group() unless $params->{skip_hold_group_cleanup}; + if ($autofill_next) { my ( undef, $next_hold ) = C4::Reserves::CheckReserves( $self->item ); if ($next_hold) { @@ -886,9 +910,10 @@ sub fill { if ( $self->hold_group_id ) { my @holds = $self->hold_group->holds->as_list; foreach my $h (@holds) { - $h->cancel unless $h->reserve_id == $self->id; + $h->cancel( { skip_hold_group_cleanup => 1 } ) unless $h->reserve_id == $self->id; } } + $self->cleanup_hold_group(); Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( { biblio_ids => [ $old_me->biblionumber ] } ) diff --git a/Koha/Patron.pm b/Koha/Patron.pm index 23f38211db9..a57fb3fd8fe 100644 --- a/Koha/Patron.pm +++ b/Koha/Patron.pm @@ -1852,6 +1852,8 @@ Creates and returns a hold group given a list of hold ids If force_grouped is supplied, the hold group will be created even if the holds are already grouped +Checks if the hold is moving from another group, and if so, removes the old group if empty + =cut sub create_hold_group { @@ -1908,9 +1910,13 @@ sub create_hold_group { { visual_hold_group_id => $next_available_visual_hold_group_id } ); foreach my $hold_id (@$hold_ids) { - my $hold = Koha::Holds->find($hold_id); + my $hold = Koha::Holds->find($hold_id); + my $previous_hold_group_id = $hold->hold_group_id; $hold->hold_group_id( $hold_group_rs->hold_group_id )->store; + if ( $previous_hold_group_id && $previous_hold_group_id != $hold_group_rs->hold_group_id ) { + $hold->cleanup_hold_group($previous_hold_group_id); + } } return Koha::HoldGroup->_new_from_dbic($hold_group_rs); diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc index 7beb583dff1..e8a458bfb17 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/holds_table_modals.inc @@ -74,3 +74,24 @@ [% END %] + +[% BLOCK 'group-hold-modal' %] + +[% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/patron-detail-tabs.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/patron-detail-tabs.inc index b59888eca45..97f403594b9 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/patron-detail-tabs.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/patron-detail-tabs.inc @@ -151,6 +151,9 @@ Hold date + [% IF Koha.Preference('DisplayAddHoldGroups') %] + Hold group + [% END %] Title Call number Item type @@ -173,6 +176,12 @@ [% PROCESS 'cancel-hold-modal' form_action = '/cgi-bin/koha/reserve/modrequest.pl' from_param = patronpage %] [% PROCESS 'suspend-hold-modal' %] + [% PROCESS 'group-hold-modal' %] + [% IF Koha.Preference('DisplayAddHoldGroups') %] +
+ +
+ [% END # IF DisplayAddHoldGroups %] [% IF Koha.Preference('SuspendHoldsIntranet') %]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt index 439d2726d2c..f9081e0eda6 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/circulation.tt @@ -1105,6 +1105,7 @@ relatives_borrowernumbers.push("[% b | html %]"); [% END %] var SuspendHoldsIntranet = [% ( Koha.Preference('SuspendHoldsIntranet') ) ? 1 : 0 | html %]; + var DisplayAddHoldGroups = [% ( Koha.Preference('DisplayAddHoldGroups') ) ? 1 : 0 | html %]; [% Asset.js("js/pages/circulation.js") | $raw %] [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt index f4db8dd9896..10907907bdb 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/moremember.tt @@ -780,6 +780,7 @@ relatives_borrowernumbers.push("[% b | html %]"); [% END %] var SuspendHoldsIntranet = [% ( Koha.Preference('SuspendHoldsIntranet') ) ? 1 : 0 | html %]; + var DisplayAddHoldGroups = [% ( Koha.Preference('DisplayAddHoldGroups') ) ? 1 : 0 | html %]; [% Asset.js("js/pages/circulation.js") | $raw %] [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/holds.js b/koha-tmpl/intranet-tmpl/prog/js/holds.js index 49884d99e86..6f671d91916 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/holds.js +++ b/koha-tmpl/intranet-tmpl/prog/js/holds.js @@ -164,6 +164,11 @@ $(document).ready(function () { return ( '' + + oObj.visual_hold_group_id + + ""; + + title = + "" + link + ""; + } + + return title; + }, + }, + ] + : []), { data: function (oObj) { title = @@ -232,17 +259,6 @@ $(document).ready(function () { ""; } - if (oObj.hold_group_id) { - title += "
"; - var link = - '' + - __("part of a hold group") + - ""; - title += "(" + link + ")"; - } - return title; }, }, @@ -603,19 +619,7 @@ $(document).ready(function () { e.preventDefault(); let selected_holds; if (!$(this).data("hold-id")) { - selected_holds = - "[" + - $(".holds_table .select_hold:checked") - .toArray() - .map(el => - JSON.stringify({ - hold: $(el).data("id"), - borrowernumber: $(el).data("borrowernumber"), - biblionumber: $(el).data("biblionumber"), - }) - ) - .join(",") + - "]"; + selected_holds = get_selected_holds_data(); } else { selected_holds = "[" + JSON.stringify({ hold: $(this).data("hold-id") }) + "]"; @@ -785,6 +789,8 @@ $(document).ready(function () { }); } + var MSG_GROUP_SELECTED = _("Group selected (%s)"); + function updateSelectedHoldsButtonCounters() { $(".cancel_selected_holds").html( MSG_CANCEL_SELECTED.format( @@ -796,6 +802,11 @@ $(document).ready(function () { $(".holds_table .select_hold:checked").length ) ); + $(".group_selected_holds").html( + MSG_GROUP_SELECTED.format( + $(".holds_table .select_hold:checked").length + ) + ); } updateSelectedHoldsButtonCounters(); @@ -926,4 +937,100 @@ $(document).ready(function () { '' ); } + + $(".group_selected_holds").html( + MSG_GROUP_SELECTED.format($(".holds_table .select_hold:checked").length) + ); + + $(".group_selected_holds").click(function (e) { + if ($(".holds_table .select_hold:checked").length > 1) { + let selected_holds = get_selected_holds_data(); + const group_ids = JSON.parse(selected_holds) + .filter(hold => hold.hold_group_id) + .map(hold => hold.hold_group_id); + + if (group_ids.length > 0) { + $("#group-modal .modal-body").prepend( + '
' + + __( + "Already grouped holds will be moved to the new group" + ) + + "
" + ); + } + + $("#group-modal").modal("show"); + } + return false; + }); + + $("#group-modal-submit").click(function (e) { + e.preventDefault(); + let selected_holds = get_selected_holds_data(); + + const hold_ids = JSON.parse(selected_holds).map(hold => hold.hold); + + try { + group_holds(hold_ids) + .success(function () { + holdsTable.api().ajax.reload(); + }) + .fail(function (jqXHR) { + $("#group-modal .modal-body").prepend( + '
' + + jqXHR.responseJSON.error + + "
" + ); + $("#group-modal-submit").prop("disabled", true); + }) + .done(function () { + $("#group-modal").modal("hide"); + $(".select_hold_all").click(); + }); + } catch (error) { + if (error.status === 404) { + alert(__("Unable to group, hold not found.")); + } else { + alert( + __( + "Your request could not be processed. Check the logs for details." + ) + ); + } + } + return false; + }); + + function group_holds(hold_ids) { + return $.ajax({ + method: "POST", + url: "/api/v1/patrons/" + borrowernumber + "/hold_groups", + contentType: "application/json", + data: JSON.stringify({ hold_ids: hold_ids, force_grouped: true }), + }); + } + + $("#group-modal").on("hidden.bs.modal", function () { + $("#group-modal .modal-body .alert-warning").remove(); + $("#group-modal .modal-body .alert-danger").remove(); + $("#group-modal-submit").prop("disabled", false); + }); + + function get_selected_holds_data() { + return ( + "[" + + $(".holds_table .select_hold:checked") + .toArray() + .map(el => + JSON.stringify({ + hold: $(el).data("id"), + borrowernumber: $(el).data("borrowernumber"), + biblionumber: $(el).data("biblionumber"), + hold_group_id: $(el).data("hold-group-id"), + }) + ) + .join(",") + + "]" + ); + } }); -- 2.39.5