[open-ils-commits] [GIT] Evergreen ILS branch master updated. c5838c87c0f20f1ba01ce525d30c1637751d85e2

Evergreen Git git at git.evergreen-ils.org
Mon Aug 14 16:10:29 EDT 2017


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  c5838c87c0f20f1ba01ce525d30c1637751d85e2 (commit)
       via  a489dc120b5df242db1d13edaea629ba6b7d020d (commit)
       via  9c6a92f8dc9d5d2965b75e7ce74b7d6ba34bc783 (commit)
       via  2e0ea0eac16390a10d1ec727a5fbd43744b0fed7 (commit)
       via  fcd4229f895e98de2164012c1730f2479da40096 (commit)
      from  2015799e80e18d4259b7149986fec175e9238aad (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit c5838c87c0f20f1ba01ce525d30c1637751d85e2
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Mon Jul 31 16:57:06 2017 -0400

    LP#1701001: add release notes
    
    Here is the test plan for the feature:
    
    [1] In the web staff client, perform a catalog search. From search
        results, choose Place Hold.
    [2] In the form that displays, click the 'Patron Search' button.
    [3] Verify that a modal with a patron search form is displayed.
    [4] Search for a patron, then select one and click the 'Select'
        button.
    [5] Verify that the patron's barcode is inserted into the patron
        barcode input.
    [6] Verify that submitting the form successfully places the hold.
    [7] Verify that the feature works from a bib record details page.
    [8] Verify that the Patron Search button does not show up when
        using the place hold form in the public catalog.
    [9] Verify that normal patron search, retrieval, and registration
        functionality continues to work, as this patch series does some
        refactoring to create a reusable Angular patron search service
        and templates.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Circulation/Patron_Search_from_Place_Hold.adoc b/docs/RELEASE_NOTES_NEXT/Circulation/Patron_Search_from_Place_Hold.adoc
new file mode 100644
index 0000000..bf7b985
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Circulation/Patron_Search_from_Place_Hold.adoc
@@ -0,0 +1,10 @@
+Patron Search from Place Hold
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Patron Search from Place Hold allows staff members, when placing a
+hold on behalf of a patron in the web staff client, to search for
+patrons by names and other searchable patron information, rather than
+relying on barcode alone. In particular, after performing a catalog
+search or going to a specific bib record and clicking the 'Place Hold'
+button, the form now includes a 'Patron Search' button. This button
+will open a dialog allowing the staff member search for and select
+a patron record.

commit a489dc120b5df242db1d13edaea629ba6b7d020d
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Jul 28 16:54:55 2017 -0400

    LP#1701001: add "patron search" button to OPAC place hold form
    
    This button is displayed only when the public catalog is embedded
    in the web staff client and the iframe's controller explicitly
    unhides the button.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>

diff --git a/Open-ILS/src/templates/opac/parts/place_hold.tt2 b/Open-ILS/src/templates/opac/parts/place_hold.tt2
index c9f1d99..73192ba 100644
--- a/Open-ILS/src/templates/opac/parts/place_hold.tt2
+++ b/Open-ILS/src/templates/opac/parts/place_hold.tt2
@@ -84,7 +84,11 @@ function toggleActivationDate() {
             <span id="patron_name"></span>
             <span id="patron_usr_barcode_not_found" style="display: none">
               [% l('Patron barcode was not found') %]
-            </span><br />
+            </span>
+            [% IF ctx.is_browser_staff %]
+            <button id="hold_usr_search" type="button" class="opac-button" style="display: none;">[% l('Patron Search') %]</button>
+            [% END %]
+            <br />
             <input type="hidden" id="staff_barcode" 
               value="[% ctx.staff_recipient.card.barcode | html %]"/>
             <span>

commit 9c6a92f8dc9d5d2965b75e7ce74b7d6ba34bc783
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Jul 28 16:55:22 2017 -0400

    LP#1701001: add search-to-hold to catalog app
    
    This patch teaches the web staff client's catalog app how
    to activate the patron search button in the embedded catalog's
    place hold form. It also adds a handler for the button action
    to display a modal to allow the staff member to search
    for and select a patron, then injects the patron's barcode
    into the form.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>

diff --git a/Open-ILS/src/templates/staff/cat/catalog/index.tt2 b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
index c2df10e..b98c3f1 100644
--- a/Open-ILS/src/templates/staff/cat/catalog/index.tt2
+++ b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
@@ -8,6 +8,7 @@
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/marcrecord.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/tagtable.js"></script>
 [% INCLUDE 'staff/cat/share/marcedit_strings.tt2' %]
diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
index d73224a..8b541f5 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
@@ -7,7 +7,7 @@
  *
  */
 
-angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','ngLocationUpdate','egCoreMod','egGridMod', 'egMarcMod', 'egUserMod', 'egHoldingsMod', 'ngToast'])
+angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','ngLocationUpdate','egCoreMod','egGridMod', 'egMarcMod', 'egUserMod', 'egHoldingsMod', 'ngToast','egPatronSearchMod'])
 
 .config(['ngToastProvider', function(ngToastProvider) {
   ngToastProvider.configure({
@@ -426,6 +426,36 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         return record_id;
     }
 
+    patron_search_dialog = function() {
+        return $uibModal.open({
+            templateUrl: './share/t_patron_selector',
+            size: 'lg',
+            animation: true,
+            controller:
+                   ['$scope','$uibModalInstance','$controller',
+            function($scope , $uibModalInstance , $controller) {
+                angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope}));
+                $scope.clearForm();
+                $scope.need_one_selected = function() {
+                    var items = $scope.gridControls.selectedItems();
+                    return (items.length == 1) ? false : true
+                }
+                $scope.ok = function() {
+                    var items = $scope.gridControls.selectedItems();
+                    if (items.length == 1) {
+                        $uibModalInstance.close(items[0].card().barcode());
+                    } else {
+                        $uibModalInstance.close()
+                    }
+                }
+                $scope.cancel = function($event) {
+                    $uibModalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        });
+    }
+
     // also set it when the iframe changes to a new record
     $scope.handle_page = function(url) {
 
@@ -464,6 +494,18 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         } else {
             $scope.in_opac_call = false;
         }
+
+        if ($scope.opac_iframe && $location.path().match(/cat\/catalog/)) {
+            var doc = $scope.opac_iframe.dom.contentWindow.document;
+            $(doc).find('#hold_usr_search').show();
+            $(doc).find('#hold_usr_search').on('click', function() {
+                patron_search_dialog().result.then(function(barc) {
+                    $(doc).find('#hold_usr_input').val(barc);
+                    $(doc).find('#hold_usr_input').change();
+                });
+            })
+        }
+
     }
 
     // xulG catalog handlers

commit 2e0ea0eac16390a10d1ec727a5fbd43744b0fed7
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Jul 28 16:55:37 2017 -0400

    LP#1701001: split patron search form into separate templates
    
    This patch breaks up the patron search form and results into
    separate templates and adds a template for a patron selector
    modal.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>

diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
index 72e8336..c67291a 100644
--- a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
+++ b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
@@ -1,154 +1,4 @@
-
-<!-- TODO: inputs need sr-only labels
-   <label class="sr-only" for="input-id">label</label>
--->
-
-<div class="row" id="patron-search-form-row">
-  <div class="col-md-11">
-    <form ng-submit="search(searchArgs)" id="patron-search-form" 
-        role="form" class="form-horizontal">
-
-      <div class="form-group">
-
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            focus-me="focusMe"
-            ng-model="searchArgs.family_name" placeholder="[% l('Last Name') %]"/>
-        </div>
-
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.first_given_name" placeholder="[% l('First Name') %]"/>
-        </div>
-
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.second_given_name" placeholder="[% l('Middle Name') %]"/>
-        </div>
-
-        <div class="col-md-2" ng-mouseover="setLastFormElement()">
-          <input type="submit" class="btn btn-primary" value="[% l('Search') %]"/>
-        </div>
-
-        <div class="col-md-2" ng-mouseover="setLastFormElement()">
-          <input type="reset" class="btn btn-primary" ng-click="clearForm()" 
-            value="[% l('Clear Form') %]"/>
-        </div>
-
-        <div class="col-md-2">
-          <button class="btn btn-default" ng-click="applyShowExtras($event, true)" 
-            ng-mouseover="setLastFormElement()"
-            title="[% l('Show More Fields') %]" ng-show="!showExtras">
-            <span class="glyphicon glyphicon-circle-arrow-down"></span>
-          </button>
-          <button class="btn btn-default" ng-click="applyShowExtras($event, false)" 
-            ng-mouseover="setLastFormElement()"
-            title="[% l('Show Fewer Fields') %]" ng-show="showExtras">
-            <span class="glyphicon glyphicon-circle-arrow-up"></span>
-          </button>
-        </div>
-      </div>
-
-      <div class="form-group" ng-show="showExtras">
-        <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="searchArgs.card" 
-            placeholder="[% l('Barcode') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.alias" placeholder="[% l('Alias') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.usrname" placeholder="[% l('Username') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.email" placeholder="[% l('Email') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.ident" placeholder="[% l('Identification') %]"/>
-        </div>
-      </div>
-
-      <div class="form-group" ng-show="showExtras">
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.id" placeholder="[% l('Database ID') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.phone" placeholder="[% l('Phone') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.street1" placeholder="[% l('Street 1') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.street2" placeholder="[% l('Street 2') %]"/>
-        </div>
-        <div class="col-md-2">
-          <input type="text" class="form-control" 
-            ng-model="searchArgs.city" placeholder="[% l('City') %]"/>
-        </div>
-      </div>
-
-      <div class="form-group" ng-show="showExtras">
-        <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="searchArgs.state" 
-            placeholder="[% l('State') %]" title="[% l('State') %]"/>
-        </div>
-
-        <div class="col-md-2">
-          <input type="text" class="form-control" ng-model="searchArgs.post_code" 
-            placeholder="[% l('Post Code') %]" title="[% l('Post Code') %]"/>
-        </div>
-
-        <div class="col-md-2">
-          <!--
-          <input type="text" class="form-control"  
-            placeholder="[% l('Profile Group') %]"
-            ng-model="searchArgs.profile"
-            typeahead="grp as grp.name for grp in profiles | filter:$viewValue" 
-            typeahead-editable="false" />
-            -->
-
-            <div class="btn-group patron-search-selector" uib-dropdown>
-              <button type="button" class="btn btn-default" uib-dropdown-toggle>
-                <span style="padding-right: 5px;">{{searchArgs.profile.name() || "[% l('Profile Group') %]"}}</span>
-                <span class="caret"></span>
-              </button>
-              <ul uib-dropdown-menu>
-                <li ng-repeat="grp in profiles">
-                  <a href
-                    style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
-                    ng-click="searchArgs.profile = grp">{{grp.name()}}</a>
-                </li>
-              </ul>
-            </div>
-        </div>
-
-        <div class="col-md-2">
-          <eg-org-selector label="[% l('Home Library') %]" 
-            selected="searchArgs.home_ou" sticky-setting="eg.circ.patron.search.ou">
-          </eg-org-selector>
-        </div>
-
-        <div class="col-md-2">
-          <div class="checkbox">
-            <label>
-              <input type="checkbox" ng-model="searchArgs.inactive"/>
-              [% l('Include Inactive?') %]
-            </label>
-          </div>
-        </div>
-      </div>
-    </form>
-  </div>
-</div>
-
+[% INCLUDE 'staff/share/t_patron_search_form.tt2' %]
 
 <br/>
 <div class="row">
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2 b/Open-ILS/src/templates/staff/share/t_patron_search_form.tt2
similarity index 97%
copy from Open-ILS/src/templates/staff/circ/patron/t_search.tt2
copy to Open-ILS/src/templates/staff/share/t_patron_search_form.tt2
index 72e8336..49152d3 100644
--- a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
+++ b/Open-ILS/src/templates/staff/share/t_patron_search_form.tt2
@@ -148,13 +148,3 @@
     </form>
   </div>
 </div>
-
-
-<br/>
-<div class="row">
-  <div class="col-md-12">
-    [% INCLUDE 'staff/circ/patron/t_search_results.tt2' %]
-  </div>
-</div>
-
-
diff --git a/Open-ILS/src/templates/staff/share/t_patron_search_results.tt2 b/Open-ILS/src/templates/staff/share/t_patron_search_results.tt2
new file mode 100644
index 0000000..885aded
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_patron_search_results.tt2
@@ -0,0 +1,35 @@
+
+<!-- patron search results grid -->
+<eg-grid
+  idl-class="au" id-field="id"
+  features="-sort,-display,-multisort,-multiselect"
+  main-label="[% l('Patron Search Results') %]"
+  grid-controls="gridControls"
+  items-provider="patronSearchGridProvider"
+  persist-key="circ.patron.search">
+
+  <eg-grid-field label="[% ('ID') %]" path='id' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Card') %]" path='card.barcode' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Profile') %]" path='profile.name' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Last Name') %]" path='family_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('First Name') %]" path='first_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('Middle Name') %]" path='second_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('DoB') %]" path='dob' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('Home Library') %]" path='home_ou.shortname' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Created On') %]" path='create_date' visible sortable multisortable></eg-grid-field>
+
+  <eg-grid-field label="[% ('Mailing:Street 1') %]" path='mailing_address.street1' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:Street 2') %]" path='mailing_address.street2'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:City') %]" path='mailing_address.city'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:County') %]" path='mailing_address.county'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:State') %]" path='mailing_address.state'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:Zip') %]" path='mailing_address.post_code'></eg-grid-field>
+
+  <eg-grid-field label="[% ('Billing:Street 1') %]" path='billing_address.street1'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:Street 2') %]" path='billing_address.street2'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:City') %]" path='billing_address.city'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:County') %]" path='billing_address.county'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:State') %]" path='billing_address.state'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:Zip') %]" path='billing_address.post_code'></eg-grid-field>
+  <eg-grid-field path='*' ignore="id family_name first_given_name second_given_name dob create_date"></eg-grid-field>
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/share/t_patron_selector.tt2 b/Open-ILS/src/templates/staff/share/t_patron_selector.tt2
new file mode 100644
index 0000000..e67df41
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_patron_selector.tt2
@@ -0,0 +1,27 @@
+<!--
+  Patron selector dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">×</button>
+    <h4 class="modal-title alert alert-info">[% l('Select Patron') %]</h4> 
+  </div>
+  <div class="modal-body">
+    [% INCLUDE 'staff/share/t_patron_search_form.tt2' %]
+    <br/>
+    <div class="row">
+      <div class="col-md-12">
+        [% INCLUDE 'staff/share/t_patron_search_results.tt2' %]
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="{{ ok_button_label || '[% l("Select") %]'}}"
+      ng-disabled="need_one_selected()"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">{{ cancel_button_label || "[% l('Cancel') %]"}}</button>
+  </div>
+</div>

