Bug 36135: Add ability to batch modify holds

TO TEST:

0. APPLY PATCHES, updatedatabase, restart_all
1. Create a user with limited permissions, making sure you give them the "batch_modify_holds" permission
2. Login as chosen patron.
3. Navigate to Tools page.
4. Note there's a link "Batch modify holds".
5. Remove permission from user.
6. Link to new tool is not displayed.

7. Navigate to new tool Tools->Batch modify holds.
8. Fill in some criteria to search holds with.
9. Confirm that holds found match your search criteria.
10. Set e.g. new expiration date for all or just selected holds.
11. Confirm that holds were modified as they should have.
12. Confirm this also from database.

13. Find holds with a found/waiting status.
14. Attempt to modify their suspend status.
15. Alert box with text "One or more holds have found status and can't be suspended." should be displayed.
16. Play around, test different modifications.

17. Create SQL report which contains column for reserve_id.
18. e.g "SELECT reserve_id FROM reserves limit 10".
19.. Run report.
20. Over report select "Batch operations" should be displayed and under that there should be option "Batch modify holds".
21. Click "Batch modify holds" option.
22. Confirm that holds displayed in "Batch mofidy holds" tool are same as found with your report.

23. Do a holds search that will bring up multiple pages of results
24. Try selecting some holds from different pages to modify
25. Ensure all selected holds are being modified
26. Play around, test different modifications.

Sponsored-by: Koha-Suomi Oy
Signed-off-by: Jason Robb <jrobb@sekls.org>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Lucas Gass 2025-06-17 13:57:49 +00:00
parent 54638fba03
commit f769e3c1e6
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
9 changed files with 930 additions and 6 deletions

View File

@ -2046,7 +2046,52 @@ modules:
columnname: suspend
-
columnname: print_slip
holds_to_modify:
default_display_length: 20
columns:
-
columnname: checkbox
cannot_be_toggled: 1
cannot_be_modified: 1
-
columnname: expiration_date
-
columnname: title
-
columnname: barcode
-
columnname: patron
-
columnname: status
-
columnname: pickup_library
-
columnname: suspended
-
columnname: suspended_until
-
columnname: notes
modified_holds:
default_display_length: 20
columns:
-
columnname: expiration_date
-
columnname: title
-
columnname: barcode
-
columnname: patron
-
columnname: status
-
columnname: pickup_library
-
columnname: suspended
-
columnname: suspended_until
-
columnname: notes
holdsratios:
holds-ratios:
default_display_length: 20

View File

@ -139,6 +139,11 @@ properties:
- object
- "null"
description: Pickup library
patron:
type:
- object
- "null"
description: Patron for hold
_strings:
type:
- object

View File

@ -106,6 +106,8 @@
- biblio
- deleted_biblio
- pickup_library
- item
- patron
collectionFormat: csv
produces:
- application/json

View File

@ -0,0 +1,17 @@
<!-- Batch modify hold error modal -->
<div class="modal fade" id="errorModal" tabindex="-1" role="dialog" aria-labelledby="errorModalLabel">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="errorModalLabel">Errors</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul id="errorList"></ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>

View File

@ -39,6 +39,9 @@
[% IF ( CAN_user_tools_batch_extend_due_dates ) %]
<li><a href="/cgi-bin/koha/tools/batch_extend_due_dates.pl">Batch extend due dates</a></li>
[% END %]
[% IF ( CAN_user_tools_batch_modify_holds ) %]
<li><a href="/cgi-bin/koha/tools/batch_modify_holds.pl">Batch modify holds</a></li>
[% END %]
[% IF ( CAN_user_tools_moderate_tags ) %]
<li><a href="/cgi-bin/koha/tags/review.pl">Tags</a></li>
[% END %]

View File

