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.

Signed-off-by: Anneli <anneli.osterman@koha-suomi.fi>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Pedro Amorim 2025-07-15 16:02:57 +00:00 committed by Lucas Gass
parent 483578c74d
commit 9df5ffeffe
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
7 changed files with 199 additions and 29 deletions

View File

@ -250,6 +250,27 @@ sub move_hold {
return { success => 1, hold => $self };
}
=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
@ -744,9 +765,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 ]
}
);
@ -870,6 +892,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) {
@ -957,9 +981,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 ] } )

View File

@ -1912,6 +1912,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 {
@ -1968,9 +1970,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);

View File

@ -74,3 +74,24 @@
</div>
</div>
[% END %]
[% BLOCK 'group-hold-modal' %]
<div id="group-modal" class="modal" role="dialog" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="suspend-modal-label"> Group selected holds </h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to group the selected holds?</p>
</div>
<div class="modal-footer">
<button id="group-modal-submit" class="btn btn-primary" type="submit" name="submit">Group</button>
<button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel </button>
</div>
<div id="inputs"></div>
</div>
</div>
</div>
[% END %]

View File

@ -150,6 +150,9 @@
<tr>
<th id="checkbox" data-colname="checkbox"><input type="checkbox" class="select_hold_all" /></th>
<th>Hold date</th>
[% IF Koha.Preference('DisplayAddHoldGroups') %]
<th>Hold group</th>
[% END %]
<th class="anti-the">Title</th>
<th>Call number</th>
<th>Item type</th>
@ -171,6 +174,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') %]
<fieldset class="action">
<button class="btn btn-primary group_selected_holds" data-bulk="true"></button>
</fieldset>
[% END # IF DisplayAddHoldGroups %]
[% IF Koha.Preference('SuspendHoldsIntranet') %]
<fieldset class="action">

View File

@ -1103,6 +1103,7 @@
relatives_borrowernumbers.push("[% b | html %]");
[% END %]
var SuspendHoldsIntranet = [% ( Koha.Preference('SuspendHoldsIntranet') ) ? 1 : 0 | html %];
var DisplayAddHoldGroups = [% ( Koha.Preference('DisplayAddHoldGroups') ) ? 1 : 0 | html %];
</script>
[% Asset.js("js/pages/circulation.js") | $raw %]
[% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %]

View File

@ -785,6 +785,7 @@
relatives_borrowernumbers.push("[% b | html %]");
[% END %]
var SuspendHoldsIntranet = [% ( Koha.Preference('SuspendHoldsIntranet') ) ? 1 : 0 | html %];
var DisplayAddHoldGroups = [% ( Koha.Preference('DisplayAddHoldGroups') ) ? 1 : 0 | html %];
</script>
[% Asset.js("js/pages/circulation.js") | $raw %]
[% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %]

View File

@ -164,6 +164,11 @@ $(document).ready(function () {
return (
'<input type="checkbox" class="select_hold" data-id="' +
oObj.reserve_id +
(oObj.hold_group_id
? '" data-hold-group-id="' +
oObj.hold_group_id +
'"'
: '"') +
'" data-borrowernumber="' +
borrowernumber +
'" data-biblionumber="' +
@ -178,6 +183,28 @@ $(document).ready(function () {
sort: "reservedate",
},
},
...(DisplayAddHoldGroups
? [
{
data: function (oObj) {
title = "";
if (oObj.visual_hold_group_id) {
var link =
'<a class="hold-group" href="/cgi-bin/koha/reserve/hold-group.pl?hold_group_id=' +
oObj.hold_group_id +
'">' +
oObj.visual_hold_group_id +
"</a>";
title =
"<span>" + link + "</span>";
}
return title;
},
},
]
: []),
{
data: function (oObj) {
title =
@ -232,17 +259,6 @@ $(document).ready(function () {
"</span>";
}
if (oObj.hold_group_id) {
title += "<br>";
var link =
'<a class="hold-group" href="/cgi-bin/koha/reserve/hold-group.pl?hold_group_id=' +
oObj.hold_group_id +
'">' +
__("part of a hold group") +
"</a>";
title += "<span>(" + link + ")</span>";
}
return title;
},
},
@ -617,19 +633,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") }) + "]";
@ -800,6 +804,8 @@ $(document).ready(function () {
});
}
var MSG_GROUP_SELECTED = _("Group selected (%s)");
function updateSelectedHoldsButtonCounters() {
$(".cancel_selected_holds").html(
MSG_CANCEL_SELECTED.format(
@ -816,6 +822,11 @@ $(document).ready(function () {
$(".holds_table .select_hold:checked").length
)
);
$(".group_selected_holds").html(
MSG_GROUP_SELECTED.format(
$(".holds_table .select_hold:checked").length
)
);
}
function updateMoveButtons(table) {
@ -1148,4 +1159,100 @@ $(document).ready(function () {
'<input type="hidden" name="reserve_id" value="' + hold.hold + '">'
);
}
$(".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(
'<div class="alert alert-warning">' +
__(
"Already grouped holds will be moved to the new group"
) +
"</div>"
);
}
$("#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(
'<div class="alert alert-danger">' +
jqXHR.responseJSON.error +
"</div>"
);
$("#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(",") +
"]"
);
}
});