commit fcd4229f895e98de2164012c1730f2479da40096
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri May 26 20:41:38 2017 +0000

    LP#1701001: carve out a reusable patron search service
    
    This patch moves the patron search service and the base
    patron search controller into a separate, reusable
    file.  The core patron search service is available for
    injection as patronSvc from the new egPatronSearchMod, while
    BasePatronSearchCtrl now exists as a base controller for the
    patron search form that can be extended as needed by doing
    something like this:
    
    module.controller('DerivedPatronSearchCtrl', [
                 '$scope', '$controller',
        function ($scope, $controller) {
        // Initialize the super class and extend it.
        angular.extend(this, $controller('BasePatronSearchCtrl', {$scope: $scope}));
    ...
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>

diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2
index d2b94ed..d2908d3 100644
--- a/Open-ILS/src/templates/staff/circ/patron/index.tt2
+++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2
@@ -18,6 +18,7 @@
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
 [% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/regctl.js"></script>
 
diff --git a/Open-ILS/src/templates/staff/circ/renew/index.tt2 b/Open-ILS/src/templates/staff/circ/renew/index.tt2
index 415556b..55a91e8 100644
--- a/Open-ILS/src/templates/staff/circ/renew/index.tt2
+++ b/Open-ILS/src/templates/staff/circ/renew/index.tt2
@@ -10,6 +10,7 @@
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
 [% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/renew/app.js"></script>
 <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js
index 1452ce5..cd8a36e 100644
--- a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js
@@ -5,7 +5,8 @@
  */
 
 angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
-    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast'])
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod', 'ngToast',
+    'egPatronSearchMod'])
 
 .config(['ngToastProvider', function(ngToastProvider) {
     ngToastProvider.configure({
@@ -224,366 +225,6 @@ angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap',
 })
 
 /**
- * Patron service
- */
-.factory('patronSvc',
-       ['$q','$timeout','$location','egCore','egUser','$locale',
-function($q , $timeout , $location , egCore,  egUser , $locale) {
-
-    var service = {
-        // cached patron search results
-        patrons : [],
-
-        // currently selected patron object
-        current : null, 
-
-        // patron circ stats (overdues, fines, holds)
-        patron_stats : null,
-
-        // event types manually overridden, which should always be
-        // overridden for checkouts to this patron for this instance of
-        // the interface.
-        checkout_overrides : {},        
-        //holds the searched barcode
-        search_barcode : null,      
-    };
-
-    // when we change the default patron, we need to clear out any
-    // data collected on that patron
-    service.resetPatronLists = function() {
-        service.checkouts = [];
-        service.items_out = []
-        service.items_out_ids = [];
-        service.holds = [];
-        service.hold_ids = [];
-        service.checkout_overrides = {};
-        service.patron_stats = null;
-        service.noncat_ids = [];
-        service.hasAlerts = false;
-        service.patronExpired = false;
-        service.patronExpiresSoon = false;
-        service.invalidAddresses = false;
-    }
-    service.resetPatronLists();  // initialize
-
-    // Returns true if the last alerted patron matches the current
-    // patron.  Otherwise, the last alerted patron is set to the 
-    // current patron and false is returned.
-    service.alertsShown = function() {
-        var key = 'eg.circ.last_alerted_patron';
-        var last_id = egCore.hatch.getSessionItem(key);
-        if (last_id && last_id == service.current.id()) return true;
-        egCore.hatch.setSessionItem(key, service.current.id());
-        return false;
-    }
-
-    // shortcut to force-reload the current primary
-    service.refreshPrimary = function() {
-        if (!service.current) return $q.when();
-        return service.setPrimary(service.current.id(), null, true);
-    }
-
-    // clear the currently focused user
-    service.clearPrimary = function() {
-        // reset with no patron
-        service.resetPatronLists();
-        service.current = null;
-        service.patron_stats = null;
-        return $q.when();
-    }
-
-    // sets the primary display user, fetching data as necessary.
-    service.setPrimary = function(id, user, force) {
-        var user_id = id ? id : (user ? user.id() : null);
-
-        console.debug('setting primary user to: ' + user_id);
-
-        if (!user_id) return $q.reject();
-
-        // when loading a new patron, update the last patron setting
-        if (!service.current || service.current.id() != user_id)
-            egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
-
-        // avoid running multiple retrievals for the same patron, which
-        // can happen during dbl-click by maintaining a single running
-        // data retrieval promise
-        if (service.primaryUserPromise) {
-            if (service.primaryUserId == user_id) {
-                return service.primaryUserPromise.promise;
-            } else {
-                service.primaryUserPromise = null;
-            }
-        }
-
-        service.primaryUserPromise = $q.defer();
-        service.primaryUserId = user_id;
-
-        service.getPrimary(id, user, force)
-        .then(function() {
-            service.checkAlerts();
-            var p = service.primaryUserPromise;
-            service.primaryUserId = null;
-            // clear before resolution just to be safe.
-            service.primaryUserPromise = null;
-            p.resolve();
-        });
-
-        return service.primaryUserPromise.promise;
-    }
-
-    service.getPrimary = function(id, user, force) {
-
-        if (user) {
-            if (!force && service.current && 
-                service.current.id() == user.id()) {
-                if (service.patron_stats) {
-                    return $q.when();
-                } else {
-                    return service.fetchUserStats();
-                }
-            }
-
-            service.resetPatronLists();
-            service.current = user;
-            service.localFlesh(user);
-            return service.fetchUserStats();
-
-        } else if (id) {
-            if (!force && service.current && service.current.id() == id) {
-                if (service.patron_stats) {
-                    return $q.when();
-                } else {
-                    return service.fetchUserStats();
-                }
-            }
-
-            service.resetPatronLists();
-
-            return egUser.get(id).then(
-                function(user) {
-                    service.current = user;
-                    service.localFlesh(user);
-                    return service.fetchUserStats();
-                },
-                function(err) {
-                    console.error(
-                        "unable to fetch user "+id+': '+js2JSON(err))
-                }
-            );
-        } else {
-
-            // fetching a null user clears the primary user.
-            // NOTE: this should probably reject() and log an error, 
-            // but calling clear for backwards compat for now.
-            return service.clearPrimary();
-        }
-    }
-
-    // flesh some additional user fields locally
-    service.localFlesh = function(user) {
-        if (!angular.isObject(typeof user.home_ou()))
-            user.home_ou(egCore.org.get(user.home_ou()));
-
-        angular.forEach(
-            user.standing_penalties(),
-            function(penalty) {
-                if (!angular.isObject(penalty.org_unit()))
-                    penalty.org_unit(egCore.org.get(penalty.org_unit()));
-            }
-        );
-
-        // stat_cat_entries == stat_cat_entry_user_map
-        angular.forEach(user.stat_cat_entries(), function(map) {
-            if (angular.isObject(map.stat_cat())) return;
-            // At page load, we only retrieve org-visible stat cats.
-            // For the common case, ignore entries for remote stat cats.
-            var cat = egCore.env.actsc.map[map.stat_cat()];
-            if (cat) {
-                map.stat_cat(cat);
-                cat.owner(egCore.org.get(cat.owner()));
-            }
-        });
-    }
-
-    // resolves to true if the patron account has expired or will
-    // expire soon, based on YAOUS circ.patron_expires_soon_warning
-    // note: returning a promise is no longer strictly necessary
-    // (no more async activity) if the calling function is changed too.
-    service.testExpire = function() {
-
-        var expire = Date.parse(service.current.expire_date());
-        if (expire < new Date()) {
-            return $q.when(service.patronExpired = true);
-        }
-
-        var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
-        if (Number(soon)) {
-            var preExpire = new Date();
-            preExpire.setDate(preExpire.getDate() + Number(soon));
-            if (expire < preExpire) 
-                return $q.when(service.patronExpiresSoon = true);
-        }
-
-        return $q.when(false);
-    }
-
-    // resolves to true if the patron account has any invalid addresses.
-    service.testInvalidAddrs = function() {
-
-        if (service.invalidAddresses)
-            return $q.when(true);
-
-        var fail = false;
-
-        angular.forEach(
-            service.current.addresses(), 
-            function(addr) { if (addr.valid() == 'f') fail = true }
-        );
-
-        return $q.when(fail);
-    }
-    //resolves to true if the patron was fetched with an inactive card
-    service.fetchedWithInactiveCard = function() {
-        var bc = service.search_barcode
-        var cards = service.current.cards();
-        var card = cards.filter(function(c) { return c.barcode() == bc })[0];
-        return (card && card.active() == 'f');
-    }   
-    // resolves to true if there is any aspect of the patron account
-    // which should produce a message in the alerts panel
-    service.checkAlerts = function() {
-
-        if (service.hasAlerts) // already checked
-            return $q.when(true); 
-
-        var deferred = $q.defer();
-        var p = service.current;
-
-        if (service.alert_penalties.length ||
-            p.alert_message() ||
-            p.active() == 'f' ||
-            p.barred() == 't' ||
-            service.patron_stats.holds.ready) {
-
-            service.hasAlerts = true;
-        }
-
-        // see if the user was retrieved with an inactive card
-        if(service.fetchedWithInactiveCard()){
-            service.hasAlerts = true;
-        }
-
-        // regardless of whether we know of alerts, we still need 
-        // to test/fetch the expire data for display
-        service.testExpire().then(function(bool) {
-            if (bool) service.hasAlerts = true;
-            deferred.resolve(service.hasAlerts);
-        });
-
-        service.testInvalidAddrs().then(function(bool) {
-            if (bool) service.invalidAddresses = true;
-            deferred.resolve(service.invalidAddresses);
-        });
-
-        return deferred.promise;
-    }
-
-    service.fetchGroupFines = function() {
-        return egCore.net.request(
-            'open-ils.actor',
-            'open-ils.actor.usergroup.members.balance_owed',
-            egCore.auth.token(), service.current.usrgroup()
-        ).then(function(list) {
-            var total = 0;
-            angular.forEach(list, function(u) { 
-                total += 100 * Number(u.balance_owed)
-            });
-            service.patron_stats.fines.group_balance_owed = total / 100;
-        });
-    }
-
-    service.getUserStats = function(id) {
-        return egCore.net.request(
-            'open-ils.actor',
-            'open-ils.actor.user.opac.vital_stats.authoritative', 
-            egCore.auth.token(), id
-        ).then(
-            function(stats) {
-                // force numeric to ensure correct boolean handling in templates
-                stats.fines.balance_owed = Number(stats.fines.balance_owed);
-                stats.checkouts.overdue = Number(stats.checkouts.overdue);
-                stats.checkouts.claims_returned = 
-                    Number(stats.checkouts.claims_returned);
-                stats.checkouts.lost = Number(stats.checkouts.lost);
-                stats.checkouts.out = Number(stats.checkouts.out);
-                stats.checkouts.total_out = 
-                    stats.checkouts.out + stats.checkouts.overdue;
-                
-                stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
-
-                if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
-                    stats.checkouts.total_out += stats.checkouts.claims_returned;
-
-                if (egCore.env.aous['circ.tally_lost'])
-                    stats.checkouts.total_out += stats.checkouts.lost
-
-                return stats;
-            }
-        );
-    }
-
-    // Fetches the IDs of any active non-cat checkouts for the current
-    // user.  Also sets the patron_stats non_cat count value to match.
-    service.getUserNonCats = function(id) {
-        return egCore.net.request(
-            'open-ils.circ',
-            'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
-            egCore.auth.token(), id
-        ).then(function(noncat_ids) {
-            service.noncat_ids = noncat_ids;
-            service.patron_stats.checkouts.noncat = noncat_ids.length;
-        });
-    }
-
-    // grab additional circ info
-    service.fetchUserStats = function() {
-        return service.getUserStats(service.current.id())
-        .then(function(stats) {
-            service.patron_stats = stats
-            service.alert_penalties = service.current.standing_penalties()
-                .filter(function(pen) { 
-                return pen.standing_penalty().staff_alert() == 't' 
-            });
-
-            service.summary_stat_cats = [];
-            angular.forEach(service.current.stat_cat_entries(), 
-                function(map) {
-                    if (angular.isObject(map.stat_cat()) &&
-                        map.stat_cat().usr_summary() == 't') {
-                        service.summary_stat_cats.push(map);
-                    }
-                }
-            );
-
-            // run these two in parallel
-            var p1 = service.getUserNonCats(service.current.id());
-            var p2 = service.fetchGroupFines();
-            return $q.all([p1, p2]);
-        });
-    }
-
-    // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
-    // which is the Angular default.
-    // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
-    // FIXME: This change needs to be moved into a project-wide collection
-    // of locale overrides.
-    $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
-    $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
-
-    return service;
-}])
-
-/**
  * Manages tabbed patron view.
  * This is the parent scope of all patron tab scopes.
  *
@@ -888,20 +529,13 @@ function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
 .controller('PatronSearchCtrl',
        ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
        '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
-       'egPatronMerge','egProgressDialog',
+       'egPatronMerge','egProgressDialog','$controller',
 function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
         $filter,  egUser,  patronSvc , egGridDataProvider , $document,
-        egPatronMerge , egProgressDialog) {
+        egPatronMerge , egProgressDialog,  $controller) {
 
+    angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope}));
     $scope.initTab('search');
-    $scope.focusMe = true;
-    $scope.searchArgs = {
-        // default to searching globally
-        home_ou : egCore.org.tree()
-    };
-
-    // last used patron search form element
-    var lastFormElement;
 
     $scope.gridControls = {
         activateItem : function(item) {
@@ -910,71 +544,6 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
         selectedItems : function() {return []}
     }
 
-    // Handle URL-encoded searches
-    if ($location.search().search) {
-        console.log('URL search = ' + $location.search().search);
-        patronSvc.urlSearch = {search : JSON2js($location.search().search)};
-
-        // why the double-JSON encoded sort?
-        if (patronSvc.urlSearch.search.search_sort) {
-            patronSvc.urlSearch.sort = 
-                JSON2js(patronSvc.urlSearch.search.search_sort);
-        } else {
-            patronSvc.urlSearch.sort = [];
-        }
-        delete patronSvc.urlSearch.search.search_sort;
-
-        // include inactive patrons if "inactive" param
-        if ($location.search().inactive) {
-            patronSvc.urlSearch.inactive = $location.search().inactive;
-        }
-    }
-
-    var propagate;
-    var propagate_inactive;
-    if (patronSvc.lastSearch) {
-        propagate = patronSvc.lastSearch.search;
-        // home_ou needs to be treated specially
-        propagate.home_ou = {
-            value : patronSvc.lastSearch.home_ou,
-            group : 0
-        };
-    } else if (patronSvc.urlSearch) {
-        propagate = patronSvc.urlSearch.search;
-        if (patronSvc.urlSearch.inactive) {
-            propagate_inactive = patronSvc.urlSearch.inactive;
-        }
-    }
-
-    if (egCore.env.pgt) {
-        $scope.profiles = egCore.env.pgt.list;
-    } else {
-        egCore.pcrud.search('pgt', {parent : null}, 
-            {flesh : -1, flesh_fields : {pgt : ['children']}}
-        ).then(
-            function(tree) {
-                egCore.env.absorbTree(tree, 'pgt')
-                $scope.profiles = egCore.env.pgt.list;
-            }
-        );
-    }
-
-    if (propagate) {
-        // populate the search form with our cached / preexisting search info
-        angular.forEach(propagate, function(val, key) {
-            if (key == 'profile')
-                val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
-            if (key == 'home_ou')
-                val.value = egCore.org.get(val.value);
-            $scope.searchArgs[key] = val.value;
-        });
-        if (propagate_inactive) {
-            $scope.searchArgs.inactive = propagate_inactive;
-        }
-    }
-
-    var provider = egGridDataProvider.instance({});
-
     $scope.$watch(
         function() {return $scope.gridControls.selectedItems()},
         function(list) {
@@ -983,228 +552,6 @@ function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
         },
         true
     );
-        
-    provider.get = function(offset, count) {
-        var deferred = $q.defer();
-
-        var fullSearch;
-        if (patronSvc.urlSearch) {
-            fullSearch = patronSvc.urlSearch;
-            // enusre the urlSearch only runs once.
-            delete patronSvc.urlSearch;
-
-        } else {
-            patronSvc.search_barcode = $scope.searchArgs.card;
-            
-            var search = compileSearch($scope.searchArgs);
-            if (Object.keys(search) == 0) return $q.when();
-
-            var home_ou = search.home_ou;
-            delete search.home_ou;
-            var inactive = search.inactive;
-            delete search.inactive;
-
-            fullSearch = {
-                search : search,
-                sort : compileSort(),
-                inactive : inactive,
-                home_ou : home_ou,
-            };
-        }
-
-        fullSearch.count = count;
-        fullSearch.offset = offset;
-
-        if (patronSvc.lastSearch) {
-            // search repeated, return the cached results
-            if (angular.equals(fullSearch, patronSvc.lastSearch)) {
-                console.log('patron search returning ' + 
-                    patronSvc.patrons.length + ' cached results');
-                
-                // notify has to happen after returning the promise
-                $timeout(
-                    function() {
-                        angular.forEach(patronSvc.patrons, function(user) {
-                            deferred.notify(user);
-                        });
-                        deferred.resolve();
-                    }
-                );
-                return deferred.promise;
-            }
-        }
-
-        patronSvc.lastSearch = fullSearch;
-
-        if (fullSearch.search.id) {
-            // search by user id performs a direct ID lookup
-            var userId = fullSearch.search.id.value;
-            $timeout(
-                function() {
-                    egUser.get(userId).then(function(user) {
-                        patronSvc.localFlesh(user);
-                        patronSvc.patrons = [user];
-                        deferred.notify(user);
-                        deferred.resolve();
-                    });
-                }
-            );
-            return deferred.promise;
-        }
-
-        if (!Object.keys(fullSearch.search).length) {
-            // Empty searches are rejected by the server.  Avoid 
-            // running the the empty search that runs on page load. 
-            return $q.when();
-        }
-
-        egProgressDialog.open(); // Indeterminate
-
-        patronSvc.patrons = [];
-        var which_sound = 'success';
-        egCore.net.request(
-            'open-ils.actor',
-            'open-ils.actor.patron.search.advanced.fleshed',
-            egCore.auth.token(), 
-            fullSearch.search, 
-            fullSearch.count,
-            fullSearch.sort,
-            fullSearch.inactive,
-            fullSearch.home_ou,
-            egUser.defaultFleshFields,
-            fullSearch.offset
-
-        ).then(
-            function() {
-                deferred.resolve();
-            },
-            function() { // onerror
-                which_sound = 'error';
-            },
-            function(user) {
-                // hide progress bar as soon as the first result appears.
-                egProgressDialog.close();
-                patronSvc.localFlesh(user); // inline
-                patronSvc.patrons.push(user);
-                deferred.notify(user);
-            }
-        )['finally'](function() { // close on 0-hits or error
-            if (which_sound == 'success' && patronSvc.patrons.length == 0) {
-                which_sound = 'warning';
-            }
-            egCore.audio.play(which_sound + '.patron.by_search');
-            egProgressDialog.close();
-        });
-
-        return deferred.promise;
-    };
-
-    $scope.patronSearchGridProvider = provider;
-
-    // determine the tree depth of the profile group
-    $scope.pgt_depth = function(grp) {
-        var d = 0;
-        while (grp = egCore.env.pgt.map[grp.parent()]) d++;
-        return d;
-    }
-
-    $scope.clearForm = function () {
-        $scope.searchArgs={};
-        if (lastFormElement) lastFormElement.focus();
-    }
-
-    $scope.applyShowExtras = function($event, bool) {
-        if (bool) {
-            $scope.showExtras = true;
-            egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
-        } else {
-            $scope.showExtras = false;
-            egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
-        }
-        if (lastFormElement) lastFormElement.focus();
-        $event.preventDefault();
-    }
-
-    egCore.hatch.getItem('eg.circ.patron.search.show_extras')
-    .then(function(val) {$scope.showExtras = val});
-
-    // map form arguments into search params
-    function compileSearch(args) {
-        var search = {};
-        angular.forEach(args, function(val, key) {
-            if (!val) return;
-            if (key == 'profile' && args.profile) {
-                search.profile = {value : args.profile.id(), group : 0};
-            } else if (key == 'home_ou' && args.home_ou) {
-                search.home_ou = args.home_ou.id(); // passed separately
-            } else if (key == 'inactive') {
-                search.inactive = val;
-            } else {
-                search[key] = {value : val, group : 0};
-            }
-            if (key.match(/phone|ident/)) {
-                search[key].group = 2;
-            } else {
-                if (key.match(/street|city|state|post_code/)) {
-                    search[key].group = 1;
-                } else if (key == 'card') {
-                    search[key].group = 3
-                }
-            }
-        });
-
-        return search;
-    }
-
-    function compileSort() {
-
-        if (!provider.sort.length) {
-            return [ // default
-                "family_name ASC",
-                "first_given_name ASC",
-                "second_given_name ASC",
-                "dob DESC"
-            ];
-        }
-
-        var sort = [];
-        angular.forEach(
-            provider.sort,
-            function(sortdef) {
-                if (angular.isObject(sortdef)) {
-                    var name = Object.keys(sortdef)[0];
-                    var dir = sortdef[name];
-                    sort.push(name + ' ' + dir);
-                } else {
-                    sort.push(sortdef);
-                }
-            }
-        );
-
-        return sort;
-    }
-
-    $scope.setLastFormElement = function() {
-        lastFormElement = $document[0].activeElement;
-    }
-
-    // search form submit action; tells the results grid to
-    // refresh itself.
-    $scope.search = function(args) { // args === $scope.searchArgs
-        if (args && Object.keys(args).length) 
-            $scope.gridControls.refresh();
-        if (lastFormElement) lastFormElement.focus();
-    }
-
-    // TODO: move this into the (forthcoming) grid row activate action
-    $scope.onPatronDblClick = function($event, user) {
-        $location.path('/circ/patron/' + user.id() + '/checkout');
-    }
-
-    if (patronSvc.urlSearch) {
-        // force the grid to load the url-based search on page load
-        provider.refresh();
-    }
 
     $scope.need_two_selected = function() {
         var items = $scope.gridControls.selectedItems();
diff --git a/Open-ILS/web/js/ui/default/staff/services/patron_search.js b/Open-ILS/web/js/ui/default/staff/services/patron_search.js
new file mode 100644
index 0000000..48859d8
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/patron_search.js
@@ -0,0 +1,685 @@
+/**
+ * Patron Search module
+ */
+
+angular.module('egPatronSearchMod', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+/**
+ * Patron service
+ */
+.factory('patronSvc',
+       ['$q','$timeout','$location','egCore','egUser','$locale',
+function($q , $timeout , $location , egCore,  egUser , $locale) {
+
+    var service = {
+        // cached patron search results
+        patrons : [],
+
+        // currently selected patron object
+        current : null, 
+
+        // patron circ stats (overdues, fines, holds)
+        patron_stats : null,
+
+        // event types manually overridden, which should always be
+        // overridden for checkouts to this patron for this instance of
+        // the interface.
+        checkout_overrides : {},        
+        //holds the searched barcode
+        search_barcode : null,      
+    };
+
+    // when we change the default patron, we need to clear out any
+    // data collected on that patron
+    service.resetPatronLists = function() {
+        service.checkouts = [];
+        service.items_out = []
+        service.items_out_ids = [];
+        service.holds = [];
+        service.hold_ids = [];
+        service.checkout_overrides = {};
+        service.patron_stats = null;
+        service.noncat_ids = [];
+        service.hasAlerts = false;
+        service.patronExpired = false;
+        service.patronExpiresSoon = false;
+        service.invalidAddresses = false;
+    }
+    service.resetPatronLists();  // initialize
+
+    // Returns true if the last alerted patron matches the current
+    // patron.  Otherwise, the last alerted patron is set to the 
+    // current patron and false is returned.
+    service.alertsShown = function() {
+        var key = 'eg.circ.last_alerted_patron';
+        var last_id = egCore.hatch.getSessionItem(key);
+        if (last_id && last_id == service.current.id()) return true;
+        egCore.hatch.setSessionItem(key, service.current.id());
+        return false;
+    }
+
+    // shortcut to force-reload the current primary
+    service.refreshPrimary = function() {
+        if (!service.current) return $q.when();
+        return service.setPrimary(service.current.id(), null, true);
+    }
+
+    // clear the currently focused user
+    service.clearPrimary = function() {
+        // reset with no patron
+        service.resetPatronLists();
+        service.current = null;
+        service.patron_stats = null;
+        return $q.when();
+    }
+
+    // sets the primary display user, fetching data as necessary.
+    service.setPrimary = function(id, user, force) {
+        var user_id = id ? id : (user ? user.id() : null);
+
+        console.debug('setting primary user to: ' + user_id);
+
+        if (!user_id) return $q.reject();
+
+        // when loading a new patron, update the last patron setting
+        if (!service.current || service.current.id() != user_id)
+            egCore.hatch.setLoginSessionItem('eg.circ.last_patron', user_id);
+
+        // avoid running multiple retrievals for the same patron, which
+        // can happen during dbl-click by maintaining a single running
+        // data retrieval promise
+        if (service.primaryUserPromise) {
+            if (service.primaryUserId == user_id) {
+                return service.primaryUserPromise.promise;
+            } else {
+                service.primaryUserPromise = null;
+            }
+        }
+
+        service.primaryUserPromise = $q.defer();
+        service.primaryUserId = user_id;
+
+        service.getPrimary(id, user, force)
+        .then(function() {
+            service.checkAlerts();
+            var p = service.primaryUserPromise;
+            service.primaryUserId = null;
+            // clear before resolution just to be safe.
+            service.primaryUserPromise = null;
+            p.resolve();
+        });
+
+        return service.primaryUserPromise.promise;
+    }
+
+    service.getPrimary = function(id, user, force) {
+
+        if (user) {
+            if (!force && service.current && 
+                service.current.id() == user.id()) {
+                if (service.patron_stats) {
+                    return $q.when();
+                } else {
+                    return service.fetchUserStats();
+                }
+            }
+
+            service.resetPatronLists();
+            service.current = user;
+            service.localFlesh(user);
+            return service.fetchUserStats();
+
+        } else if (id) {
+            if (!force && service.current && service.current.id() == id) {
+                if (service.patron_stats) {
+                    return $q.when();
+                } else {
+                    return service.fetchUserStats();
+                }
+            }
+
+            service.resetPatronLists();
+
+            return egUser.get(id).then(
+                function(user) {
+                    service.current = user;
+                    service.localFlesh(user);
+                    return service.fetchUserStats();
+                },
+                function(err) {
+                    console.error(
+                        "unable to fetch user "+id+': '+js2JSON(err))
+                }
+            );
+        } else {
+
+            // fetching a null user clears the primary user.
+            // NOTE: this should probably reject() and log an error, 
+            // but calling clear for backwards compat for now.
+            return service.clearPrimary();
+        }
+    }
+
+    // flesh some additional user fields locally
+    service.localFlesh = function(user) {
+        if (!angular.isObject(typeof user.home_ou()))
+            user.home_ou(egCore.org.get(user.home_ou()));
+
+        angular.forEach(
+            user.standing_penalties(),
+            function(penalty) {
+                if (!angular.isObject(penalty.org_unit()))
+                    penalty.org_unit(egCore.org.get(penalty.org_unit()));
+            }
+        );
+
+        // stat_cat_entries == stat_cat_entry_user_map
+        angular.forEach(user.stat_cat_entries(), function(map) {
+            if (angular.isObject(map.stat_cat())) return;
+            // At page load, we only retrieve org-visible stat cats.
+            // For the common case, ignore entries for remote stat cats.
+            var cat = egCore.env.actsc.map[map.stat_cat()];
+            if (cat) {
+                map.stat_cat(cat);
+                cat.owner(egCore.org.get(cat.owner()));
+            }
+        });
+    }
+
+    // resolves to true if the patron account has expired or will
+    // expire soon, based on YAOUS circ.patron_expires_soon_warning
+    // note: returning a promise is no longer strictly necessary
+    // (no more async activity) if the calling function is changed too.
+    service.testExpire = function() {
+
+        var expire = Date.parse(service.current.expire_date());
+        if (expire < new Date()) {
+            return $q.when(service.patronExpired = true);
+        }
+
+        var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
+        if (Number(soon)) {
+            var preExpire = new Date();
+            preExpire.setDate(preExpire.getDate() + Number(soon));
+            if (expire < preExpire) 
+                return $q.when(service.patronExpiresSoon = true);
+        }
+
+        return $q.when(false);
+    }
+
+    // resolves to true if the patron account has any invalid addresses.
+    service.testInvalidAddrs = function() {
+
+        if (service.invalidAddresses)
+            return $q.when(true);
+
+        var fail = false;
+
+        angular.forEach(
+            service.current.addresses(), 
+            function(addr) { if (addr.valid() == 'f') fail = true }
+        );
+
+        return $q.when(fail);
+    }
+    //resolves to true if the patron was fetched with an inactive card
+    service.fetchedWithInactiveCard = function() {
+        var bc = service.search_barcode
+        var cards = service.current.cards();
+        var card = cards.filter(function(c) { return c.barcode() == bc })[0];
+        return (card && card.active() == 'f');
+    }   
+    // resolves to true if there is any aspect of the patron account
+    // which should produce a message in the alerts panel
+    service.checkAlerts = function() {
+
+        if (service.hasAlerts) // already checked
+            return $q.when(true); 
+
+        var deferred = $q.defer();
+        var p = service.current;
+
+        if (service.alert_penalties.length ||
+            p.alert_message() ||
+            p.active() == 'f' ||
+            p.barred() == 't' ||
+            service.patron_stats.holds.ready) {
+
+            service.hasAlerts = true;
+        }
+
+        // see if the user was retrieved with an inactive card
+        if(service.fetchedWithInactiveCard()){
+            service.hasAlerts = true;
+        }
+
+        // regardless of whether we know of alerts, we still need 
+        // to test/fetch the expire data for display
+        service.testExpire().then(function(bool) {
+            if (bool) service.hasAlerts = true;
+            deferred.resolve(service.hasAlerts);
+        });
+
+        service.testInvalidAddrs().then(function(bool) {
+            if (bool) service.invalidAddresses = true;
+            deferred.resolve(service.invalidAddresses);
+        });
+
+        return deferred.promise;
+    }
+
+    service.fetchGroupFines = function() {
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.usergroup.members.balance_owed',
+            egCore.auth.token(), service.current.usrgroup()
+        ).then(function(list) {
+            var total = 0;
+            angular.forEach(list, function(u) { 
+                total += 100 * Number(u.balance_owed)
+            });
+            service.patron_stats.fines.group_balance_owed = total / 100;
+        });
+    }
+
+    service.getUserStats = function(id) {
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.opac.vital_stats.authoritative', 
+            egCore.auth.token(), id
+        ).then(
+            function(stats) {
+                // force numeric to ensure correct boolean handling in templates
+                stats.fines.balance_owed = Number(stats.fines.balance_owed);
+                stats.checkouts.overdue = Number(stats.checkouts.overdue);
+                stats.checkouts.claims_returned = 
+                    Number(stats.checkouts.claims_returned);
+                stats.checkouts.lost = Number(stats.checkouts.lost);
+                stats.checkouts.out = Number(stats.checkouts.out);
+                stats.checkouts.total_out = 
+                    stats.checkouts.out + stats.checkouts.overdue;
+
+                stats.checkouts.total_out += Number(stats.checkouts.long_overdue);
+
+                if (!egCore.env.aous['circ.do_not_tally_claims_returned'])
+                    stats.checkouts.total_out += stats.checkouts.claims_returned;
+
+                if (egCore.env.aous['circ.tally_lost'])
+                    stats.checkouts.total_out += stats.checkouts.lost
+
+                return stats;
+            }
+        );
+    }
+
+    // Fetches the IDs of any active non-cat checkouts for the current
+    // user.  Also sets the patron_stats non_cat count value to match.
+    service.getUserNonCats = function(id) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
+            egCore.auth.token(), id
+        ).then(function(noncat_ids) {
+            service.noncat_ids = noncat_ids;
+            service.patron_stats.checkouts.noncat = noncat_ids.length;
+        });
+    }
+
+    // grab additional circ info
+    service.fetchUserStats = function() {
+        return service.getUserStats(service.current.id())
+        .then(function(stats) {
+            service.patron_stats = stats
+            service.alert_penalties = service.current.standing_penalties()
+                .filter(function(pen) { 
+                return pen.standing_penalty().staff_alert() == 't' 
+            });
+
+            service.summary_stat_cats = [];
+            angular.forEach(service.current.stat_cat_entries(), 
+                function(map) {
+                    if (angular.isObject(map.stat_cat()) &&
+                        map.stat_cat().usr_summary() == 't') {
+                        service.summary_stat_cats.push(map);
+                    }
+                }
+            );
+
+            // run these two in parallel
+            var p1 = service.getUserNonCats(service.current.id());
+            var p2 = service.fetchGroupFines();
+            return $q.all([p1, p2]);
+        });
+    }
+
+    // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
+    // which is the Angular default.
+    // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
+    // FIXME: This change needs to be moved into a project-wide collection
+    // of locale overrides.
+    $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
+    $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
+
+    return service;
+}])
+
+/**
+ * Manages patron search
+ */
+.controller('BasePatronSearchCtrl',
+       ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
+       '$filter','egUser', 'patronSvc','egGridDataProvider','$document',
+       'egProgressDialog',
+function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
+        $filter,  egUser,  patronSvc , egGridDataProvider , $document,
+        egProgressDialog) {
+
+    $scope.focusMe = true;
+    $scope.searchArgs = {
+        // default to searching globally
+        home_ou : egCore.org.tree()
+    };
+
+    // last used patron search form element
+    var lastFormElement;
+
+    $scope.gridControls = {
+        selectedItems : function() {return []}
+    }
+
+    // Handle URL-encoded searches
+    if ($location.search().search) {
+        console.log('URL search = ' + $location.search().search);
+        patronSvc.urlSearch = {search : JSON2js($location.search().search)};
+
+        // why the double-JSON encoded sort?
+        if (patronSvc.urlSearch.search.search_sort) {
+            patronSvc.urlSearch.sort = 
+                JSON2js(patronSvc.urlSearch.search.search_sort);
+        } else {
+            patronSvc.urlSearch.sort = [];
+        }
+        delete patronSvc.urlSearch.search.search_sort;
+
+        // include inactive patrons if "inactive" param
+        if ($location.search().inactive) {
+            patronSvc.urlSearch.inactive = $location.search().inactive;
+        }
+    }
+
+    var propagate;
+    var propagate_inactive;
+    if (patronSvc.lastSearch) {
+        propagate = patronSvc.lastSearch.search;
+        // home_ou needs to be treated specially
+        propagate.home_ou = {
+            value : patronSvc.lastSearch.home_ou,
+            group : 0
+        };
+    } else if (patronSvc.urlSearch) {
+        propagate = patronSvc.urlSearch.search;
+        if (patronSvc.urlSearch.inactive) {
+            propagate_inactive = patronSvc.urlSearch.inactive;
+        }
+    }
+
+    if (egCore.env.pgt) {
+        $scope.profiles = egCore.env.pgt.list;
+    } else {
+        egCore.pcrud.search('pgt', {parent : null}, 
+            {flesh : -1, flesh_fields : {pgt : ['children']}}
+        ).then(
+            function(tree) {
+                egCore.env.absorbTree(tree, 'pgt')
+                $scope.profiles = egCore.env.pgt.list;
+            }
+        );
+    }
+
+    if (propagate) {
+        // populate the search form with our cached / preexisting search info
+        angular.forEach(propagate, function(val, key) {
+            if (key == 'profile')
+                val.value = $scope.profiles.filter(function(p) { return p.id() == val.value })[0];
+            if (key == 'home_ou')
+                val.value = egCore.org.get(val.value);
+            $scope.searchArgs[key] = val.value;
+        });
+        if (propagate_inactive) {
+            $scope.searchArgs.inactive = propagate_inactive;
+        }
+    }
+
+    var provider = egGridDataProvider.instance({});
+
+    provider.get = function(offset, count) {
+        var deferred = $q.defer();
+
+        var fullSearch;
+        if (patronSvc.urlSearch) {
+            fullSearch = patronSvc.urlSearch;
+            // enusre the urlSearch only runs once.
+            delete patronSvc.urlSearch;
+
+        } else {
+            patronSvc.search_barcode = $scope.searchArgs.card;
+            
+            var search = compileSearch($scope.searchArgs);
+            if (Object.keys(search) == 0) return $q.when();
+
+            var home_ou = search.home_ou;
+            delete search.home_ou;
+            var inactive = search.inactive;
+            delete search.inactive;
+
+            fullSearch = {
+                search : search,
+                sort : compileSort(),
+                inactive : inactive,
+                home_ou : home_ou,
+            };
+        }
+
+        fullSearch.count = count;
+        fullSearch.offset = offset;
+
+        if (patronSvc.lastSearch) {
+            // search repeated, return the cached results
+            if (angular.equals(fullSearch, patronSvc.lastSearch)) {
+                console.log('patron search returning ' + 
+                    patronSvc.patrons.length + ' cached results');
+                
+                // notify has to happen after returning the promise
+                $timeout(
+                    function() {
+                        angular.forEach(patronSvc.patrons, function(user) {
+                            deferred.notify(user);
+                        });
+                        deferred.resolve();
+                    }
+                );
+                return deferred.promise;
+            }
+        }
+
+        patronSvc.lastSearch = fullSearch;
+
+        if (fullSearch.search.id) {
+            // search by user id performs a direct ID lookup
+            var userId = fullSearch.search.id.value;
+            $timeout(
+                function() {
+                    egUser.get(userId).then(function(user) {
+                        patronSvc.localFlesh(user);
+                        patronSvc.patrons = [user];
+                        deferred.notify(user);
+                        deferred.resolve();
+                    });
+                }
+            );
+            return deferred.promise;
+        }
+
+        if (!Object.keys(fullSearch.search).length) {
+            // Empty searches are rejected by the server.  Avoid 
+            // running the the empty search that runs on page load. 
+            return $q.when();
+        }
+
+        egProgressDialog.open(); // Indeterminate
+
+        patronSvc.patrons = [];
+        var which_sound = 'success';
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced.fleshed',
+            egCore.auth.token(), 
+            fullSearch.search, 
+            fullSearch.count,
+            fullSearch.sort,
+            fullSearch.inactive,
+            fullSearch.home_ou,
+            egUser.defaultFleshFields,
+            fullSearch.offset
+
+        ).then(
+            function() {
+                deferred.resolve();
+            },
+            function() { // onerror
+                which_sound = 'error';
+            },
+            function(user) {
+                // hide progress bar as soon as the first result appears.
+                egProgressDialog.close();
+                patronSvc.localFlesh(user); // inline
+                patronSvc.patrons.push(user);
+                deferred.notify(user);
+            }
+        )['finally'](function() { // close on 0-hits or error
+            if (which_sound == 'success' && patronSvc.patrons.length == 0) {
+                which_sound = 'warning';
+            }
+            egCore.audio.play(which_sound + '.patron.by_search');
+            egProgressDialog.close();
+        });
+
+        return deferred.promise;
+    };
+
+    $scope.patronSearchGridProvider = provider;
+
+    // determine the tree depth of the profile group
+    $scope.pgt_depth = function(grp) {
+        var d = 0;
+        while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+        return d;
+    }
+
+    $scope.clearForm = function () {
+        $scope.searchArgs={};
+        if (lastFormElement) lastFormElement.focus();
+    }
+
+    $scope.applyShowExtras = function($event, bool) {
+        if (bool) {
+            $scope.showExtras = true;
+            egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
+        } else {
+            $scope.showExtras = false;
+            egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
+        }
+        if (lastFormElement) lastFormElement.focus();
+        $event.preventDefault();
+    }
+
+    egCore.hatch.getItem('eg.circ.patron.search.show_extras')
+    .then(function(val) {$scope.showExtras = val});
+
+    // map form arguments into search params
+    function compileSearch(args) {
+        var search = {};
+        angular.forEach(args, function(val, key) {
+            if (!val) return;
+            if (key == 'profile' && args.profile) {
+                search.profile = {value : args.profile.id(), group : 0};
+            } else if (key == 'home_ou' && args.home_ou) {
+                search.home_ou = args.home_ou.id(); // passed separately
+            } else if (key == 'inactive') {
+                search.inactive = val;
+            } else {
+                search[key] = {value : val, group : 0};
+            }
+            if (key.match(/phone|ident/)) {
+                search[key].group = 2;
+            } else {
+                if (key.match(/street|city|state|post_code/)) {
+                    search[key].group = 1;
+                } else if (key == 'card') {
+                    search[key].group = 3
+                }
+            }
+        });
+
+        return search;
+    }
+
+    function compileSort() {
+
+        if (!provider.sort.length) {
+            return [ // default
+                "family_name ASC",
+                "first_given_name ASC",
+                "second_given_name ASC",
+                "dob DESC"
+            ];
+        }
+
+        var sort = [];
+        angular.forEach(
+            provider.sort,
+            function(sortdef) {
+                if (angular.isObject(sortdef)) {
+                    var name = Object.keys(sortdef)[0];
+                    var dir = sortdef[name];
+                    sort.push(name + ' ' + dir);
+                } else {
+                    sort.push(sortdef);
+                }
+            }
+        );
+
+        return sort;
+    }
+
+    $scope.setLastFormElement = function() {
+        lastFormElement = $document[0].activeElement;
+    }
+
+    // search form submit action; tells the results grid to
+    // refresh itself.
+    $scope.search = function(args) { // args === $scope.searchArgs
+        if (args && Object.keys(args).length) 
+            $scope.gridControls.refresh();
+        if (lastFormElement) lastFormElement.focus();
+    }
+
+    // TODO: move this into the (forthcoming) grid row activate action
+    $scope.onPatronDblClick = function($event, user) {
+        $location.path('/circ/patron/' + user.id() + '/checkout');
+    }
+
+    if (patronSvc.urlSearch) {
+        // force the grid to load the url-based search on page load
+        provider.refresh();
+    }
+
+    $scope.need_two_selected = function() {
+        var items = $scope.gridControls.selectedItems();
+        return (items.length == 2) ? false : true;
+    }
+   
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
index 87d6731..049f677 100644
--- a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
+++ b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
@@ -44,6 +44,7 @@ module.exports = function(config){
       'services/ui.js',
       'services/grid.js',
       'services/op_change.js',
+      'services/patron_search.js',
       'services/navbar.js', 'services/date.js',
       // load app scripts
       'app.js',

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/src/templates/opac/parts/place_hold.tt2   |    6 +-
 Open-ILS/src/templates/staff/cat/catalog/index.tt2 |    1 +
 Open-ILS/src/templates/staff/circ/patron/index.tt2 |    1 +
 .../src/templates/staff/circ/patron/t_search.tt2   |  152 +-----
 Open-ILS/src/templates/staff/circ/renew/index.tt2  |    1 +
 .../t_patron_search_form.tt2}                      |   10 -
 .../t_patron_search_results.tt2}                   |   13 +-
 .../templates/staff/share/t_patron_selector.tt2    |   27 +
 .../web/js/ui/default/staff/cat/catalog/app.js     |   44 ++-
 .../web/js/ui/default/staff/circ/patron/app.js     |  663 +-------------------
 .../js/ui/default/staff/services/patron_search.js  |  685 ++++++++++++++++++++
 .../web/js/ui/default/staff/test/karma.conf.js     |    1 +
 .../Circulation/Patron_Search_from_Place_Hold.adoc |   10 +
 13 files changed, 784 insertions(+), 830 deletions(-)
 copy Open-ILS/src/templates/staff/{circ/patron/t_search.tt2 => share/t_patron_search_form.tt2} (97%)
 copy Open-ILS/src/templates/staff/{circ/patron/t_search_results.tt2 => share/t_patron_search_results.tt2} (82%)
 create mode 100644 Open-ILS/src/templates/staff/share/t_patron_selector.tt2
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/patron_search.js
 create mode 100644 docs/RELEASE_NOTES_NEXT/Circulation/Patron_Search_from_Place_Hold.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list