@ -1071,6 +1071,16 @@
>
</form>
<form method="post" action="/cgi-bin/koha/tools/batch_modify_holds.pl" id="batch_modify_holds" target="_blank">
[% INCLUDE 'csrf-token.inc' %]
<input type="hidden" name="op" value="cud-form" />
<textarea style="display:none" name="reserve_ids_list" id="reserve_ids_list">
[%# Preserve the whitespace of the following textarea in order to format the values correctly %]
[%- reserve_ids = PROCESS batch_list results=results batch_type='reserve_id' | trim | html %][% IF reserve_ids %][% SET batch_reserve_ids = 1 %][% reserve_ids | html %][% END -%]
</textarea
>
</form>
[% BLOCK batch_list %]
[%- FOREACH result IN results %]
[%- FOREACH cells IN result.cells %]
@ -1095,9 +1105,9 @@
</form>
<!-- /#limitselect -->
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_borrowernumbers ) || ( unlimited_total > 10 && limit <= 1000 ) %]
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_borrowernumbers || batch_reserve_ids ) || ( unlimited_total > 10 && limit <= 1000 ) %]
<div id="toolbar" class="btn-toolbar">
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_borrowernumbers ) %]
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_borrowernumbers || batch_reserve_ids ) %]
<div class="btn-group">
<button class="btn btn-default btn-sm dropdown-toggle" type="button" id="batch_mod_menu" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
Batch operations with [% IF unlimited_total >= limit %][% results.size | html %][% ELSE %][% unlimited_total | html %][% END %] visible records
@ -1205,12 +1215,26 @@
>
</li>
[% END %]
[% IF header_ro.cell == 'reserve_id' || header_types.item( header_ro.cell ) == 'reserve_id' %]
<li class="dropdown-header">Hold records</li>
<li>
<a
href="#"
data-submit="batch_modify_holds"
data-toggle="tooltip"
data-placement="right"
title="Send visible results to batch hold modification"
class="batch_op send_to_hold_mod dropdown-item"
>Batch hold modification</a
>
</li>
[% END %]
[% END # /FOREACH header_ro %]
</ul>
<!-- /.dropdown-menu -->
</div>
<!-- /.dropdown -->
[% END # /IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers ) %]
[% END # /IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_reserve_ids ) %]
[% IF ( unlimited_total > 10 && limit <= 1000 ) %]
<div class="btn-group">
@ -1238,7 +1262,7 @@
</div>
[% END # /IF ( unlimited_total > 10 && limit <= 1000 ) %]
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers ) %]
[% IF ( batch_biblionumbers || batch_itemnumbers || batch_cardnumbers || batch_reserve_ids ) %]
<a href="#" class="btn btn-link" id="toggle_auto_links">
<i class="fa-solid fa-eye autolink" aria-hidden="true" style="display:none"></i>
<i class="fa-solid fa-eye-slash autolink" aria-hidden="true"></i>

View File

@ -0,0 +1,716 @@
[% USE raw %]
[% USE Asset %]
[% SET footerjs = 1 %]
[% USE Branches %]
[% USE Categories %]
[% USE KohaDates %]
[% USE ItemTypes %]
[% USE TablesSettings %]
[% USE To %]
[% PROCESS 'html_helpers.inc' %]
[% INCLUDE 'doc-head-open.inc' %]
<title>Batch modify holds &rsaquo; Tools &rsaquo; Koha</title>
[% INCLUDE 'doc-head-close.inc' %]
[% Asset.css("css/humanmsg.css") | $raw %]
</head>
<body id="tools_batch_extend_due_dates" class="tools">
[% WRAPPER 'header.inc' %]
[% INCLUDE 'cat-search.inc' %]
[% INCLUDE modals/batch_modify_hold_errors.inc %]
[% END %]
[% WRAPPER 'sub-header.inc' %]
[% WRAPPER breadcrumbs %]
[% WRAPPER breadcrumb_item %]
<a href="/cgi-bin/koha/tools/tools-home.pl">Tools</a>
[% END %]
[% IF ( view == 'form' || view == 'report' ) %]
[% WRAPPER breadcrumb_item %]
<a href="/cgi-bin/koha/tools/batch_modify_holds.pl">Batch modify holds</a>
[% END %]
[% ELSE %]
[% WRAPPER breadcrumb_item bc_active= 1 %]
<span>Batch modify holds</span>
[% END %]
[% END %]
[% END #/ WRAPPER breadcrumbs %]
[% END #/ WRAPPER sub-header.inc %]
<div class="main container-fluid">
<div class="row">
<div class="col-sm-10 col-sm-push-2 order-sm-1">
<main>
<h1>Batch modify holds</h1>
[% IF view == "cud-form" %]
<div id="modify_holds_search">
<form method="get" action="/cgi-bin/koha/tools/batch_modify_holds.pl" id="modify_holds_form">
<fieldset class="rows">
<legend>Hold search criteria:</legend>
<ol>
<li>
<label for="expirationdate_from">Expiration date from:</label>
<input type="text" size="10" id="expirationdate_from" name="expirationdate_from" class="flatpickr" />
</li>
<li>
<label for="expirationdate_to">Expiration date to:</label>
<input type="text" size="10" id="expirationdate_to" name="expirationdate_to" class="flatpickr" />
</li>
<li>
<label for="branchcodes">Libraries:</label>
<select name="branchcodes" id="branchcodes" multiple="multiple">
[% PROCESS options_for_libraries libraries => Branches.all(selected => "") %]
</select>
</li>
<li>
<label for="found_status">Found status:</label>
<select name="found_status" id="found_status" multiple="multiple">
<option value="NULL">No status</option>
<option value="T">In transit</option>
<option value="P">In processing</option>
<option value="W">Waiting</option>
</select>
</li>
<li>
<label for="suspend_status">Suspended:</label>
<select name="suspend_status" id="suspend_status">
<option value="none" selected="selected"></option>
<option value="0">Not suspended</option>
<option value="1">Suspended</option>
</select>
</li>
<li>
<label for="suspend_until_from">Suspended until from:</label>
<input type="text" size="10" id="suspend_until_from" name="suspend_until_from" class="flatpickr" data-date_to="suspend_until_to" />
</li>
<li>
<label for="suspend_until_to">Suspended until to:</label>
<input type="text" size="10" id="suspend_until_to" name="suspend_until_to" class="flatpickr" />
</li>
<li>
<label for="holdnotes">Hold note:</label>
<input type="text" id="holdnotes" name="holdnotes" />
</li>
</ol>
</fieldset>
<fieldset class="action">
<input type="submit" class="btn btn-primary" value="Search" />
</fieldset>
</form>
<!-- /#modify_holds_form -->
</div>
<!-- /#modify_holds_search -->
[% ELSIF view == "report" %]
<div id="modified_holds_results-wrapper">
<div id="modified_holds_results" class="page-section">
<table id="modified_holds"></table>
</div>
<!-- /#modified_holds_results -->
<fieldset class="action">
<a href="/cgi-bin/koha/tools/batch_modify_holds.pl">Return to batch hold modification</a>
</fieldset>
</div>
<!-- /#modified_holds_results-wrapper -->
[% END %]
<div id="modify_holds_results-wrapper" style="display:none;">
<div id="toolbar" class="btn-toolbar">
<a href="#" id="edit_search" class="btn btn-default"><i class="fa-solid fa-pencil" aria-hidden="true"></i> Edit search</a>
</div>
<!-- /#toolbar -->
<form action="/cgi-bin/koha/tools/batch_modify_holds.pl" method="post" id="process_mods">
[% INCLUDE 'csrf-token.inc' %]
<div id="modify_holds_results" class="page-section">
<div id="searchheader" class="searchheader">
<a id="select_all" href="#" class="btn btn-link"><i class="fa fa-check"></i> Select all visible rows</a>
| <a id="clear_all" href="#" class="btn btn-link"><i class="fa fa-times"></i> Clear selections</a>
<div id="table_search_selections" class="btn-group" style="display:none;">
<span></span>
<a href="#" id="clear-row-selection"><i class="fa fa-times"></i> Clear</a>
</div>
</div>
<!-- /#searchheader -->
<h3>Holds found for: <span class="searchpattern"></span></h3>
<table id="holds_to_modify"></table>
</div>
<!-- /#modify_holds_results -->
<div id="modify_holds_form_options" class="page-section">
<h2>Modify holds</h2>
<table id="modify_holds_options">
<thead>
<tr>
<th>New expiration date</th>
<th>New pickup library</th>
<th>Suspend holds</th>
<th>Suspend until</th>
<th>New hold note</th>
<th>Clear hold notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input type="text" id="new_expiration_date" name="new_expiration_date" class="flatpickr" data-flatpickr-futuredate="true" />
</td>
<td>
<select name="new_pickup_loc" id="new_pickup_loc">
<option value="" selected="selected"></option>
[% PROCESS options_for_libraries libraries => Branches.pickup_locations({ selected => "" }) %]
</select>
</td>
<td>
<select id="new_suspend_status" name="new_suspend_status">
<option value="" selected="selected"></option>
<option value="0">Not suspended</option>
<option value="1">Suspend</option>
</select>
</td>
<td>
<input type="text" id="new_suspend_date" name="new_suspend_date" class="flatpickr" data-flatpickr-futuredate="true" />
</td>
<td>
<input type="text" id="new_hold_note" name="new_hold_note" />
</td>
<td>
<input class="selection" type="checkbox" id="clear_hold_notes" name="clear_hold_notes" value="1" />
</td>
</tr>
</tbody>
</table>
<!-- /#modify_holds_options -->
</div>
<!-- /#modify_holds_options -->
<fieldset class="action">
<input type="hidden" name="op" value="cud-modify" />
<input type="submit" class="btn btn-primary" value="Modify holds" />
<a class="cancel" href="/cgi-bin/koha/tools/batch_modify_holds.pl">Cancel</a>
</fieldset>
</form>
<!-- /#process_mods -->
</div>
<!-- /#modify_holds_results-wrapper -->
</main>
</div>
<!-- /.col-sm-10 col-sm-push-2 -->
<div class="col-sm-2 col-sm-pull-10">
<aside> [% INCLUDE 'tools-menu.inc' %] </aside>
</div>
<!-- /.col-sm-2 col-sm-pull-10 -->
</div>
[% MACRO jsinclude BLOCK %]
[% Asset.js("js/tools-menu.js") | $raw %]
[% INCLUDE 'calendar.inc' %]
[% INCLUDE 'datatables.inc' %]
[% INCLUDE 'select2.inc' %]
[% INCLUDE 'js-patron-format.inc' %]
[% Asset.js("lib/jquery/plugins/humanmsg.js") | $raw %]
<script>
let hide_patron_name = [% IF Koha.Preference('HidePatronName') %]true[% ELSE %]false[% END %];
let hold_ids = [% IF updated_holds %] [% updated_holds | $raw %] [% ELSE %] [] [% END %];
let hold_ids_from_sql = [% IF reserve_ids_list %] [% reserve_ids_list | $raw %] [% ELSE %] [] [% END %];
$(document).ready(function() {
// Apply select2 to all select fields having a "multiple" attribute
let selectFields = document.querySelectorAll('select[multiple]');
selectFields.forEach((selectField) => {
selectField.style.minWidth = '200px';
$(selectField).select2();
});
//Empty local storage from previous selections
localStorage.removeItem("holds_modify_selections");
$("#holds_to_modify").on("change", "input[type='checkbox']", function(){
var holds_modify_selections = JSON.parse(localStorage.getItem("holds_modify_selections")) || [];
var hold_id = $(this).val();
if ($(this).prop("checked")) {
hold_data = {
hold_id: hold_id,
found_status: $(this).closest('tr').find('.found_status').children('span').data('found-status'),
suspended: $(this).closest('tr').find('.suspended').children('span').data('suspended'),
}
holds_modify_selections.push(hold_data);
localStorage.setItem("holds_modify_selections", JSON.stringify(holds_modify_selections));
showHoldSelections(holds_modify_selections.length);
} else {
var filtered = holds_modify_selections.filter(function( hold_obj ){
return hold_obj.hold_id !== hold_id;
});
if( filtered.length > 0 ){
localStorage.setItem("holds_modify_selections", JSON.stringify( filtered ));
holds_modify_selections = filtered;
showHoldSelections( filtered.length );
} else {
holds_modify_selections = [];
localStorage.removeItem("holds_modify_selections");
showHoldSelections( 0 );
}
}
});
$("#select_all").on("click", function(e){
e.preventDefault();
$("#holds_to_modify input[type='checkbox']").each(function(){
$(this).prop("checked", true).change();
});
});
$("#clear_all").on("click", function(e){
e.preventDefault();
$("#holds_to_modify input[type='checkbox']").each(function(){
$(this).prop("checked", false).change();
});
});
$("#clear-row-selection").on("click", function (e) {
e.preventDefault();
$("input[type='checkbox']").prop("checked", false).change();
localStorage.removeItem("holds_modify_selections");
$("#table_search_selections").hide();
});
$('#modify_holds_form').submit(function() {
var modify_holds_form = $(this);
search_holds(modify_holds_form);
$("#modify_holds_search").hide();
$('#edit_search').show();
return false;
});
if(hold_ids_from_sql.length > 0){
$('#modify_holds_form').trigger('submit');
}
$("#edit_search").on("click", function(e){
e.preventDefault();
$('#modify_holds_search').show();
$('#edit_search').hide();
});
function showHoldSelections( number ){
if (number === 0) {
$("#table_search_selections").hide();
} else {
$("#table_search_selections")
.show()
.find("span")
.text(__("Holds selected: %s").format(number));
}
}
function search_holds(modify_holds_form){
var table_settings = [% TablesSettings.GetTableSettings( 'circ', 'holds', 'holds_to_modify', 'json' ) | $raw %];
var searchpattern = "";
var filters = {
"me.expirationdate": function(){
var expirationdate_from = modify_holds_form.find("#expirationdate_from").val();
var expirationdate_to = modify_holds_form.find("#expirationdate_to").val();
if (expirationdate_from && expirationdate_to){
searchpattern += _("expiration date between ") + expirationdate_from + " and " + expirationdate_to + " ";
return {"-between": [expirationdate_from, expirationdate_to]};
}
else if (expirationdate_from && !expirationdate_to){
searchpattern += _("expiration date from ") + expirationdate_from + " ";
return {">=": expirationdate_from };
} else if (!expirationdate_from && expirationdate_to){
searchpattern += _("expiration date to ") + expirationdate_to + " ";
return {"<=": expirationdate_to };
}
},
"me.branchcode": function(){
var branchcodes = modify_holds_form.find("#branchcodes").val();
if( branchcodes.length > 0 ){
searchpattern += _("from libraries ");
var selected_libraries = modify_holds_form.find("#branchcodes option:selected");
$.each(selected_libraries, function(key, value) {
searchpattern += value.text + " ";
});
return {"-in": branchcodes };
}
},
"me.status": function(){
var found_status = modify_holds_form.find("#found_status").val();
if( found_status.length > 0 ){
var filters = [{"-in": found_status}];
if(jQuery.inArray("NULL", found_status) !== -1){
filters.push({"=": null});
}
searchpattern += _("found status in ");
$.each(found_status, function(key, value) {
let status = _("No status ");
if ( value == "T" ) {
status = _("In transit ");
} else if( value == "P" ) {
status = _("In processing ");
} else if( value == "W" ) {
status = _("Waiting ");
}
searchpattern += status;
});
return filters;
}
},
"me.suspended": function(){
var suspend_status = modify_holds_form.find("#suspend_status").val();
if(suspend_status != "none"){
var suspended_text = suspend_status == 0 ? _("Not suspended") : _("Suspended");
searchpattern += _("with suspend status ") + suspended_text;
return {"=": suspend_status};
}
},
"me.suspend_until": function(){
var suspend_until_from = modify_holds_form.find("#suspend_until_from").val();
var suspend_until_to = modify_holds_form.find("#suspend_until_to").val();
searchpattern += suspend_until_from && suspend_until_to ? _("suspend date between ")+ suspend_until_from +" and "+suspend_until_to : "";
return suspend_until_from && suspend_until_to ? {"-between": [suspend_until_from, suspend_until_to]} : "";
},
"me.notes": function(){
var holdnotes = modify_holds_form.find("#holdnotes").val();
searchpattern += holdnotes ? _("hold notes like ") + holdnotes : "";
return holdnotes ? {"-like": "%"+holdnotes+"%"} : "";
},
"me.hold_id": function(){
return hold_ids_from_sql.length > 0 ? { "-in": hold_ids_from_sql } : "";
}
}
var holds_modify_table = $("#holds_to_modify").kohaTable({
"ajax": {
"url": "/api/v1/holds"
},
"embed": [
"biblio",
"item",
"pickup_library",
"patron"
],
"destroy": true,
"autoWidth": false,
"processing": true,
"columns": [
{
"data": "hold_id",
"name": "checkbox",
"orderable": false,
"render": function(data, type, row, meta) {
return '<input class="selection" type="checkbox" value="'+ encodeURIComponent(data) +'"/>'
}
},
{
"data": "expiration_date",
"name": "expiration_date",
"type": "date",
"title": _("Expiration date"),
"orderable": true,
"render": function( data, type, row, meta ) {
return $date(data);
}
},
{
"data": "biblio.title",
"name": "title",
"title": _("Title"),
"orderable": true,
"render": function( data, type, row, meta ) {
return '<a href="/cgi-bin/koha/catalogue/detail.pl?' +
'biblionumber=' + encodeURIComponent( row.biblio_id ) + '">' + escape_str( data ) + '</a>';
}
},
{
"data": "item.external_id",
"name": "barcode",
"title": _("Barcode"),
"defaultContent": _(""),
"orderable": true,
"render": function( data, type, row, meta ) {
if( row.item ){
let item_id = encodeURIComponent( row.item_id );
let biblio_id = encodeURIComponent( row.biblio_id );
return '<a href="/cgi-bin/koha/catalogue/moredetail.pl?' +
'itemnumber='+ item_id +'&biblionumber='+ biblio_id +'&' +
'bi='+ biblio_id +'#item'+ item_id +'">' + escape_str( data ) + '</a>'
}
}
},
{
"data": "patron_id",
"name": "patron",
"title": _("Patron"),
"orderable": true,
"render": function( data, type, row, meta ) {
let patron_to_html = $patron_to_html(row.patron, { url: true, display_cardnumber: true, hide_patron_name });
return patron_to_html;
}
},
{
"data": "status",
"name": "status",
"title": _("Status"),
"className": "found_status",
"orderable": true,
"render": function( data, type, row, meta ) {
let status = _("No status");
if ( data == "T" ) {
status = _("In transit");
} else if( data == "P" ) {
status = _("In processing");
} else if( data == "W" ) {
status = _("Waiting");
}
return "<span data-found-status='" + escape_str( data ) + "'>" + status + "</span>";
}
},
{
"data": "pickup_library_id",
"name": "pickup_library",
"title": _("Hold pickup library"),
"orderable": true,
"render": function( data, type, row, meta ) {
return escape_str( row.pickup_library.name );
}
},
{
"data": "suspended",
"name": "suspended",
"className": "suspended",
"title": _("Suspended"),
"orderable": true,
"render": function( data, type, row, meta ) {
var suspended_string = data == 0 ? _("No") : _("Yes");
return "<span data-suspended='" + data + "'>" + suspended_string + "</span>";
}
},
{
"data": "suspended_until",
"name": "suspended_until",
"title": _("Suspended until"),
"orderable": true,
"render": function( data, type, row, meta ) {
return $date( data );
}
},
{
"data": "notes",
"name": "notes",
"title": _("Notes"),
"orderable": true,
"render": function( data, type, row, meta ) {
return escape_str( data );
}
}
],
}, table_settings, 0, filters);
$(".searchpattern").text(searchpattern);
$("#modify_holds_results-wrapper").show();
}
$("#process_mods").on('submit', function(e) {
var hold_ids = JSON.parse( localStorage.getItem("holds_modify_selections") ) || [];
$("#process_mods input[name='hold_id']").remove();
hold_ids.forEach(function(hold) {
$('<input>').attr({
type: 'hidden',
name: 'hold_id',
value: hold.hold_id
}).appendTo('#process_mods');
});
var errors = [];
var new_expiration_date = $("#new_expiration_date").val();
var new_pickup_loc = $("#new_pickup_loc").val();
var new_suspend_status = $("#new_suspend_status").val();
var new_suspend_date = $("#new_suspend_date").val();
var new_hold_note = $("#new_hold_note").val();
var clear_hold_notes = $("#clear_hold_notes").val();
if ( hold_ids.length == 0 ) {
e.preventDefault();
errors.push(_("Please select at least one hold to process."));
}
if( new_pickup_loc || new_suspend_status || new_suspend_date ){
var found = false;
hold_ids.forEach(function(hold){
if( hold.found_status ){
e.preventDefault();
found = true;
}
});
if( ( new_suspend_status || new_suspend_date ) && found ){
errors.push(_("One or more holds have found status and can't be suspended."));
}
if( new_pickup_loc && found ){
errors.push(_("One or more holds have found status and their pick up location can't be changed."));
}
}
var suspended = false;
hold_ids.forEach(function(hold){
if( hold.suspened ){
suspended = true;
}
});
if( ( !new_suspend_status || new_suspend_status == "0" || !suspended ) && new_suspend_date ){
new_suspend_status = 1;
}
if (errors.length > 0) {
e.preventDefault();
$("#errorList").html(errors.map(error => `<li>${error}</li>`).join(""));
$("#errorModal").modal('show');
return false;
}
return true;
});
//Modified holds table
var table_settings = [% TablesSettings.GetTableSettings( 'circ', 'holds', 'modified_holds', 'json' ) | $raw %];
var filters = {
"me.hold_id": function(){
return {"-in": hold_ids};
}
}
var holds_modified_table = $("#modified_holds").kohaTable({
"ajax": {
"url": "/api/v1/holds"
},
"embed": [
"biblio",
"item",
"pickup_library",
"patron"
],
"destroy": true,
"autoWidth": false,
"processing": true,
"columns": [
{
"data": "expiration_date",
"name": "expiration_date",
"type": "date",
"title": _("Expiration date"),
"orderable": true,
"render": function( data, type, row, meta ) {
return $date(data);
}
},
{
"data": "biblio.title",
"name": "title",
"title": _("Title"),
"orderable": true,
"render": function( data, type, row, meta ) {
return '<a href="/cgi-bin/koha/catalogue/detail.pl?' +
'biblionumber=' + encodeURIComponent( row.biblio_id ) + '">' + escape_str( data ) + '</a>';
}
},
{
"data": "item.external_id",
"name": "barcode",
"title": _("Barcode"),
"defaultContent": _(""),
"orderable": true,
"render": function( data, type, row, meta ) {
if( row.item ){
let item_id = encodeURIComponent( row.item_id );
let biblio_id = encodeURIComponent( row.biblio_id );
return '<a href="/cgi-bin/koha/catalogue/moredetail.pl?' +
'itemnumber='+ item_id +'&biblionumber='+ biblio_id +'&' +
'bi='+ biblio_id +'#item'+ item_id +'">' + escape_str( data ) + '</a>'
}
}
},
{
"data": "patron_id",
"name": "patron",
"title": _("Patron"),
"orderable": true,
"render": function( data, type, row, meta ) {
let patron_to_html = $patron_to_html(row.patron, { url: true, display_cardnumber: true, hide_patron_name });
return patron_to_html;
}
},
{
"data": "status",
"name": "status",
"className": "found_status",
"title": _("Status"),
"orderable": true,
"render": function( data, type, row, meta ) {
let status = _("No status");
if ( data == "T" ) {
status = _("In transit");
} else if( data == "P" ) {
status = _("In processing");
} else if( data == "W" ) {
status = _("Waiting");
}
return status;
}
},
{
"data": "pickup_library_id",
"name": "pickup_library",
"title": _("Hold pickup library"),
"orderable": true,
"render": function( data, type, row, meta ) {
return escape_str( row.pickup_library.name );
}
},
{
"data": "suspended",
"name": "suspended",
"title": _("Suspended"),
"orderable": true,
"render": function( data, type, row, meta ) {
return data == 0 ? _("No") : _("Yes");
}
},
{
"data": "suspended_until",
"name": "suspended_until",
"title": _("Suspended until"),
"orderable": true,
"render": function( data, type, row, meta ) {
return $date( data );
}
},
{
"data": "notes",
"name": "notes",
"title": _("Notes"),
"orderable": true,
"render": function( data, type, row, meta ) {
return escape_str( data );
}
}
]
}, table_settings, 0, filters);
var modified_message = '<div class="alert alert-info">'+hold_ids.length+' hold(s) have been modified!</div>';
$("#modified_holds_results-wrapper").prepend(modified_message);
});
</script>
[% END %]
[% INCLUDE 'intranet-bottom.inc' %]
</div>

View File

@ -29,7 +29,7 @@
<h1>Tools</h1>
<div class="row">
<div class="col-sm-6">
[% IF ( CAN_user_tools_manage_patron_lists || CAN_user_clubs || CAN_user_tools_moderate_comments || CAN_user_tools_import_patrons || CAN_user_tools_edit_notices || CAN_user_tools_edit_notice_status_triggers || CAN_user_tools_label_creator || CAN_user_tools_delete_anonymize_patrons || CAN_user_tools_edit_patrons || CAN_user_tools_batch_extend_due_dates || CAN_user_tools_moderate_tags || CAN_user_tools_rotating_collections || ( CAN_user_tools_batch_upload_patron_images && Koha.Preference('patronimages') ) ) %]
[% IF ( CAN_user_tools_manage_patron_lists || CAN_user_clubs || CAN_user_tools_moderate_comments || CAN_user_tools_import_patrons || CAN_user_tools_edit_notices || CAN_user_tools_edit_notice_status_triggers || CAN_user_tools_label_creator || CAN_user_tools_delete_anonymize_patrons || CAN_user_tools_edit_patrons || CAN_user_tools_batch_extend_due_dates || CAN_user_tools_batch_modify_holds || CAN_user_tools_moderate_tags || CAN_user_tools_rotating_collections || ( CAN_user_tools_batch_upload_patron_images && Koha.Preference('patronimages') ) ) %]
<h3>Patrons and circulation</h3>
[% END %]
<dl>
@ -88,6 +88,11 @@
<dd>Modify the due date checkouts in batch</dd>
[% END %]
[% IF ( CAN_user_tools_batch_modify_holds ) %]
<dt><a href="/cgi-bin/koha/tools/batch_modify_holds.pl">Batch modify holds</a></dt>
<dd>Modify holds in batch</dd>
[% END %]
[% IF ( CAN_user_tools_moderate_tags ) %]
<dt>
<a href="/cgi-bin/koha/tags/review.pl">Tags</a>

107
tools/batch_modify_holds.pl Executable file
View File

@ -0,0 +1,107 @@
#!/usr/bin/perl
# This file is part of Koha.
#
# Copyright 2024 Koha Development Team
#
# Koha is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# Koha is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General
# Public License along with Koha; if not, see
# <http://www.gnu.org/licenses>
use Modern::Perl;
use CGI;
use JSON qw( to_json );
use C4::Auth qw( get_template_and_user );
use C4::Output qw( output_html_with_http_headers );
use Koha::DateUtils qw( dt_from_string );
use Koha::Holds;
my $input = CGI->new;
my $op = $input->param('op') // q|cud-form|;
my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
{
template_name => 'tools/batch_modify_holds.tt',
query => $input,
type => "intranet",
flagsrequired => { tools => 'batch_modify_holds' },
}
);
my @hold_ids;
if ( $op eq 'cud-form' ) {
my $reserve_ids_list = $input->param('reserve_ids_list') || undef;
if ($reserve_ids_list) {
my @reserve_ids = split /\n/, $reserve_ids_list;
$template->param( reserve_ids_list => to_json( \@reserve_ids ), );
}
$template->param( view => 'cud-form', );
} elsif ( $op eq 'cud-modify' ) {
my $new_expiration_date = $input->param('new_expiration_date');
my $new_pickup_loc = $input->param('new_pickup_loc');
my $new_suspend_status = $input->param('new_suspend_status');
my $new_suspend_date = $input->param('new_suspend_date');
my $new_hold_note = $input->param('new_hold_note');
my $clear_hold_notes = $input->param('clear_hold_notes');
@hold_ids = $input->multi_param('hold_id');
my $holds_to_update =
Koha::Holds->search( { reserve_id => { -in => \@hold_ids } }, { join => [ "item", "biblio" ] } );
while ( my $hold = $holds_to_update->next ) {
if ($new_expiration_date) {
$hold->expirationdate($new_expiration_date)->store;
}
if ( $new_pickup_loc && ( $hold->branchcode ne $new_pickup_loc ) ) {
$hold->branchcode($new_pickup_loc)->store;
}
if ( $new_suspend_status && $new_suspend_status ne "" && !$hold->is_found ) {
$hold->suspend(1)->store;
if ($new_suspend_date) {
$hold->suspend_until($new_suspend_date)->store;
} else {
$hold->suspend_until(undef)->store;
}
} elsif ( !$new_suspend_status && $new_suspend_date ) {
$hold->suspend(1)->store;
$hold->suspend_until($new_suspend_date)->store;
} else {
$hold->suspend(0)->store;
$hold->suspend_until(undef)->store;
}
if ($new_hold_note) {
$hold->reservenotes($new_hold_note)->store;
}
if ($clear_hold_notes) {
$hold->reservenotes(undef)->store;
}
}
$template->param(
updated_holds => to_json( \@hold_ids ),
view => 'report',
);
}
output_html_with_http_headers $input, $cookie, $template->output;