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

Evergreen Git git at git.evergreen-ils.org
Fri Aug 29 16:16:09 EDT 2014


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  8487d77bc34b1160f57df4b6c1c9d6b3b4d20f24 (commit)
       via  b5d32378c7d7e8e1cc573f0a402301081aa07679 (commit)
       via  ac474f077006d4b12527aebedb587039c392ab23 (commit)
       via  c267ec08ad901efe11926315697b23798a504074 (commit)
       via  48a063c16bcaffdd19628133496f281db18b5f54 (commit)
       via  9aa9269eddd6e43b10b8ad8245eda0e887422f52 (commit)
       via  7df9dd0aeccdd87e156ab499e2408f550dc0a3d7 (commit)
       via  f7fc7fa2a1bac404317a7ba549beaf73d56c0ac5 (commit)
       via  cd4b5c68affdaf7cb915c81f9b252df8688344db (commit)
      from  a4b7150fb9dc2981249770bdf989b205af60dbf7 (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 8487d77bc34b1160f57df4b6c1c9d6b3b4d20f24
Author: Bill Erickson <berick at esilibrary.com>
Date:   Wed Aug 13 12:41:40 2014 -0400

    LP#1350042 add missing ui-bootstrap dep to Gruntfile
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/web/js/ui/default/staff/Gruntfile.js b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
index d55d71f..eb004dc 100644
--- a/Open-ILS/web/js/ui/default/staff/Gruntfile.js
+++ b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
@@ -19,6 +19,7 @@ module.exports = function(grunt) {
             'bower_components/angular/angular.min.js.map',
             'bower_components/angular-route/angular-route.min.js',
             'bower_components/angular-route/angular-route.min.js.map',
+            'bower_components/angular-bootstrap/ui-bootstrap.min.js',
             'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js',
             'bower_components/angular-hotkeys/build/hotkeys.min.js',
           ]
@@ -78,6 +79,7 @@ module.exports = function(grunt) {
             // The order is important.
             'build/js/angular.min.js',
             'build/js/angular-route.min.js',
+            'build/js/ui-bootstrap.min.js',
             'build/js/ui-bootstrap-tpls.min.js',
             'build/js/hotkeys.min.js',
             // NOTE: OpenSRF must be installed

commit b5d32378c7d7e8e1cc573f0a402301081aa07679
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 14:06:02 2014 -0400

    LP#1350042 Browser client templates/scripts (phase 1)
    
     * Templates in Open-ILS/src/templates/staff/
     * JS files in Open-ILS/web/js/ui/default/staff/
     * Also includes a few TPAC modifications for embedded mode
     * Resurrects the pre-XUL user permission editor
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/.gitignore b/.gitignore
index 76675d6..073c02d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -353,3 +353,6 @@ Open-ILS/xul/staff_client/xulrunner-stub.exe
 Thumbs.db
 /js*
 /JavaScript*
+Open-ILS/web/js/ui/default/staff/build/
+Open-ILS/web/js/ui/default/staff/node_modules/
+Open-ILS/web/js/ui/default/staff/bower_components/
diff --git a/Open-ILS/src/templates/opac/parts/bookbag_actions.tt2 b/Open-ILS/src/templates/opac/parts/bookbag_actions.tt2
index de33841..611d1fc 100644
--- a/Open-ILS/src/templates/opac/parts/bookbag_actions.tt2
+++ b/Open-ILS/src/templates/opac/parts/bookbag_actions.tt2
@@ -4,7 +4,7 @@
 
     # Wrap a url to open in a new tab in staff client.
     MACRO opac_wrap(url) BLOCK;
-    	  IF ctx.is_staff;
+    	  IF ctx.is_staff AND NOT ctx.is_browser_staff;
               # void(0) to return false and not go to new page in current tab.
               "javascript:xulG.new_tab(xulG.urls.XUL_OPAC_WRAPPER, {}, {'opac_url' : 'oils://remote" _ url _ "'});void(0);";
           ELSE;
diff --git a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2 b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
index 814c4f9..6ecaa10 100644
--- a/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
+++ b/Open-ILS/src/templates/opac/parts/record/copy_table.tt2
@@ -105,6 +105,19 @@ END; # FOREACH bib
             <td headers='copy_header_barcode' property="serialNumber">
                 [% copy_info.barcode | html -%]
                 [% IF ctx.is_staff %]
+                  [%- IF ctx.is_browser_staff %]
+                    <a target="_top" href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]">[% l('view') %]</a>
+                    [% IF ctx.has_perm('UPDATE_COPY', copy_info.circ_lib) 
+                        OR ctx.has_perm('UPDATE_COPY', copy_info.call_number_owning_lib) %]
+                        <span> | </span>
+                        <!-- XXX: copy edit is not yet supported in browser client.
+                          Enable this link when available
+                        -->
+                        <!--
+                        <a href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]/edit">[% l('edit') %]</a>
+                        -->
+                    [% END %]
+                  [% ELSE %]
                     <a onclick="xulG.new_tab(xulG.urls.XUL_COPY_STATUS, {}, {'from_item_details_new': true, 'barcodes': ['[%- copy_info.barcode | html | replace('\'', '\\\'') -%]']})"
                         href="javascript:;">[% l('view') %]</a>
                     [%# if the user can edit copies, show the copy edit link %]
@@ -116,6 +129,7 @@ END; # FOREACH bib
                             [% l(' edit') %]
                         </a>
                     [% END %]
+                  [% END %]
                 [% END %]
                 [%- IF attrs.gtin13;
                     '<meta property="gtin13" content="' _ attrs.gtin13 _ '" />';
diff --git a/Open-ILS/src/templates/staff/README b/Open-ILS/src/templates/staff/README
new file mode 100644
index 0000000..9206301
--- /dev/null
+++ b/Open-ILS/src/templates/staff/README
@@ -0,0 +1,6 @@
+AnguarJS/Web Staff Client
+=========================
+
+ * TT templates loaded via JS routes must be preceded with t_* (or similar), 
+   otherwise apache will serve the template at that path instead of the 
+   index file since the path maps to a real template.
diff --git a/Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2 b/Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2
new file mode 100644
index 0000000..3a23cc5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2
@@ -0,0 +1,24 @@
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input 
+      focus-me="selectMe" 
+      select-me="selectMe"
+      class="form-control barcode"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-lookup-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/admin/user_perms.tt2 b/Open-ILS/src/templates/staff/admin/user_perms.tt2
new file mode 100644
index 0000000..23a1257
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/user_perms.tt2
@@ -0,0 +1,18 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("User Permission Editor"); 
+  ctx.page_app = "egUserPermsEditor";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/user_perms.js"></script>
+[% END %]
+
+<script type="text/ng-template" id="user-perms-template">
+  <eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
+</script>
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/workstation/index.tt2 b/Open-ILS/src/templates/staff/admin/workstation/index.tt2
new file mode 100644
index 0000000..3f927a4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/workstation/index.tt2
@@ -0,0 +1,24 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Workstation Administration"); 
+  ctx.page_app = "egWorkstationAdmin";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/workstation/app.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.PREFS_REMOVE_KEY_CONFIRM = 
+    '[% l('Delete content for key "[_1]"?', '{{deleteKey}}') %]';
+  s.DEFAULT_WS_LABEL = '[% l('[_1] (Default)', '{{ws}}') %]';
+  s.WS_EXISTS = '[% l("Workstation name already exists.  Use it anyway?") %]';
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2
new file mode 100644
index 0000000..06067ac
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2
@@ -0,0 +1,176 @@
+<div class="container" id="admin-workstation-printing">
+
+  <style>
+    /* TODO: more context and move me */
+    textarea {
+      height: 400px;
+      width: 100%;
+    }
+    .tab-pane .row {
+      padding-top: 20px;
+    }
+    h2 { margin-bottom: 15px }
+      
+  </style>
+
+  <div class="row"> 
+    <div class="col-md-12">
+      <h2>[% l('Printer Settings for Remote Printing') %]</h2>
+    </div>
+  </div>
+
+  <div class="row"> 
+    <div class="col-md-12">
+      <ul class="nav nav-tabs">
+        <li ng-class="{active : context == 'default'}">
+          <a href='' ng-click="setContext('default')">[% l('Default') %]</a>
+        </li>
+        <li ng-class="{active : context == 'receipt'}">
+          <a href='' ng-click="setContext('receipt')">[% l('Receipt') %]</a>
+        </li>
+        <li ng-class="{active : context == 'label'}">
+          <a href='' ng-click="setContext('label')">[% l('Label') %]</a>
+        </li>
+        <li ng-class="{active : context == 'mail'}">
+          <a href='' ng-click="setContext('mail')">[% l('Mail') %]</a>
+        </li>
+        <li ng-class="{active : context == 'offline'}">
+          <a href='' ng-click="setContext('offline')">[% l('Offline') %]</a>
+        </li>
+        <li ng-class="{active : isTestView}" class="pull-right">
+          <a href='' ng-click="isTestView=true">[% l('Test Printing') %]</a>
+        </li>
+      </ul>
+      <div class="tab-content">
+        <div class="tab-pane active">
+
+          <!-- printer config UI -->
+          <div class="row" ng-hide="isTestView">
+            <div class="col-md-6">
+              <div class="input-group">
+                <div class="input-group-btn" dropdown>
+                  <button type="button" class="btn btn-default dropdown-toggle">
+                    [% l('Select Printer') %]
+                    <span class="caret"></span></button>
+                  <ul class="dropdown-menu">
+                    <li ng-repeat="printer in printers">
+                      <a href='' ng-click="setPrinter(printer.name)">
+                        {{printer.name}}
+                      </a>
+                    </li>
+                  </ul>
+                </div><!-- /btn-group -->
+                <input ng-if="!printers[0]" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="[% l('No Printers Found') %]">
+                <input ng-if="printers[0] && !printConfig[context]" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="[% l('No Printer Selected') %]">
+                <input ng-if="printConfig[context].printer" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="{{printConfig[context].printer}}">
+              </div><!-- /input-group -->
+            </div><!-- col -->
+            <div class="col-md-6">
+              <div class="input-group">
+                <div class="input-group-btn">
+                  <button type="button" 
+                    ng-click="configurePrinter()"
+                    ng-class="{disabled : actionPending || !printers[0]}"
+                    class="btn btn-default btn-success">
+                      [% l('Configure Printer') %]
+                  </button>
+                  <button type="button" 
+                    ng-click="resetConfig()"
+                    ng-class="{disabled : actionPending}"
+                    class="btn btn-default btn-warning">
+                      [% l('Reset Configuration') %]
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div><!-- row -->
+          <div class="row" ng-hide="isTestView"> 
+            <div class="col-md-12">
+              <h2>[% l('Compiled Printer Settings') %]</h2>
+              <pre>{{printerConfString()}}</pre>
+            </div><!-- col -->
+          </div><!-- row -->
+
+          <!-- printer test UI -->
+          <div class="row" ng-show="isTestView"> 
+            <div class="col-md-10">
+              <div class="btn-group">
+                <button type="button" 
+                  class="btn btn-default btn-lg" 
+                  ng-class="{active : contentType=='text/plain'}"
+                  ng-click="setContentType('text/plain')">[% l('Plain Text') %]</button>
+                <button type="button" 
+                  class="btn btn-default btn-lg" 
+                  ng-class="{active : contentType=='text/html'}"
+                  ng-click="setContentType('text/html')">[% l('HTML') %]</button>
+              </div>
+            </div>
+            <div class="col-md-2">
+              <div class="input-group pull-right">
+                <div class="input-group-btn">
+                  <button type="button" 
+                    ng-click="testPrint()"
+                    class="btn btn-default btn-success">
+                      [% l('Print') %]</button>
+                  <button type="button" 
+                    ng-click="testPrint(true)"
+                    class="btn btn-default btn-info">
+                      [% l('Print with Dialog') %]</button>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div class="row" ng-show="isTestView"> 
+            <div class="col-md-12">
+              <div ng-show="contentType=='text/plain'"
+ng-init="textPrintContent='
+[% l('Test Print') %]
+
+1234567890
+
+12345678901234567890
+
+123456789012345678901234567890
+
+1234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890123456790
+
+[% l('Test Print') %]
+'">
+        <pre><textarea>{{textPrintContent}}</textarea></pre>
+      </div>
+
+      <div ng-show="contentType=='text/html'">
+        <textarea ng-model="htmlPrintContent" 
+ng-init="htmlPrintContent='
+<div>
+  <style>p { color: blue }</style>
+  <h2>[% l('Test HTML Print') %]</h2>
+  <br/>
+  <img src=\'https://[% ctx.hostname %]/opac/images/main_logo.png\' width=\'140\' height=\'24\'/>
+  <p>[% l('Welcome, Stranger!') %]</p>
+  <p>{{value1}}</p>
+  <p>{{value2}}</p>
+  <p>{{date_value | date}}</p>
+</div>
+'">
+                </textarea>
+              </div><!-- html content -->
+            </div><!-- col -->
+          </div><!-- row -->
+        </div><!-- tab pane -->
+      </div><!-- tab content -->
+    </div><!-- col -->
+  </div><!-- row -->
+</div><!-- container -->
+
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2
new file mode 100644
index 0000000..cbc79f8
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2
@@ -0,0 +1,59 @@
+<style>
+  /* TODO: move me */
+  .print-template-text {
+    height: 36em;
+    width: 100%;
+  }
+</style>
+
+<h2>[% l('Print Templates') %]</h2>
+
+<div class="row">
+  <div class="col-md-2">[% l('Template Name') %]</div>
+  <div class="col-md-3">
+    <select class="form-control" ng-model="print.template_name" ng-change="template_changed()">
+      <option value="bills_current">[% l('Bills, Current') %]</option>
+      <option value="bills_historical">[% l('Bills, Historical') %]</option>
+      <option value="bill_payment">[% l('Bills, Payment') %]</option>
+      <option value="checkout">[% l('Checkout') %]</option>
+      <option value="hold_transit_slip">[% l('Hold Transit Slip') %]</option>
+      <option value="hold_shelf_slip">[% l('Hold Shelf Slip') %]</option>
+      <option value="holds_for_bibs">[% l('Holds for Bib Record') %]</option>
+      <option value="holds_for_patron">[% l('Holds for Patron') %]</option>
+      <option value="patron_address">[% l('Patron Address') %]</option>
+      <option value="patron_note">[% l('Patron Note') %]</option>
+      <option value="transit_slip">[% l('Transit Slip') %]</option>
+    </select>
+  </div>
+  <div class="col-md-7">
+    <div class="pull-right">
+      <button class="btn btn-default" ng-click="save_locally()">[% l('Save Locally') %]</button>
+    </div>
+  </div>
+  <!-- other stuff -->
+</div>
+
+<hr/>
+
+<div class="row">
+  <div class="col-md-5">
+    <h3>[% l('Preview') %]</h3>
+    <div eg-print-template-output 
+      content="print.template_content" 
+      context="preview_scope"></div>
+  </div>
+  <div class="col-md-7">
+    <h3>[% l('Template') %]</h3>
+    <div ng-if="print.load_failed" class="alert alert-danger">
+      [% l(
+        "Unable to load template '[_1]'.  The web server returned an error.", 
+        '{{print.template_name}}') 
+      %]
+    </div>
+    <div>
+      <textarea ng-model="print.template_content" class="print-template-text">
+      </textarea>
+    </div>
+  </div> <!-- col -->
+</div>
+ 
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
new file mode 100644
index 0000000..3a14bbe
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
@@ -0,0 +1,120 @@
+<br/>
+<style>
+  #admin-workstation-container .row {
+    margin-top: 5px;
+  }
+  #admin-workstation-container .new-entry {
+    margin-top: 10px;
+    padding-top: 10px;
+    border-top: 2px solid #F5F5F5;
+  }
+</style>
+
+<div class="container" id="admin-workstation-container">
+
+  <div class="row">
+    <div class="col-md-6">
+      <div class="checkbox">
+        <label>
+          <input type="checkbox" ng-class="{disabled : !userHasAdminPerm}"
+            ng-model="hatchRequired" ng-change="updateHatchRequired()">
+[% l('This workstation uses a remote print / storage service ("Hatch")?') %]
+        </label>
+      </div>
+    </div><!-- row -->
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <input type='text' class='form-control'  
+        ng-disabled="!hatchRequired || !userHasRegPerm"
+        title="[% l('Hatch URL') %]"
+        placeholder="[% l('Hatch URL') %]"
+        ng-change='updateHatchURL()' ng-model='hatchURL'/>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      [% l('Workstations Registered With This Computer') %]
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <select class="form-control" ng-model="selectedWS">
+        <option ng-repeat="ws in workstations" value="{{ws}}"
+          ng-selected="ws == selectedWS">
+          {{getWSLabel(ws)}}
+        </option>
+      </select>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-6">
+      <button class="btn btn-default" ng-click="useWS()">
+        [% l('Use Now') %]
+      </button>
+      <button class="btn btn-default" ng-click="setDefaultWS()">
+        [% l('Mark As Default') %]
+      </button>
+      <button class="btn btn-default btn-danger disabled">
+        [% l('Delete') %]
+      </button>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      [% l('Register a New Workstation For This Computer') %]
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <div class="input-group">
+        <div class="input-group-btn">
+          <eg-org-selector 
+            selected="contextOrg"
+            hidden-test="wsOrgHidden">
+          </eg-org-selector>
+        </div>
+        <input type='text' class='form-control'  
+          title="[% l('Workstation Name') %]"
+          placeholder="[% l('Workstation Name') %]"
+          ng-model='newWSName'/>
+        <div class="input-group-btn">
+          <button class="btn btn-default" ng-click="registerWS()">
+            [% l('Register') %]
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-print"></span>
+      <a target="_self" href="./admin/workstation/print/config">
+        [% l('Printer Settings') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-film"></span>
+      <a target="_self" href="./admin/workstation/print/templates">
+        [% l('Print Templates') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-info-sign"></span>
+      <a target="_self" href="./admin/workstation/stored_prefs">
+        [% l('Stored Preferences') %]
+      </a>
+    </div>
+  </div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2
new file mode 100644
index 0000000..dc031b4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2
@@ -0,0 +1,65 @@
+<style>
+  /* TODO */
+  #stored-prefs-container .selected {
+    background-color: #F5F5F5;   
+  }
+  #stored-prefs-container .row {
+    padding-top: 10px;
+  }
+</style>
+<div class="container" id="stored-prefs-container">
+  <div class="row">
+    <div class="col-md-12">
+      <h2>[% l('Stored User Preferences') %]</h2>
+      <div class="well">
+[% |l %]
+Preference values are stored as JSON strings.  
+Click on a preference to view the stored value.
+Click on the delete (X) button to remove a preference's value.
+[% END %]
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-4">
+
+      <ul class="nav nav-tabs">
+        <li ng-class="{active : context == 'local'}">
+          <a href='' ng-click="setContext('local')">[% l('Local Prefs') %]</a>
+        </li>
+        <li ng-class="{active : context == 'remote'}">
+          <a href='' ng-click="setContext('remote')">[% l('Remote Prefs') %]</a>
+        </li>
+      </ul>
+      <div class="tab-content">
+        <div class="tab-pane active">
+
+          <div class="row" ng-repeat="key in keys[context]">
+            <div class="col-md-1">{{$index + 1}}.</div>
+            <div class="col-md-8 stored-prefs-key" 
+              ng-class="{selected : currentKey == key}">
+              <a href='' ng-click="selectKey(key)">{{key}}</a>
+            </div>
+            <div class="col-md-1">
+              <!-- padding to give the buttom some overflow space -->
+            </div>
+            <div class="col-md-1" class="stored-prefs-remove-button">
+              <button class="btn btn-default btn-danger"
+                ng-class="{disabled : !userHasDeletePerm}"
+                ng-click="removeKey(key)" title="[% l('Remove Item') %]">
+                <span class="glyphicon glyphicon-remove"></span>
+              </button>
+            </div>
+          </div><!-- row -->
+
+        </div><!-- tab pane -->
+      </div><!-- tab content -->
+    </div><!-- col -->
+
+    <div class="col-md-8">
+      <pre>{{getCurrentKeyContent()}}</pre>
+    </div><!-- col -->
+
+  </div><!-- row -->
+</div><!-- container -->
diff --git a/Open-ILS/src/templates/staff/base.tt2 b/Open-ILS/src/templates/staff/base.tt2
new file mode 100644
index 0000000..ce9cc5e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/base.tt2
@@ -0,0 +1,48 @@
+<!doctype html>
+[%- PROCESS 'staff/config.tt2' %]
+<html lang="[% ctx.locale %]"
+    [%- IF ctx.page_app %] ng-app="[% ctx.page_app %]"[% END -%]
+    [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]>
+  <head>
+    <title>[% l('Evergreen Staff [_1]', ctx.page_title) %]</title>
+    <base href="/eg/staff/">
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    [% IF EXPAND_WEB_IMPORTS %]
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/bootstrap.min.css" />
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/hotkeys.min.css" />
+    [% ELSE %]
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/evergreen-staff-client-deps.[% EVERGREEN_VERSION %].min.css" />
+    [% END %]
+    <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/style.css" />
+    <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/print.css" type="text/css" media="print" />
+  </head>
+  <body>
+
+    <!-- load the navbar template inline since it's used on every page -->
+    <script type="text/ng-template" id="eg-navbar-template">
+      [% INCLUDE "staff/navbar.tt2" %]
+    </script>
+
+    <!-- instantiate the navbar by invoking it's name -->
+    <eg-navbar></eg-navbar>
+
+    <!-- main page content goes here -->
+    <div id="top-content-container" class="container">[% content %]</div>
+
+    [% 
+      # status bar along bottom of page
+      INCLUDE "staff/statusbar.tt2";
+
+      # script imports
+      INCLUDE "staff/base_js.tt2";
+
+      # App-specific JS load commands go into an APP_JS block.
+      PROCESS APP_JS;
+    %]
+
+    <!-- content printed via the browser is inserted here for 
+         DOM-ification prior to delivery to the printer -->
+    <div id="print-div" eg-print-container></div>
+  </body>
+</html>
diff --git a/Open-ILS/src/templates/staff/base_js.tt2 b/Open-ILS/src/templates/staff/base_js.tt2
new file mode 100644
index 0000000..76bc5a3
--- /dev/null
+++ b/Open-ILS/src/templates/staff/base_js.tt2
@@ -0,0 +1,47 @@
+<script src="/IDL2js"></script>
+
+[% IF EXPAND_WEB_IMPORTS %]
+
+<!-- angular -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-route.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/ui-bootstrap-tpls.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/hotkeys.min.js"></script>
+
+<!-- IDL / opensrf (network) -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_ws.js"></script>
+
+<!-- evergreen core services -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/strings.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/event.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/net.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/env.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/org.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/hatch.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/print.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/coresvc.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/statusbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+
+[% ELSE %]
+
+<!-- concatenated, minified version of all of the above -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/evergreen-staff-client.[% EVERGREEN_VERSION %].min.js"></script>
+
+[% END %]
+
+<script>
+  // Configure OpenSRF
+  // pending api_level thunking in C
+  // OpenSRF.api_level = 2;
+  OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+</script>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2
new file mode 100644
index 0000000..2f21e72
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2
@@ -0,0 +1,65 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Record Buckets"); 
+  ctx.page_app = "egCatRecordBuckets";
+  ctx.page_ctrl = "RecordBucketCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/bucket/record/app.js"></script>
+[% END %]
+
+<!-- using native Bootstrap taps because of limitations
+with angular-ui tabsets. it always defaults to making the
+first tab active, so it can't be driven from the route
+https://github.com/angular-ui/bootstrap/issues/910 
+No JS is needed to drive the native tabs, since we're
+changing routes with each tab selection anyway.
+-->
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : tab == 'search'}">
+    <a href="./cat/bucket/record/search/{{bucketSvc.currentBucket.id()}}">
+        [% l('Record Query') %]
+        <span ng-cloak>({{bucketSvc.queryRecords.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'pending'}">
+    <a href="./cat/bucket/record/pending/{{bucketSvc.currentBucket.id()}}">
+        [% l('Pending Records') %]
+        <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'view'}">
+    <a href="./cat/bucket/record/view/{{bucketSvc.currentBucket.id()}}">
+        [% l('Bucket View') %]
+        <span ng-cloak>({{bucketSvc.currentBucket.items().length}})</span>
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <!-- bucket info header -->
+    <div class="row">
+      <div class="col-md-6">
+        [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %]
+      </div>
+    </div>
+
+    <!-- bucket not accessible warning -->
+    <div class="col-md-10 col-md-offset-1" ng-show="forbidden">
+      <div class="alert alert-warning">
+        [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %]
+      </div>
+    </div>
+
+    <div ng-view></div>
+  </div>
+</div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2
new file mode 100644
index 0000000..e6bb3fe
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2
@@ -0,0 +1,35 @@
+<!-- edit bucket dialog -->
+
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"/> 
+          [% l('Publicly Visible?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Create Bucket') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2
new file mode 100644
index 0000000..0ca9887
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2
@@ -0,0 +1,16 @@
+<div class="modal-dialog">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Confirm Bucket Delete') %]</h4>
+    </div>
+    <div class="modal-body">
+      <p>[% l('Delete bucket {{bucket().name()}}?') %]</p>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-primary" ng-click="ok()">[% l('Delete Bucket') %]</button>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</div> <!-- modal-dialog -->
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2
new file mode 100644
index 0000000..288c577
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2
@@ -0,0 +1,34 @@
+<!-- edit bucket dialog -->
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Edit Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"> 
+          [% l('Publicly Visible?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2
new file mode 100644
index 0000000..ffc26d0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2
@@ -0,0 +1,41 @@
+<!-- export bucket dialog -->
+<form ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Export Records') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="export-bucket-format">[% l('Record Format') %]</label>
+        <select class="form-control" ng-model="args.format" id="export-bucket-format">
+          <option value="XML">[% l('MARC XML') %]</option>
+          <option value="USMARC">[% l('USMARC') %]</option>
+          <option value="UNIMARC">[% l('UNIMARC') %]</option>
+          <option value="BRE">[% l('Evergreen Record Entry') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="export-bucket-encoding">[% l('Encoding') %]</label>
+        <select class="form-control" ng-model="args.encoding" id="export-bucket-encoding">
+          <option value="UTF-8">[% l('UTF-8') %]</option>
+          <option value="MARC8">[% l('MARC8') %]</option>
+        </select>
+      </div>
+
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.holdings" type="checkbox"> 
+          [% l('Include Items?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary"
+          ng-click="ok(args)" value="[% l('Export') %]"/>
+      <button class="btn btn-warning" 
+          ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2
new file mode 100644
index 0000000..877fcf6
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2
@@ -0,0 +1,16 @@
+
+<div ng-show="bucket()">
+  <strong>[% l('Bucket: {{bucket().name()}}') %]</strong> 
+  <span>
+    <ng-pluralize count="bucketSvc.currentBucket.items().length"
+      when="{'one': '[% l("1 item") %]', 'other': '[% l("{} items") %]'}">
+    </ng-pluralize>
+  </span> 
+  <span> / [% l('Created {{bucket().create_time() | date}}') %]</span>
+  <span ng-show="bucket().description()"> / {{bucket().description()}}</span>
+</div>
+
+<div ng-show="!bucket()">
+  <strong>[% l('No Bucket Selected') %]</strong>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2
new file mode 100644
index 0000000..37eef80
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2
@@ -0,0 +1,27 @@
+<div class="btn-group text-left" dropdown>
+  <button type="button" class="btn btn-default dropdown-toggle">
+    [% l('Buckets') %]<span class="caret"></span>
+  </button>
+  <ul class="dropdown-menu">
+    <li>
+      <a href='' ng-click="openCreateBucketDialog()">[% l('New Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openEditBucketDialog()">[% l('Edit Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openDeleteBucketDialog()">[% l('Delete Bucket') %]</a>
+    </li>
+    <li>
+      <a href='' ng-click="openSharedBucketDialog()">[% l('Load Shared Bucket') %]</a>
+    </li>
+    <li role="presentation" class="divider"></li>
+
+    <!-- list all of this user's buckets -->
+    <li ng-repeat="bkt in bucketSvc.allBuckets" 
+      ng-class="{disabled : bkt.id() == bucket().id()}">
+      <a href='' ng-click="loadBucket(bkt.id())">{{bkt.name()}}</a>
+    </li>
+  </ul>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
new file mode 100644
index 0000000..a2e2bde
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
@@ -0,0 +1,20 @@
+
+<!-- global grid menu displayed on every Bucket page -->
+<eg-grid-menu-item label="[% l('New Bucket') %]" 
+  handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Edit Bucket') %]" 
+  handler="openEditBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Delete Bucket') %]" 
+  handler="openDeleteBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Shared Bucket') %]" 
+  handler="openSharedBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item divider="true"></eg-grid-menu-item>
+
+<eg-grid-menu-item ng-repeat="bkt in bucketSvc.allBuckets" 
+  label="{{bkt.name()}}" handler-data="bkt" 
+  handler="loadBucketFromMenu"></eg-grid-menu-item>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2
new file mode 100644
index 0000000..9aab308
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2
@@ -0,0 +1,25 @@
+<!-- load bucket by id ("shared") -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Load Shared Bucket Bucket by ID') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="load-bucket-id">[% l('Bucket ID') %]</label>
+        <!-- NOTE: type='number' / required -->
+        <input type="number" class="form-control" focus-me='focusMe' required
+          id="load-bucket-id" ng-model="args.id" placeholder="[% l('Bucket ID...') %]"/>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Load Bucket') %]"/>
+      <button class="btn btn-warning" 
+          ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2
new file mode 100644
index 0000000..eefc60a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2
@@ -0,0 +1,20 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="-sort,-multisort"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  items-provider="gridDataProvider"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.pending">
+
+  [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Bucket') %]" 
+    handler="addToBucket"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Clear List') %]" 
+    handler="clearPendingList"></eg-grid-action>
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2
new file mode 100644
index 0000000..684b139
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2
@@ -0,0 +1,46 @@
+<br/>
+
+<!-- search bar -->
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="search()">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Record Query') %]</span>
+        <input type="text" class="form-control" focus-me="focusMe"
+        ng-model="bucketSvc.queryString" placeholder="[% l('Query...') %]">
+      </div>
+    </form>
+  </div>
+</div>
+<br/>
+<div class="row" ng-show="searchInProgress">
+  <div class="col-md-6">
+    <div class="progress progress-striped active">
+        <div class="progress-bar"  role="progressbar" aria-valuenow="100" 
+              aria-valuemin="0" aria-valuemax="100" style="width: 100%">
+            <span class="sr-only">[% l('Searching...') %]</span>
+        </div>
+    </div>
+  </div>
+</div>
+
+
+<eg-grid
+  ng-hide="forbidden"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.search">
+
+  [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Pending') %]"
+    handler="addToPending"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Add To Bucket') %]" 
+    handler="addToBucket"></eg-grid-action>
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2
new file mode 100644
index 0000000..39c866f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2
@@ -0,0 +1,28 @@
+<eg-grid
+  ng-hide="forbidden"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.view">
+
+  [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Remove Selected Records') %]" 
+    handler="detachRecords"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Export Records') %]" 
+    handler="openExportBucketDialog"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.id}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/catalog/index.tt2 b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
new file mode 100644
index 0000000..9e799d4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
@@ -0,0 +1,21 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Catalog"); 
+  ctx.page_app = "egCatalogApp";
+%]
+
+[% BLOCK APP_JS %]
+<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/cat/services/record.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/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/catalog/app.js"></script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
new file mode 100644
index 0000000..47f1c6f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
@@ -0,0 +1,48 @@
+
+<div class="row pad-vert">
+  <div class="col-md-9">
+    <div class="alert alert-info alert-less-pad strong-text-2">
+      <span ng-if="record_tab == 'catalog'">[% l('Catalog') %]</span>
+      <span ng-if="record_tab == 'marc_html'">[% l('MARC HTML') %]</span>
+      <span ng-if="record_tab == 'holds'">[% l('Holds for Record') %]</span>
+    </div>
+  </div>
+  <div class="col-md-3">
+    <!-- actions for this record menu -->
+    <div class="btn-group pull-right" dropdown>
+      <button type="button" 
+          class="btn btn-default dropdown-toggle" ng-disabled="!record_id">
+        [% l('Actions for This Record') %] 
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu dropdown-menu-right" role="menu">
+        <li><a href dropdown-toggle ng-click="set_record_tab('catalog')">
+            [% l('OPAC View') %]</a></li>
+        <li><a href dropdown-toggle ng-click="set_record_tab('marc_html')">
+            [% l('MARC View') %]</a></li>
+        <li class="divider"></li>
+        <li><a href dropdown-toggle ng-click="set_record_tab('holds')">
+            [% l('View Holds') %]</a></li>
+        <li><a href dropdown-toggle ng-click="mark_hold_transfer_dest()">
+            [% l('Mark as Title Hold Transfer Destination') %]</a></li>
+        <li><a href dropdown-toggle ng-click="transfer_holds_to_marked()">
+            [% l('Transfer All Title Holds') %]</a></li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+<div>
+  <!-- ng-show allows the catalog iframe to stay loaded (unlike ng-if) -->
+  <div ng-show="record_tab == 'catalog'">
+    <eg-embed-frame url="catalog_url" handlers="handlers" onchange="handle_page"></eg-embed-frame>
+  </div>
+  <!-- ng-if the remaining tabs so they can be instantiated on demand -->
+  <div ng-if="record_tab == 'marc_html'">
+    <eg-record-html record-id="record_id"></eg-record-html>
+  </div>
+  <div ng-if="record_tab == 'holds'">
+    [% INCLUDE 'staff/cat/catalog/t_holds.tt2' %]
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2
new file mode 100644
index 0000000..62af918
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2
@@ -0,0 +1,108 @@
+
+<div ng-if="!detail_hold_id">
+  <div class="row">
+    <div class="col-md-3">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Pickup Library') %]</span>
+        <eg-org-selector selected="pickup_ou" onchange="pickup_ou_changed"></eg-org-selector>
+      </div>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+
+  <eg-grid
+    id-field="id"
+    features="-sort,-multisort"
+    items-provider="hold_grid_data_provider"
+    grid-controls="hold_grid_controls"
+    persist-key="cat.catalog.holds">
+
+    <eg-grid-menu-item handler="detail_view" 
+      label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+    <eg-grid-action handler="grid_actions.show_recent_circs"
+      label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+    <eg-grid-action divider="true"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.set_copy_quality"
+      label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_pickup_lib"
+      label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_notify_prefs"
+      label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_dates"
+      label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.activate"
+      label="[% l('Activate') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.suspend"
+      label="[% l('Suspend') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.set_top_of_queue"
+      label="[% l('Set Top of Queue') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.clear_top_of_queue"
+      label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+      label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.mark_damaged"
+      label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.mark_missing"
+      label="[% l('Mark Item Missing') %]"></eg-grid-action>
+    <eg-grid-action divider="true"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.retarget"
+      label="[% l('Find Another Target') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.cancel_hold"
+      label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+    <eg-grid-field label="[% l('Current Copy') %]" 
+      path='hold.current_copy.barcode'>
+      <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+        {{item.hold.current_copy().barcode()}}
+      </a>
+    </eg-grid-field>
+
+    <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+    <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+        {{item.mvr.title()}}
+      </a>
+    </eg-grid-field>
+
+    <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+    <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+    <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+    <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+    <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+    <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+    <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+    <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+  </eg-grid>
+
+  <div class="flex-row pad-vert">
+    <div class="flex-cell"></div>
+    <div>
+      <button class="btn btn-default" ng-click="print_holds()">
+        [% l('Print') %]
+      </button>
+    </div>
+  </div>
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/item/index.tt2 b/Open-ILS/src/templates/staff/cat/item/index.tt2
new file mode 100644
index 0000000..2232a7d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/index.tt2
@@ -0,0 +1,83 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Item Status"); 
+  ctx.page_app = "egItemStatus";
+  ctx.page_ctrl = "SearchCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/app.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/circ/services/billing.js"></script>
+[% END %]
+
+<style>
+  /* FIXME: MOVE ME */
+  #item-status-barcode {width: 16em;}
+  #item-status-form { 
+    margin-bottom: 20px; 
+  }
+</style>
+
+<h1 class="sr-only">[% l('Item Status Display') %]</h1>
+
+<h2>[% l('Scan Item') %]</h2>
+
+<form id="item-status-form" ng-submit="context.search(args)" role="form">
+  <!-- the upload button drops down to the line below when it sits in the
+    same col-md-x as the text input and submit.  avoid by using a flex-row -->
+  <div class="flex-row">
+    <div class="input-group">
+      <input type="text" id="item-status-barcode" class="form-control"
+        select-me="context.selectBarcode" ng-model="args.barcode">
+      <input class="btn btn-default" 
+        type="submit" value="[% l('Submit') %]"/>
+    </div>
+    <!-- give the upload container div some padding to prevent force the
+        upload widget into the vertical middle of the row -->
+    <div class="btn-pad" style="padding:4px;">
+      <div class="flex-row">
+        <div class="strong-text">[% l('OR') %]</div>
+        <div class="btn-pad">
+          <input type="file" eg-file-reader 
+            container="barcodesFromFile" value="[% l('Upload from File') %]">
+        </div>
+      </div>
+    </div>
+    <div class="flex-cell"></div><!-- force the final divs to the right -->
+    <div>
+      <button class="btn btn-default" ng-click="toggleView($event)">
+        <span ng-show="context.page == 'list'">[% l('Detail View') %]</span>
+        <span ng-show="context.page == 'detail'">[% l('List View') %]</span>
+      </button>
+    </div>
+    <!--
+    <div class="btn-group btn-pad" dropdown>
+      <button type="button" class="btn btn-default dropdown-toggle">
+        [% l('Actions for Catalogers') %]<span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu" role="menu">
+      </ul>
+    </div>
+    -->
+  </div><!-- flex row -->
+</form>
+
+
+<div class="row">
+  <div class="col-md-6">
+    <div ng-show="context.itemNotFound" class="alert alert-danger">
+      [% l('Item Not Found') %]
+    </div>
+  </div>
+</div>
+
+<div ng-view></div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2 b/Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2
new file mode 100644
index 0000000..2581ba7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2
@@ -0,0 +1,69 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Scan Item as Missing Pieces"); 
+  ctx.page_app = "egItemMissingPieces";
+  ctx.page_ctrl = "MissingPiecesCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/missing_pieces.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.CONFIRM_MARK_MISSING_TITLE = "[% l('Mark item as missing pieces?') %]";
+s.CONFIRM_MARK_MISSING_BODY = 
+  "[% l('[_1] / [_2]', '{{barcode}}', '{{title}}') %]";
+s.CIRC_NOT_FOUND = 
+  "[% l('No circulation found for item with barcode [_1].  Item not modified.', '{{barcode}}') %]"
+}])
+</script>
+ 
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Scan Item as Missing Pieces') %]</span>
+  </div>
+</div>
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input 
+      focus-me="selectMe" 
+      select-me="selectMe"
+      class="form-control barcode"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-lookup-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+<hr/>
+
+<div ng-show="letter"> 
+  <div class="row">
+    <div class="col-md-2">
+      <button ng-click="print_letter()" class="btn btn-default">[% l('Print Letter') %]</button>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <textarea ng-model="letter" rows="25" style="width:100%"></textarea>
+    </div>
+  </div>
+</div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2 b/Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2
new file mode 100644
index 0000000..6472a48
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2
@@ -0,0 +1,49 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Replace Item Barcode"); 
+  ctx.page_app = "egItemReplaceBarcode";
+  ctx.page_ctrl = "ReplaceItemBarcodeCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/replace_barcode/app.js"></script>
+[% END %]
+
+<h2>[% l('Replace Item Barcode') %]</h2>
+
+<div class="row">
+  <div class="col-md-6 pad-vert">
+    <form role="form" ng-submit="updateBarcode()">
+      <div class="form-group">
+        <label for="barcode1">[% l('Enter Original Barcode for Item') %]</label>
+        <input type="text" class="form-control" id="barcode1" required
+          ng-model="barcode1"
+          placeholder="[% l('Original Barcode...') %]" select-me="focusBarcode">
+      </div>
+      <div class="form-group">
+        <label for="barcode2">[% l('Enter New Barcode for Item') %]</label>
+        <input type="text" class="form-control" id="barcode2" 
+          ng-model="barcode2"
+          required placeholder="[% l('New Barcode...') %]">
+      </div>
+      <button type="submit" class="btn btn-default">[% l('Submit') %]</button>
+    </form>
+  </div>
+</div>
+
+<div class="row pad-vert">
+  <div class="col-md-6">
+    <div class="alert alert-danger" ng-if="copyNotFound">
+      [% l('Copy Not Found') %]
+    </div>
+    <div class="alert alert-success" ng-if="updateOK">
+      <span>[% l('Copy Updated') %]</span>
+      <span class="horiz-pad" ng-if="copyId">
+        <a href="./cat/item/{{copyId}}/summary" target="_self">
+          [% l('View Item Details') %]
+        </a>
+    </div>
+  </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2
new file mode 100644
index 0000000..43436af
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2
@@ -0,0 +1,3 @@
+<h3>[% l('MARC Record') %]</h3>
+
+<eg-record-html record-id="recordId"></eg-record-html>
diff --git a/Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2
new file mode 100644
index 0000000..9f7bbb5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2
@@ -0,0 +1,51 @@
+<div class="col-md-12" ng-show="!circ_list.length">
+  <div class="alert alert-info">
+    [% l('Item has not circulated.') %]
+  </div>
+</div>
+
+<div class="row" ng-repeat="circ in circ_list">
+  <div class="flex-row">
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{circ.usr().id()}}/checkout" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{circ.usr().family_name()}}'
+          '{{circ.usr().first_given_name()}}'
+          '{{circ.usr().second_given_name()}}'
+          '{{circ.usr().card().barcode()}}') %]
+      </a>
+      <span class="pad-horiz">[% l('Circulation ID: [_1]', '{{circ.id()}}') %]</span>
+    </div>
+    <div>
+      <button class="btn btn-default" ng-click="addBilling(circ)">
+        [% l('Add Billing') %]
+      </button>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Check Out Date') %]</div>
+    <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Due Date') %]</div>
+    <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">{{circ.stop_fines_time() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">{{circ.checkin_time() | date:'short'}}</div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Check Out Library') %]</div>
+    <div class="flex-cell well">{{circ.circ_lib().shortname()}}</div>
+    <div class="flex-cell">[% l('Renewal?') %]</div>
+    <div class="flex-cell well">{{
+      circ.phone_renewal() == 't' ||
+      circ.desk_renewal() == 't' ||
+      circ.opac_renewal() == 't'
+    }}</div>
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">{{circ.stop_fines()}}</div>
+    <div class="flex-cell">[% l('Check In Library') %]</div>
+    <div class="flex-cell well">{{circ.checkin_lib().shortname()}}</div>
+  </div>
+  <hr/>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2
new file mode 100644
index 0000000..24a3241
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2
@@ -0,0 +1,189 @@
+<div class="col-md-6" ng-show="!prev_circ_summary">
+  <div class="alert alert-info">
+    [% l('No Previous Circ Group') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="prev_circ_summary">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Previous Circ Group') %]
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{prev_circ_usr.id()}}/checkout" 
+        ng-if="prev_circ_summary" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{prev_circ_usr.family_name()}}'
+          '{{prev_circ_usr.first_given_name()}}'
+          '{{prev_circ_usr.second_given_name()}}'
+          '{{prev_circ_usr.card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.num_circs()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.start_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.checkout_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Last Renewed On') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_renewal_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_renewal_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_stop_fines()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_stop_fines_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_workstation()}}
+    </div>
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="!circ">
+  <div class="alert alert-info">
+    [% l('No Recent Circ Group') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="circ">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Most Recent Circ Group') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{circ.usr().id()}}/checkout" 
+        ng-if="circ" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{circ.usr().family_name()}}'
+          '{{circ.usr().first_given_name()}}'
+          '{{circ.usr().second_given_name()}}'
+          '{{circ.usr().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.num_circs()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">
+      {{circ.xact_start() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.workstation().name()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Last Renewed On') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.last_renewal_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.last_renewal_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">
+      {{circ.stop_fines()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.stop_fines_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_workstation.name()}}
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2
new file mode 100644
index 0000000..601c128
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2
@@ -0,0 +1,125 @@
+<div class="col-md-6" ng-show="!hold">
+  <div class="alert alert-info">
+    [% l('Item is not captured for a hold') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="hold">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Captured Hold Info') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{hold.usr().id()}}/checkout" 
+        ng-if="hold" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{hold.usr().family_name()}}'
+          '{{hold.usr().first_given_name()}}'
+          '{{hold.usr().second_given_name()}}'
+          '{{hold.usr().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Requestor') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{hold.requestor().id()}}/checkout" 
+        ng-if="hold" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{hold.requestor().family_name()}}'
+          '{{hold.requestor().first_given_name()}}'
+          '{{hold.requestor().second_given_name()}}'
+          '{{hold.requestor().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Pickup Lib') %]</div>
+    <div class="flex-cell well">
+      {{hold.pickup_lib().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+    <div class="flex-cell well">
+      {{hold.current_shelf_lib().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Request Date') %]</div>
+    <div class="flex-cell well">
+      {{hold.request_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Capture Date') %]</div>
+    <div class="flex-cell well">
+      {{hold.capture_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Shelf Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.shelf_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Shelf Expire Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.shelf_expire_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Hold Expire Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.expire_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Behind Desk') %]</div>
+    <div class="flex-cell well">
+      {{hold.behind_desk()}}
+    </div>
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="!transit">
+  <div class="alert alert-info">
+    [% l('Item has not transited') %]
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="transit">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Most Recent Transit') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Source') %]</div>
+    <div class="flex-cell well">
+      {{transit.source().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Destination') %]</div>
+    <div class="flex-cell well">
+      {{transit.dest().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Send Time') %]</div>
+    <div class="flex-cell well">
+      {{transit.source_send_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Receive Time') %]</div>
+    <div class="flex-cell well">
+      {{transit.source_recv_time() | date:'short'}}
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_list.tt2 b/Open-ILS/src/templates/staff/cat/item/t_list.tt2
new file mode 100644
index 0000000..cbd2e76
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_list.tt2
@@ -0,0 +1,21 @@
+<eg-grid
+  id-field="index"
+  idl-class="acp"
+  features="-display,-sort,-multisort"
+  main-label="[% l('Item Status') %]"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="cat.items">
+
+  <eg-grid-field label="[% l('Barcode') %]"     path='barcode' visible></eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path="call_number.label" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Location') %]"    path="location.name" visible></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]"       
+    path="call_number.record.simple_record.title" visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['call_number.record.id']}}">
+      {{item['call_number.record.simple_record.title']}}
+    </a>
+  </eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
new file mode 100644
index 0000000..6918124
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
@@ -0,0 +1,178 @@
+<style>
+/* FIXME: move me */
+#item-status-alert-msg {
+  flex:7; /* fill the remaining horizontal space */
+}
+</style>
+
+<div class="">
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Barcode') %]</div>
+    <div class="flex-cell well">{{copy.barcode()}}</div>
+
+    <div class="flex-cell">[% l('Circ Library') %]</div>
+    <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+    <div class="flex-cell">[% l('Call # Prefix') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().prefix().label()}}
+    </div>
+
+    <div class="flex-cell">[% l('Status') %]</div>
+    <div class="flex-cell well">{{copy.status().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Price') %]</div>
+    <div class="flex-cell well">{{copy.price()}}</div>
+
+    <div class="flex-cell">[% l('Owning Library') %]</div>
+    <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+    <div class="flex-cell">[% l('Call #') %]</div>
+    <div class="flex-cell well">{{copy.call_number().label()}}</div>
+
+    <div class="flex-cell">[% l('Due Date') %]</div>
+    <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('ISBN') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().record().simple_record().isbn()}}
+    </div>
+
+    <div class="flex-cell">[% l('Copy Location') %]</div>
+    <div class="flex-cell well">{{copy.location().name()}}</div>
+
+    <div class="flex-cell">[% l('Call # Suffix') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().suffix().label()}}
+    </div>
+
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Date Created') %]</div>
+    <div class="flex-cell well">{{copy.create_date() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Loan Duration') %]</div>
+    <div class="flex-cell well">{{circ.duration()}}</div>
+
+    <div class="flex-cell">[% l('Renewal Type') %]</div>
+    <div class="flex-cell well">
+      <div ng-if="circ.opac_renewal() == 't'">[% l('OPAC') %]</div>
+      <div ng-if="circ.desk_renewal() == 't'">[% l('Desk') %]</div>
+      <div ng-if="circ.phone_renewal() == 't'">[% l('Phone') %]</div>
+    </div>
+
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">{{circ.workstation().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Date Active') %]</div>
+    <div class="flex-cell well">{{copy.active_date() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Fine Level') %]</div>
+    <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">{{total_circs}}</div>
+
+    <div class="flex-cell">[% l('Duration Rule') %]</div>
+    <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Status Changed') %]</div>
+    <div class="flex-cell well">{{copy.status_changed_time() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Reference') %]</div>
+    <div class="flex-cell well">{{copy.ref()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs - Current Year') %]</div>
+    <div class="flex-cell well">{{total_circs_this_year}}</div>
+
+    <div class="flex-cell">[% l('Recurring Fine Rule') %]</div>
+    <div class="flex-cell well">{{circ.recurring_fine_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Copy ID') %]</div>
+    <div class="flex-cell well">{{copy.id()}}</div>
+
+    <div class="flex-cell">[% l('OPAC Visible') %]</div>
+    <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs - Prev Year') %]</div>
+    <div class="flex-cell well">{{total_circs_prev_year}}</div>
+
+    <div class="flex-cell">[% l('Max Fine Rule') %]</div>
+    <div class="flex-cell well">{{circ.max_fine_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('TCN') %]</div>
+    <div class="flex-cell well">{{copy.call_number().record().tcn_value()}}</div>
+
+    <div class="flex-cell">[% l('Holdable') %]</div>
+    <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">{{circ_summary.last_renewal_workstation()}}</div>
+
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_time() || 
+        circ_summary.last_checkin_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Floating') %]</div>
+    <div class="flex-cell well">{{copy.floating()}}</div>
+
+    <div class="flex-cell">[% l('Circulate') %]</div>
+    <div class="flex-cell well">{{copy.circulate()}}</div>
+
+    <div class="flex-cell">[% l('Remaining Renewals') %]</div>
+    <div class="flex-cell well">{{circ.renewal_remaining()}}</div>
+
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_scan_time() || 
+        circ_summary.last_checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <!-- empty -->
+    <div class="flex-cell"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell">[% l('Circ Modifier') %]</div>
+    <div class="flex-cell well">{{copy.circ_modifier().name()}}</div>
+
+    <!-- empty -->
+    <div class="flex-cell"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_workstation().name() || 
+        circ_summary.last_checkin_workstation().name()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Alert Message') %]</div>
+    <div id="item-status-alert-msg" class="well">
+      {{copy.alert_message()}}
+    </div>
+  </div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2
new file mode 100644
index 0000000..1e32073
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2
@@ -0,0 +1,2 @@
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_view.tt2 b/Open-ILS/src/templates/staff/cat/item/t_view.tt2
new file mode 100644
index 0000000..82920b4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/item/t_view.tt2
@@ -0,0 +1,34 @@
+<eg-record-summary record="summaryRecord"></eg-record-summary>
+
+<!-- tabbed copy data view -->
+
+<div class="pad-vert"></div>
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : tab == 'summary'}">
+    <a href="./cat/item/{{copy.id()}}">[% l('Quick Summary') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'circs'}">
+    <a href="./cat/item/{{copy.id()}}/circs">[% l('Recent Circ History') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'circ_list'}">
+    <a href="./cat/item/{{copy.id()}}/circ_list">[% l('Circ History List') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'holds'}">
+    <a href="./cat/item/{{copy.id()}}/holds">[% l('Holds / Transit') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'cat'}">
+    <a href="./cat/item/{{copy.id()}}/cat">[% l('Cataloging Info') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'triggered_events'}">
+    <a href="./cat/item/{{copy.id()}}/triggered_events">[% l('Triggered Events') %]</a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+    <div ng-if="tab.length">
+      <div ng-include="'[% ctx.base_path %]/staff/cat/item/t_'+tab+'_pane'"></div>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2 b/Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2
new file mode 100644
index 0000000..3de97e5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2
@@ -0,0 +1,53 @@
+<div class="strong-text-2">[% l('Record Summary') %]</div>
+
+<div class="flex-container-striped flex-container-bordered">
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Title:') %]</div>
+    <div class="flex-cell flex-2">
+      <a target="_self" 
+        href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.id()}}">
+        {{record.simple_record().title()}}
+      </a>
+    </div>
+
+    <div class="flex-cell strong-text">[% l('Edition:') %]</div>
+    <div class="flex-cell"><!-- FIXME: no edition field on simple record --></div>
+
+    <div class="flex-cell strong-text">[% l('TCN:') %]</div>
+    <div class="flex-cell">{{record.tcn_value()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Created By:') %]</div>
+    <div class="flex-cell">{{record.creator().usrname()}}</div>
+  </div><!-- flex-row -->
+
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Author:') %]</div>
+    <div class="flex-cell flex-2">{{record.simple_record().author()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Pub Date:') %]</div>
+    <div class="flex-cell">
+      {{record.simple_record().pubdate()}}
+    </div>
+
+    <div class="flex-cell strong-text">[% l('Databse ID:') %]</div>
+    <div class="flex-cell">{{record.id()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Last Edited By:') %]</div>
+    <div class="flex-cell">{{record.editor().usrname()}}</div>
+  </div><!-- flex-row -->
+
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Bib Call #:') %]</div>
+    <div class="flex-cell flex-2"><!-- FIXME: no bib call no on simple rec --></div>
+
+    <div class="flex-cell strong-text"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell strong-text">[% l('Record Owner:') %]</div>
+    <div class="flex-cell">{{record.owner().shortname()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Last Edited On:') %]</div>
+    <div class="flex-cell">{{record.edit_date() | date:'short'}}</div>
+  </div><!-- flex-row -->
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2 b/Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2
new file mode 100644
index 0000000..1e32073
--- /dev/null
+++ b/Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2
@@ -0,0 +1,2 @@
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/checkin/index.tt2 b/Open-ILS/src/templates/staff/circ/checkin/index.tt2
new file mode 100644
index 0000000..dd2c0cf
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/checkin/index.tt2
@@ -0,0 +1,19 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Check In"); 
+  ctx.page_app = "egCheckinApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<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/circ/checkin/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2 b/Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2
new file mode 100644
index 0000000..83b88f5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2
@@ -0,0 +1,225 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span ng-if="!is_capture">[% l('Checkin Items') %]</span>
+    <span ng-if="is_capture">[% l('Capture Holds') %]</span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+    <div class="flex-row left-anchored">
+      <div ng-if="is_backdate()" class="alert-danger pad-all-min">
+        [% l('Backdated Check In [_1]', 
+          '{{checkinArgs.backdate | date:"shortDate"}}') %]
+      </div>
+      <div ng-if="modifiers.no_precat_alert" class="alert-danger pad-all-min">
+        [% l('Ignore Pre-Cataloged Items') %]
+      </div>
+      <div ng-if="modifiers.noop" class="alert-danger pad-all-min">
+        [% l('Suppress Holds and Transits') %]
+      </div>
+      <div ng-if="modifiers.void_overdues" class="alert-danger pad-all-min">
+        [% l('Amnesty Mode') %]
+      </div>
+      <div ng-if="modifiers.auto_print_holds_transits" 
+        class="alert-danger pad-all-min">
+        [% l('Auto-Print Hold and Transit Slips') %]
+      </div>
+      <div ng-if="modifiers.clear_expired" class="alert-danger pad-all-min">
+        [% l('Clear Holds Shelf') %]
+      </div>
+      <div ng-if="modifiers.retarget_holds" class="alert-danger pad-all-min">
+        <div ng-if="modifiers.retarget_holds_all">
+          [% l('Always Retarget Local Holds') %]
+        </div>
+        <div ng-if="!modifiers.retarget_holds_all">
+          [% l('Retarget Local Holds') %]
+        </div>
+      </div>
+      <div ng-if="modifiers.hold_as_transit" class="alert-danger pad-all-min">
+        [% l('Capture Local Holds As Transits') %]
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- checkin form -->
+<div class="row pad-vert">
+  <div class="col-md-4">
+    <form ng-submit="checkin(checkinArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <label class="input-group-addon" 
+          for="patron-checkin-barcode" >[% l('Barcode') %]</label>
+
+        <input focus-me="focusMe" blur-me="blurMe" 
+          class="form-control"
+          ng-model="checkinArgs.copy_barcode" 
+          placeholder="[% l('Barcode') %]"
+          id="patron-checkin-barcode" type="text"/> 
+
+        <input type="submit" class="btn btn-default" value="[% l('Submit') %]"/>
+      </div>
+    </form>
+  </div>
+
+  <div class="col-md-4">
+    <div ng-if="alert" class="col-md-12 alert-danger pad-all-min">
+      <span ng-if="alert.already_checked_in">
+        [% l('[_1] was already checked in.', '{{alert.already_checked_in}}') %]
+      </span>
+      <span ng-if="alert.item_never_circed">
+        [% l('Item [_1] has never circulated.', '{{alert.item_never_circed}}') %]
+      </span>
+    </div>
+  </div>
+
+  <div class="col-md-4" ng-if="!is_capture">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="pad-horiz">[% l('Effective Date') %]</div>
+      <!-- date max= not yet supported -->
+      <div><input eg-date-input
+        class="form-control" ng-model="checkinArgs.backdate"/>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="row" ng-if="fine_total">
+  <div class="col-md-12">
+    <span>[% l('Fine Tally:') %]</span>
+    <span class="pad-horiz alert alert-danger">{{fine_total | currency}}</span>
+    <span ng-if="billable_barcode">
+      <span>[% l('Transaction for [_1] billed:', '{{billable_barcode}}') %]</span>
+      <span class="pad-horiz alert alert-danger">{{billable_amount | currency}}</span>
+    </span>
+  </div>
+</div>
+
+<hr/>
+
+[% INCLUDE 'staff/circ/checkin/t_checkin_table.tt2' %]
+
+<div class="row pad-vert">
+  <div class="col-md-10">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="pad-horiz">
+        <button class="btn btn-default" 
+          ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+      </div>
+      <div class="checkbox" ng-if="using_hatch">
+        <label>
+          <input ng-model="show_print_dialog" type="checkbox"/>
+          [% l('Show Print Dialog') %]
+        </label>
+      </div>
+      <div class="pad-horiz" ng-if="using_hatch"></div>
+      <div class="checkbox">
+        <label>
+          <input ng-model="trim_list" type="checkbox"/>
+          [% l('Trim List (20 Rows)') %]
+        </label>
+      </div>
+      <div class="pad-horiz"></div>
+      <div class="checkbox">
+        <label>
+          <input ng-model="strict_barcode" type="checkbox"/>
+          [% l('Strict Barcode') %]
+        </label>
+      </div>
+    </div><!-- flex row -->
+  </div><!-- col -->
+  <div class="col-md-2">
+    <div class="input-group-btn" dropdown>
+      <button type="button" class="btn btn-default dropdown-toggle">
+        [% l('Checkin Modifiers') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu pull-right">
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('no_precat_alert')">
+            <span ng-if="modifiers.no_precat_alert" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.no_precat_alert"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Ignore Pre-cataloged Items') %]</span>
+          </a>
+        </li>
+        <li ng-if="!is_capture"><!-- nonsensical for hold capture -->
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('noop')">
+            <span ng-if="modifiers.noop" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.noop"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Suppress Holds and Transits') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('void_overdues')">
+            <span ng-if="modifiers.void_overdues" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.void_overdues"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Amnesty Mode') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('auto_print_holds_transits')">
+            <span ng-if="modifiers.auto_print_holds_transits" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.auto_print_holds_transits"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Auto-Print Hold and Transit Slips') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('clear_expired')">
+            <span ng-if="modifiers.clear_expired" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.clear_expired"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Clear Holds Shelf') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('retarget_holds')">
+            <span ng-if="modifiers.retarget_holds" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.retarget_holds"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Retarget Local Holds') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('retarget_holds_all')">
+            <span ng-if="modifiers.retarget_holds_all" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.retarget_holds_all"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Retarget All Statuses') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('hold_as_transit')">
+            <span ng-if="modifiers.hold_as_transit" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.hold_as_transit"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Capture Local Holds As Transits') %]</span>
+          </a>
+        </li>
+      </ul>
+    </div><!-- btn grp -->
+  </div><!-- col -->
+</div><!-- row -->
+
diff --git a/Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2 b/Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
new file mode 100644
index 0000000..1ee0c09
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
@@ -0,0 +1,89 @@
+<!-- checkins list -->
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  main-label="[% l('Items Checked In') %]"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="{{grid_persist_key}}">
+
+  <eg-grid-action 
+    handler="fetchLastCircPatron"
+    label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showBackdateDialog"
+    label="[% l('Backdate Post-Checkin') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showMarkDamaged"
+    label="[% l('Mark Items Damaged') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="abortTransit"
+    label="[% l('Abort Transits') %]">
+  </eg-grid-action>
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Checkin Date') %]"    
+    path='circ.checkin_time' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Finish') %]"    
+    path='circ.stop_fines_time'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]"    
+    path='acp.location.name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Route To') %]" path='route_to'>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Start') %]"    
+    path='circ.xact_start'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+  <eg-grid-field path="transit.*" parent-idl-class="atc" hidden></eg-grid-field>
+  <eg-grid-field path="hold.*" parent-idl-class="ahr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/holds/index.tt2 b/Open-ILS/src/templates/staff/circ/holds/index.tt2
new file mode 100644
index 0000000..b327738
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/holds/index.tt2
@@ -0,0 +1,30 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Holds Shelf"); 
+  ctx.page_app = "egHoldsApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<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/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/circ/holds/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.CLEAR_SHELF_ACTION_shelf = "[% l('Reshelve') %]";
+  s.CLEAR_SHELF_ACTION_hold = "[% l('Needed for Hold') %]";
+  s.CLEAR_SHELF_ACTION_transit = "[% l('Needs Transiting') %]";
+  s.CLEAR_SHELF_ACTION_pl_changed = "[% l('Wrong Shelf') %]";
+}])
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_pull.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_pull.tt2
new file mode 100644
index 0000000..de2988b
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/holds/t_pull.tt2
@@ -0,0 +1,26 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Holds Pull List') %]</span>
+  </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_pull_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2
new file mode 100644
index 0000000..8cea1d1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2
@@ -0,0 +1,88 @@
+<div ng-if="print_list_progress !== null" class="strong-text-2">
+  [% l('Loading... [_1]', '{{print_list_progress}}') %]
+</div>
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.holds.pull">
+
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="print_full_list" 
+    label="[% l('Print Full List') %]"></eg-grid-menu-item>
+
+  <!--
+  The Alternate print UI appears to be generated in a very similar
+  fashion to our native full list printer.  Also, since it's 
+  generated from a separate standalone HTML page, the print
+  action bypasses Hatch and goes straight to the browser printer.
+  <eg-grid-menu-item handler="print_list_alt" 
+    label="[% l('Print Full List (Alt)') %]"></eg-grid-menu-item>
+  -->
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Part') %]" path='part.label'></eg-grid-field>
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Copy Location') %]" path='copy.location.name'></eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path='volume.label'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2
new file mode 100644
index 0000000..b0a4aaf
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2
@@ -0,0 +1,40 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Holds Shelf') %]</span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-3">
+    <div class="input-group">
+      <span class="input-group-addon">[% l('Pickup Library') %]</span>
+      <eg-org-selector selected="pickup_ou" ng-disabled="is_clearing()"></eg-org-selector>
+    </div>
+  </div>
+  <div class="col-md-3" ng-show="is_clearing()">
+    <progressbar max="clear_progress.max" value="clear_progress.value">
+      <span class="progressbar-text">{{clear_progress.value}} / {{clear_progress.max}}</span>
+    </progressbar>
+  </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_shelf_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2
new file mode 100644
index 0000000..3700df9
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2
@@ -0,0 +1,85 @@
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.holds.shelf">
+
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="show_clearable" 
+    hidden="clear_mode" disabled="is_clearing"
+    label="[% l('Show Clearable Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="show_active" 
+    hidden="active_mode" disabled="is_clearing"
+    label="[% l('Show All Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="clear_holds" disabled="disable_clear"
+    label="[% l('Clear These Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Post-Clear') %]" path='post_clear'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/in_house_use/index.tt2 b/Open-ILS/src/templates/staff/circ/in_house_use/index.tt2
new file mode 100644
index 0000000..2299603
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/in_house_use/index.tt2
@@ -0,0 +1,81 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("In-House Use"); 
+  ctx.page_app = "egInHouseUseApp";
+  ctx.page_ctrl = "InHouseUseCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/in_house_use/app.js"></script>
+[% END %]
+
+<style>
+  /* FIXME: MOVE ME */
+  #in-house-use-barcode {width: 16em;}
+  #in-house-use-form { margin-bottom: 20px }
+</style>
+
+<form id="in-house-use-form" ng-submit="checkout(args)" role="form">
+  <div class="row">
+
+    <div class="col-md-2">
+      <div class="input-group">
+        <label class="input-group-addon" for="in-house-num-uses">
+          [% l('# of Uses:') %]
+        </label>
+        <input type="number" min="1" max="{{countMax}}"
+          class="form-control" focus-me="useFocus"
+           id="in-house-num-uses" ng-model="args.num_uses"/>
+      </div>
+    </div>
+
+    <div class="col-md-6">
+      <div class="input-group">
+        <div class="input-group-btn" dropdown>
+          <button type="button" class="btn btn-default dropdown-toggle">
+            {{selectedNcType() || "[% l('Barcode') %]"}}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu">
+            <li><a href dropdown-toggle
+              ng-click="args.noncat_type='barcode';bcFocus=true">
+              [% l('Barcode') %]</a>
+            </li>
+            <li class="divider"></li>
+            <li><a href dropdown-toggle
+              ng-repeat='type in nonCatTypes'
+              ng-click="args.noncat_type=type.id()">{{type.name()}}</a>
+            </li>
+          </ul>
+        </div>
+
+        <input type="text" id="in-house-use-barcode" focus-me="bcFocus"
+          class="form-control" ng-model="args.barcode"
+          ng-disabled="args.noncat_type != 'barcode'"/>
+        <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+      </div><!-- input group -->
+    </div><!-- col -->
+  </div><!-- row -->
+</form>
+
+<div clas="row" ng-if="copyNotFound">
+  <div class="col-md-6 alert alert-danger">[% l('Copy Not Found') %]</div>
+</div>
+
+<eg-grid
+  id-field="index"
+  features="-display,-sort,-multisort"
+  main-label="[% l('In-House Use') %]"
+  items-provider="gridDataProvider"
+  persist-key="circ.in_house_use">
+  <eg-grid-field label="[% l('# of Uses') %]"   path='num_uses' visible></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"     path='copy.barcode' visible></eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path="copy.call_number.label" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Location') %]"    path="copy.location.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Title') %]"       path="title" visible></eg-grid-field>
+</eg-grid>
+ 
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2
new file mode 100644
index 0000000..9048faa
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2
@@ -0,0 +1,175 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Patron"); 
+  ctx.page_app = "egPatronApp";
+  ctx.page_ctrl = "PatronCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.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/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/circ/patron/app.js"></script>
+
+<!-- load the rest on demand? -->
+
+<!-- required for credentials verify API -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/checkout.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/items_out.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/holds.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/bills.js"></script>
+
+<!-- TODO: APP_JS should really be called APP_ADDONS or some such.
+    It just means "load these things, too, and load them last" -->
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.ANNOTATE_PAYMENT_MSG = "[% l('Please annotate this payment') %]";
+  s.CONFIRM_REFUND_PAYMENT = 
+    "[% |l('{{xactIds}}') -%]Are you sure you would like to refund excess payment on bills [_1]?  This action will simply put the amount in the Payment Pending column as a negative value.  You must still select Apply Payment!  Certain types of payments may not be refunded.  The refund may be applied to checked transactions that follow the refunded transaction.[% END %]";
+  s.EDIT_BILL_PAY_NOTE = "[% l('Enter new note for #[_1]:','{{ids}}') %]";
+  s.GROUP_ADD_USER = "[% l('Enter the patron barcode') %]";
+  s.RENEW_ITEMS = "[% l('Renew Items?') %]";
+  s.RENEW_ALL_ITEMS = "[% l('Renew All Items?') %]";
+  s.CHECK_IN_CONFIRM = "[% l('Check In Items?') %]";
+}]);
+</script>
+
+[% END %]
+
+<div class="row">
+  <div class="col-md-3">
+    <div ng-show="patron()">
+      <h4 title="{{patron().id()}}">
+        <div class="flex-row">
+          <div class="flex-cell">
+            [% l('[_1], [_2] [_3]', 
+                '{{patron().family_name()}}',
+                '{{patron().first_given_name()}}',
+                '{{patron().second_given_name()}}') %]
+          </div>
+          <div ng-show="tab != 'search'">
+            <a href ng-click="toggle_expand_summary()"
+              title="[% l('Collapse Patron Summary Display') %]"
+              ng-hide="collapse_summary()">
+              <span class="glyphicon glyphicon-resize-small"></span>
+            </a>
+            <a href ng-click="toggle_expand_summary()"
+              title="[% l('Expand Patron Summary Display') %]"
+              ng-show="collapse_summary()">
+              <span class="glyphicon glyphicon-resize-full"></span>
+            </a>
+          </div>
+        </div><!-- row -->
+      </h4>
+    </div><!-- if patron -->
+  </div><!-- col -->
+  <div class="col-md-9">
+    <ul class="nav nav-pills nav-pills-like-tabs">
+      <li ng-class="{active : tab == 'checkout', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/checkout">[% l('Check Out') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'items_out', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/items_out">
+          [% l('Items Out') %] 
+          <span ng-if="patron()"><!-- lack of space / newline below intentional -->
+          (<span ng-class="{'patron-summary-alert-small' : patron_stats().checkouts.overdue}">{{patron_stats().checkouts.total_out}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'holds', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/holds">
+          [% l('Holds') %]
+          <span ng-if="patron()">
+            (<span>{{patron_stats().holds.total}} / {{patron_stats().holds.ready}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'bills', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/bills">
+          [% l('Bills') %]
+          <span ng-if="patron()">
+            (<span ng-class="{'patron-summary-alert-small' : patron_stats().fines.balance_owed}">{{patron_stats().fines.balance_owed | currency}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'messages', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/messages">[% l('Messages') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'edit', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/edit">[% l('Edit') %]</a>
+      </li>
+      <li class="dropdown" ng-class="{active : tab == 'other', disabled : !patron()}">
+        <a href class="dropdown-toggle" data-toggle="dropdown">
+            [% l('Other') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/alerts">
+              [% l('Display Alert and Messages') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/notes">
+              [% l('Notes') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/triggered_events">
+              [% l('Triggered Events / Notifications') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/stat_cats">
+              [% l('Statistical Categories') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/group">
+              [% l('Group Member Details') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/edit_perms">
+              [% l('User Permission Editor') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/credentials">
+              [% l('Test Password') %]
+            </a>
+          </li>
+       </ul>
+      </li>
+      <li ng-class="{active : tab == 'search'}" class="pull-right">
+        <a href="./circ/patron/search">[% l('Patron Search') %]</a>
+      </li>
+    </ul>
+  </div><!-- col -->
+</div><!-- row -->
+
+<div class="row">
+  <div class="col-md-3" ng-hide="collapse_summary()">
+    [% INCLUDE 'staff/circ/patron/t_summary.tt2' %]
+  </div>
+  <div ng-class="{'col-md-12' : collapse_summary(),'col-md-9' : !collapse_summary()}">
+    <div class="tab-content">
+      <div class="tab-pane active">
+        <div ng-view></div>
+      </div>
+    </div>
+  </div><!-- col -->
+</div><!-- row -->
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/pending.tt2 b/Open-ILS/src/templates/staff/circ/patron/pending.tt2
new file mode 100644
index 0000000..21c21cb
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/pending.tt2
@@ -0,0 +1,17 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Pending Patrons"); 
+  ctx.page_app = "egPendingPatronsApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/pending.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/register.tt2 b/Open-ILS/src/templates/staff/circ/patron/register.tt2
new file mode 100644
index 0000000..4a3c9ce
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/register.tt2
@@ -0,0 +1,15 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Patron Registration"); 
+  ctx.page_app = "egPatronRegApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/register.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2
new file mode 100644
index 0000000..822240c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2
@@ -0,0 +1,75 @@
+<div>
+
+  <!-- FIXME: move image file -->
+  <img src='/xul/server/skin/media/images/stop_sign.png'>
+
+  <div class="alert alert-info" ng-if="patron_stats().holds.ready > 0">
+    [% l('Holds available: [_1]', '{{patron_stats().holds.ready}}') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patronExpired">
+    [% l('Patron account is EXPIRED.') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patronExpiresSoon">
+    [% l('Patron account will expire soon.  Please renew.') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patron().barred() == 't'">
+    [% l('Patron account is BARRED') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patron().active() == 'f'">
+    [% l('Patron account is INACTIVE') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="retrievedWithInactive">
+    [% l('Patron account retrieved with an INACTIVE card.') %]
+  </div>
+
+  <!-- alert message -->
+  <div class="row" ng-if="patron().alert_message()">
+    <div class="col-md-12">
+      <div class="panel panel-warning">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Alert Message') %]</div>
+        </div>
+        <div class="panel-body">
+          {{patron().alert_message()}}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- penalties -->
+  <div class="row" ng-if="alert_penalties().length">
+    <div class="col-md-12">
+      <div class="panel panel-warning">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Penalties') %]</div>
+        </div>
+        <div class="panel-body">
+          <div class="row" 
+            ng-repeat="penalty in alert_penalties()">
+            <div class="col-md-2">
+              {{penalty.org_unit().shortname()}}
+            </div>
+            <div class="col-md-8"
+              title="{{penalty.standing_penalty().name()}}">
+              {{penalty.standing_penalty().label()}}
+              <div>{{penalty.note()}}</div><!-- force newline -->
+            </div>
+            <div class="col-md-2">
+              {{penalty.set_date() | date:'shortDate'}}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <br/>
+  <div class="well">
+[% l('Press a navigation button above (for example, Check Out) to clear this alert.') %]
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2
new file mode 100644
index 0000000..216b186
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2
@@ -0,0 +1,22 @@
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-checkout-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input select-me="selectMe" class="form-control"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-checkout-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2
new file mode 100644
index 0000000..6739d7e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2
@@ -0,0 +1,34 @@
+<h2>[% l('Bill History') %]</h2>
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : bill_tab == 'transactions'}">
+    <a href="./circ/patron/{{patron().id()}}/bill_history/transactions">
+        [% l('Transactions') %]
+    </a>
+  </li>
+  <li ng-class="{active : bill_tab == 'payments'}">
+    <a href="./circ/patron/{{patron().id()}}/bill_history/payments">
+        [% l('Payments') %]
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <div class="flex-row padded">
+      <div ng-if="bill_tab == 'transactions'">[% l('Selected Billed:') %]</div>
+      <div ng-if="bill_tab == 'transactions'">{{totals.selected_billed() | currency}}</div>
+      <div>[% l('Selected Paid:') %]</div>
+      <div>{{totals.selected_paid() | currency}}</div>
+      <div class="flex-cell"></div>
+      <div>[% l('Start Date:') %]</div>
+      <div><input eg-date-input class="form-control" ng-model="dates.xact_start"/></div>
+      <div>[% l('End Date:') %]</div>
+      <div><input eg-date-input class="form-control" ng-model="dates.xact_finish"/></div>
+    </div><!-- top row -->
+    <hr/>
+    [% INCLUDE 'staff/circ/patron/t_bill_history_xacts.tt2' %]
+    [% INCLUDE 'staff/circ/patron/t_bill_history_payments.tt2' %]
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2
new file mode 100644
index 0000000..c4fa9d3
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2
@@ -0,0 +1,79 @@
+
+<div ng-if="bill_tab == 'payments'" ng-controller="BillPaymentHistoryCtrl">
+
+  <eg-grid
+    idl-class="mp"
+    id-field="id"
+    grid-controls="gridControls">
+
+    <eg-grid-action 
+      label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+    <eg-grid-field path="amount" label="[% l('Amount') %]"></eg-grid-field>
+    <eg-grid-field path="id" label="[% l('Payment ID') %]"></eg-grid-field>
+    <eg-grid-field path="payment_ts" label="[% l('Payment Time') %]"></eg-grid-field>
+    <eg-grid-field path="note" label="[% l('Note') %]"></eg-grid-field>
+    <eg-grid-field path="voided" label="[% l('Voided') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.xact_type" label="[% l('Transaction Type') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.last_billing_type" label="[% l('Last Billing Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="xact.circulation.target_copy.call_number.record.simple_record.title">
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <!-- needed for bib link -->
+    <eg-grid-field name="record_id" 
+      path="xact.circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="xact.circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <!-- needed for item link -->
+    <eg-grid-field name="copy_id" 
+      path="xact.circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <!-- ... -->
+
+    <eg-grid-field path="xact.id" required hidden></eg-grid-field>
+    <eg-grid-field path="xact.usr" required hidden></eg-grid-field>
+    <eg-grid-field path="xact.*" hidden></eg-grid-field>
+    <eg-grid-field path="xact.summary.*" hidden></eg-grid-field>
+
+    <!--
+    <eg-grid-field path="xact.summary.balance_owed"></eg-grid-field>
+    <eg-grid-field path="xact.xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+    <eg-grid-field path="xact.xact_start" label="[% l('Start') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="circulation.target_copy.call_number.record.simple_record.title">
+      <a href="[% ctx.base_path %]/opac/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <eg-grid-field name="record_id" 
+      path="circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <eg-grid-field name="copy_id" 
+      path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+    -->
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2
new file mode 100644
index 0000000..70f48b6
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2
@@ -0,0 +1,49 @@
+
+<div ng-if="bill_tab == 'transactions'" ng-controller="BillXactHistoryCtrl">
+
+  <eg-grid
+    idl-class="mbt"
+    id-field="id"
+    grid-controls="gridControls">
+
+    <eg-grid-action 
+      label="[% l('Add Billing') %]" handler="addBilling"></eg-grid-action>
+    <eg-grid-action 
+      label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+    <eg-grid-field path="summary.balance_owed"></eg-grid-field>
+    <eg-grid-field path="id" label="[% l('Bill #') %]"></eg-grid-field>
+    <eg-grid-field path="xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+    <eg-grid-field path="xact_start" label="[% l('Start') %]"></eg-grid-field>
+    <eg-grid-field path="summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+    <eg-grid-field path="summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+    <eg-grid-field path="summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="circulation.target_copy.call_number.record.simple_record.title">
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <!-- needed for bib link -->
+    <eg-grid-field name="record_id" 
+      path="circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <!-- needed for item link -->
+    <eg-grid-field name="copy_id" 
+      path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <!-- needed for grid query -->
+    <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
new file mode 100644
index 0000000..b8ab917
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
@@ -0,0 +1,109 @@
+
+<div class="row">
+  <div class="col-md-7">
+
+    <div class="row">
+      <div class="col-md-4">[% l('Total Owed:') %]</div>
+      <div class="col-md-2 strong-text">{{summary.balance_owed() | currency}}</div>
+      <div class="col-md-4">[% l('Refunds Available:') %]</div>
+      <div class="col-md-2">{{refunds_available() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Total Billed:') %]</div>
+      <div class="col-md-2">{{summary.total_owed() | currency}}</div>
+      <div class="col-md-4">[% l('Credit Available:') %]</div>
+      <div class="col-md-2">{{patron().credit_forward_balance() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Total Paid:') %]</div>
+      <div class="col-md-2">{{summary.total_paid() | currency}}</div>
+      <div class="col-md-4">[% l('Session Voided:') %]</div>
+      <div class="col-md-2">{{session_voided | currency}}</div>
+    </div>
+    <div class="row"><hr/></div>
+    <div class="row">
+      <div class="col-md-4">[% l('Owed for Selected:') %]</div>
+      <div class="col-md-2">{{owed_selected() | currency}}</div>
+      <div class="col-md-4">[% l('Pending Payment:') %]</div>
+      <div class="col-md-2 strong-text">{{pending_payment() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Billed for Selected:') %]</div>
+      <div class="col-md-2">{{billed_selected() | currency}}</div>
+      <div class="col-md-4">[% l('Pending Change:') %]</div>
+      <div class="col-md-2 strong-text">{{pending_change() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Paid for Selected:') %]</div>
+      <div class="col-md-2">{{paid_selected() | currency}}</div>
+    </div>
+  </div><!-- col -->
+
+  <div class="col-md-5">
+    <form role="form" class="form-horizontal" ng-submit="applyPayment()">
+      <fieldset>
+        <legend>[% l('Pay Bill') %]</legend>
+
+        <div class="form-group">
+          <label for="type-input" class="col-md-6 control-label">[% l('Payment Type') %]</label>
+          <div class="col-md-6">
+            <select ng-model="payment_type" class="form-control">
+              <option value="cash_payment" selected="selected">[% l('Cash') %]</option>
+              <option value="check_payment">[% l('Check') %]</option>
+              <option value="credit_card_payment">[% l('Credit Card') %]</option>
+              <option value="credit_payment">[% l('Patron Credit') %]</option>
+              <option value="work_payment">[% l('Work') %]</option>
+              <option value="forgive_payment">[% l('Forgive') %]</option>
+              <option value="goods_payment">[% l('Goods') %]</option>
+            </select>
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="amount-input" class="col-md-6 control-label">
+            [% l('Payment Received') %]
+          </label>
+          <div class="col-md-6">
+            <input type="number" min="0" step="any" id="amount-input" 
+              ng-model="payment_amount" focus-me="focus_payment" 
+              value="" class="form-control col-md-6 "/>
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="annotate-payment" class="control-label col-md-5">[% l('Annotate') %]</label>
+          <div class="col-md-1">
+            <input id="annotate-payment" type="checkbox" ng-model="annotate_payment"/>
+          </div>
+          <div class="col-md-6">
+            <button type="submit" class="btn btn-default">[% l('Apply Payment') %]</button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  </div>
+</div>
+
+<div class="pad-vert">
+[% INCLUDE 'staff/circ/patron/t_bills_list.tt2' %]
+</div>
+
+<!-- pull-right is causing the content to flow several pixels 
+off to the right.  flex-row is honoring the boundaries better. 
+not sure what's up, there. -->
+<div class="flex-row">
+  <div class="flex-cell"></div>
+  <form class="form-inline" role="form">
+   <div class="checkbox">
+      <label>
+        <input type="checkbox" ng-model="receipt_on_pay"/> 
+        [% l('Receipt On Payment') %]
+      </label>
+    </div>
+    <div class="form-group" style="margin-left:10px">
+      <label for="bill-receipt-copies">[% l('# Copies') %]</label>
+      <input type="number" min="1" style="width:5em"
+        ng-model="receipt_count"
+        class="form-control" id="bill-receipt-copies"/>
+    </div>
+  </form>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
new file mode 100644
index 0000000..c98d041
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
@@ -0,0 +1,87 @@
+
+<eg-grid
+  idl-class="mbt"
+  query="gridQuery"
+  sort="gridSort"
+  grid-controls="gridControls"
+  revision="gridRevision"
+  persist-key="circ.patron.bills">
+
+  <eg-grid-menu-item label="[% l('Bill Patron') %]" 
+    handler="showBillDialog"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('History') %]" 
+    handler="showHistory"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Check All Refunds') %]" 
+    handler="selectRefunds"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Print Bills') %]" 
+    handler="printBills"></eg-grid-action>
+
+  <!--
+  need to decide if these are necessary here w/ inline links
+  to record and copy details (though they could be hidden).
+  it's misleading to allow the user to select multiple bills
+  but only open the link to one
+
+  <eg-grid-action label="[% l('Show in Catalog') %]" 
+    handler=""></eg-grid-action>
+
+  <eg-grid-action label="[% l('Show Item Details') %]" 
+    handler=""></eg-grid-action>
+  -->
+
+  <eg-grid-action label="[% l('Void All Billings') %]" 
+    handler="voidAllBillings"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Refund') %]" 
+    handler="refundXact"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Add Billing') %]" 
+    handler="addBilling"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Full Details') %]" 
+    handler="showFullDetails"></eg-grid-action>
+
+  <eg-grid-field label="[% ('Balance Owed') %]" path='summary.balance_owed'></eg-grid-field>
+  <eg-grid-field label="[% ('Bill #') %]" path='id'></eg-grid-field>
+  <eg-grid-field label="[% ('Start') %]" path='xact_start'></eg-grid-field>
+  <eg-grid-field label="[% ('Total Billed') %]" path='summary.total_owed'></eg-grid-field>
+  <eg-grid-field label="[% ('Total Paid') %]" path='summary.total_paid'></eg-grid-field>
+  <eg-grid-field label="[% ('Type') %]" path='xact_type'></eg-grid-field>
+
+  <!-- receipt data -->
+  <eg-grid-field path='summary.last_billing_type' required></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" name="title"
+    path='circulation.target_copy.call_number.record.simple_record.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+  </eg-grid-field>
+  <!-- fetch the record ID so we can link to it.  hide it by default -->
+  <eg-grid-field path="circulation.target_copy.call_number.record.id" 
+    label="[% l('Record ID') %]" name="record_id" required hidden>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" required
+    path='circulation.target_copy.barcode' name="copy_barcode">
+    <a href="./cat/item/{{item.copy_id}}" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+  <!-- fetch the copy ID so we can link to it.  hide it by default -->
+  <eg-grid-field path="circulation.target_copy.id" 
+    label="[% l('Copy ID') %]" name="copy_id" required hidden>
+  </eg-grid-field>
+
+  <!-- virtual field -->
+  <eg-grid-field datatype="money" label="[% ('Payment Pending') %]" 
+    name="payment_pending"></eg-grid-field>
+
+  <!-- import all circ fields, hidden by default -->
+  <eg-grid-field path='circulation.*' hidden> </eg-grid-field>
+
+  <eg-grid-field path='circulation.target_copy.*' hidden> </eg-grid-field>
+  
+</eg-grid>
+ 
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
new file mode 100644
index 0000000..945f2db
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
@@ -0,0 +1,143 @@
+<!-- item checkout form / list -->
+
+<div class="row pad-vert">
+  <div class="col-md-6">
+    <form ng-submit="checkout(checkoutArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <div class="input-group-btn" dropdown>
+          <button type="button" class="btn btn-default dropdown-toggle"
+            ng-class="{disabled : disable_checkout()}">
+            {{selectedNcType() || "[% l('Barcode') %]"}}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu">
+            <li><a href dropdown-toggle
+              ng-click="checkoutArgs.noncat_type='barcode';focusMe=true">
+              [% l('Barcode') %]</a>
+            </li>
+            <li class="divider"></li>
+            <li>
+              <a href ng-repeat='type in nonCatTypes' dropdown-toggle
+               ng-click="checkoutArgs.noncat_type=type.id()">{{type.name()}}</a>
+            </li>
+          </ul>
+        </div>
+
+        <input focus-me="focusMe" class="form-control"
+          ng-model="checkoutArgs.copy_barcode" 
+          ng-disabled="checkoutArgs.noncat_type != 'barcode' || disable_checkout()"
+          id="patron-checkout-barcode" type="text"/> 
+
+        <input class="btn btn-default" type="submit" 
+          ng-class="{disabled : disable_checkout()}" value="[% l('Submit') %]"/>
+
+      </div>
+    </form>
+  </div>
+  <div class="col-md-6">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="checkbox pad-horiz">
+        <label>
+          <input type="checkbox" ng-model="checkoutArgs.sticky_date"/>
+          [% l('Specific Due Date') %]
+        </label>
+      </div>
+      <!--
+      <div><input type="checkbox" class="checkbox" ng-model="checkoutArgs.sticky_date"/></div>
+      <div class="pad-horiz">[% l('Specific Due Date') %]</div>
+      -->
+      <!-- FIXME: This needs a time component as well, but type="datetime" 
+            is not yet supported by any browsers -->
+      <div><input eg-date-input class="form-control" 
+        ng-model="checkoutArgs.due_date"/>
+      </div>
+    </div>
+  </div>
+</div>
+<hr/>
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.patron.checkout">
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]" 
+    path='acp.location.name'> </eg-grid-field>
+
+  <eg-grid-field label="[% l('Remaining Renewals') %]" 
+    path='circ.renewal_remaining'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="strict_barcode" type="checkbox"/>
+      [% l('Strict Barcode') %]
+    </label>
+  </div>
+  <div class="pad-horiz" ng-if="using_hatch"></div>
+  <div class="checkbox" ng-if="using_hatch">
+    <label>
+      <input ng-model="show_print_dialog" type="checkbox"/>
+      [% l('Show Print Dialog') %]
+    </label>
+  </div>
+  <div class="pad-horiz">
+    <button class="btn btn-default" 
+      ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+  </div>
+  <div>
+    <button class="btn btn-default" 
+      ng-click="done()">[% l('Done') %]</button>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2
new file mode 100644
index 0000000..524cb7f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2
@@ -0,0 +1,65 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-6">
+      <fieldset>
+        <legend>[% l('Verify Credentials') %]</legend>
+        <form ng-submit="verify()" 
+          name="verify-creds-form" class="form-horizontal" role="form">
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-username">[% l('Username') %]</label>
+            <div class="col-md-8">
+              <input type="text" id="verify-username" class="form-control" 
+                focus-me="focusMe" ng-disabled="prepop"
+                placeholder="[% l('Username') %]" ng-model="username"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-barcode">[% l('Barcode') %]</label>
+            <div class="col-md-8">
+              <input type="text" id="verify-barcode" class="form-control" 
+                ng-disabled="prepop"
+                placeholder="[% l('Barcode') %]" ng-model="barcode"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-password">[% l('Password') %]</label>
+            <div class="col-md-8">
+              <input type="password" id="verify-password" class="form-control" 
+                placeholder="[% l('Password') %]" ng-model="password"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <div class="col-md-offset-4 col-md-2">
+              <button type="submit" class="btn btn-default">[% l('Verify') %]</button>
+            </div>
+            <div class="col-md-2" ng-hide="prepop">
+              <button class="btn btn-default" ng-click="load($event)">[% l('Retrieve') %]</button>
+            </div>
+          </div>
+
+          <div class="form-group" ng-cloak>
+            <div class="col-md-offset-4 col-md-8">
+              <div class="alert alert-success" ng-show="verified">
+                [% l('Succes testing credentials') %]
+              </div>
+              <div class="alert alert-danger" ng-show="verified === false">
+                [% l('Failure testing credentials') %]
+              </div>
+              <div class="alert alert-danger" ng-show="notFound">
+                [% l('No user found with the requested username / barcode') %]
+              </div>
+            </div>
+          </div>
+
+        </form>
+      </fieldset>
+    <div><!-- col -->
+  </div><!-- row -->
+</div><!-- container -->
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
new file mode 100644
index 0000000..3f85366
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
@@ -0,0 +1,2 @@
+<eg-embed-frame url="patron_edit_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2
new file mode 100644
index 0000000..dcca6d3
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2
@@ -0,0 +1,24 @@
+<form ng-submit="ok(args)" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Edit Due Date For [_1] Items', '{{args.num_circs}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group row pad-vert">
+      <div class="col-md-6">
+        [% l('Enter Due Date: ') %]
+      </div>
+      <div class="col-md-6">
+        <input eg-date-input class="form-control" ng-model="args.due_date"/>
+      </div>
+    </div>
+    <!-- TODO: time picker -->
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+    <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2
new file mode 100644
index 0000000..76d10f6
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2
@@ -0,0 +1 @@
+<eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_group.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_group.tt2
new file mode 100644
index 0000000..21bb4c2
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_group.tt2
@@ -0,0 +1,55 @@
+
+<div class="strong-text-2">[% l('Group Member Details') %]</div>
+<div class="pad-vert flex-row">
+  <div>[% l('Total Owed: ') %]</div>
+  <div class="pad-horiz">{{totals.owed | currency}}</div>
+  <div>[% l('Total Out: ') %]</div>
+  <div class="pad-horiz">{{totals.total_out}}</div>
+  <div>[% l('Total Overdue: ') %]</div>
+  <div class="pad-horiz">{{totals.overdue}}</div>
+</div>
+<div class="pad-vert"></div>
+<eg-grid
+  idl-class="au"
+  sort="gridSort"
+  grid-controls="gridControls"
+  menu-label="[% l('Group Actions') %]">
+
+  <eg-grid-menu-item handler="moveToGroup"
+    label="[% l('Move Another Patron To This Group') %]"></eg-grid-menu-item>
+
+  <eg-grid-action 
+    label="[% l('Register a New Group Member by Cloning Selected Patron') %]" 
+    handler="cloneUser"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Remove Selected From Group') %]" 
+    handler="removeFromGroup"></eg-grid-action>
+
+  <eg-grid-action label="[% l("Move Selected Patrons to Another Patron's Group") %]" 
+    handler="moveToAnotherGroup"></eg-grid-action>
+
+  <eg-grid-action label="[% l("Retrieve Selected Patron") %]" 
+    handler="retrieveSelected"></eg-grid-action>
+
+  <eg-grid-field path="active"></eg-grid-field>
+  <eg-grid-field path="barred"></eg-grid-field>
+  <eg-grid-field path="dob"></eg-grid-field>
+  <eg-grid-field path="family_name"></eg-grid-field>
+  <eg-grid-field path="first_given_name"></eg-grid-field>
+  <eg-grid-field path="master_account"></eg-grid-field>
+  <eg-grid-field path="second_given_name"></eg-grid-field>
+  <eg-grid-field path="stats.fines.balance_owed" nonsortable label="[% l('Balance Owed') %]"></eg-grid-field>
+  <eg-grid-field path="stats.checkouts.out" nonsortable label="[% l('Items Out') %]"></eg-grid-field>
+  <eg-grid-field path="stats.checkouts.overdue" nonsortable label="[% l('Items Overdue') %]"></eg-grid-field>
+
+  <!-- needed for query, sorting -->
+  <eg-grid-field path="id" hidden required></eg-grid-field>
+  <eg-grid-field path="usrgroup" hidden required></eg-grid-field>
+  <eg-grid-field path="deleted" hidden required></eg-grid-field>
+  <eg-grid-field path="create_date" hidden required></eg-grid-field>
+
+  <!--
+  <eg-grid-field path=".*"></eg-grid-field>
+  -->
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2
new file mode 100644
index 0000000..5ccd762
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2
@@ -0,0 +1,38 @@
+<!-- patron holds list -->
+
+<ul class="nav nav-tabs" ng-if="!detail_hold_id">
+  <li ng-class="{active : holds_display == 'main', disabled : detail_hold_id}">
+    <a href ng-click="show_main_list()">[% l('Open Hold Requests') %]</a>
+  </li>
+  <li ng-class="{active : holds_display == 'alt', disabled : detail_hold_id}">
+    <a href ng-click="show_alt_list()">[% l('Recently Canceled Holds') %]</a>
+  </li>
+</ul>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/patron/t_holds_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
+
+<!-- catalog view for holds placement -->
+
+<div ng-if="placing_hold">
+  <eg-embed-frame url="catalog_url" handlers="handlers" 
+    onchange="handle_page"></eg-embed-frame>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2
new file mode 100644
index 0000000..e8e82e4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2
@@ -0,0 +1,4 @@
+<!-- holds are created within the catalog -->
+
+<eg-embed-frame url="catalog_url" 
+  handlers="handlers" onchange="handle_page"></eg-embed-frame>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2
new file mode 100644
index 0000000..df026fa
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2
@@ -0,0 +1,82 @@
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.patron.holds">
+
+  <eg-grid-menu-item handler="place_hold" 
+    label="[% l('Place Hold') %]"></eg-grid-menu-item>
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a href="[% ctx.base_path %]/opac/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div>
+    <button class="btn btn-default" ng-click="print()">
+      [% l('Print') %]
+    </button>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
new file mode 100644
index 0000000..71fc1a0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
@@ -0,0 +1,73 @@
+<!-- items out list -->
+
+<div ng-if="show_alt_circs">
+  <!-- only show the main vs. alt circ list tabs if the alt
+      circ list is meant to display -->
+  <ul class="nav nav-tabs">
+    <li ng-class="{active : items_out_display == 'main'}">
+      <a href ng-click="show_main_list()">
+        [% l('Items Checked Out') %] ({{main_list.length}})
+      </a>
+    </li>
+    <li ng-class="{active : items_out_display == 'alt'}">
+      <a href ng-click="show_alt_list()">
+        [% l('Other/Special Circulations') %] ({{alt_list.length}})
+      </a>
+    </li>
+  </ul>
+</div>
+<div ng-if="!show_alt_circs" class="strong-text-2">
+  [% l('Items Checked Out') %]
+</div>
+
+<div class="tab-content">
+  <div class="tab-pane active">
+<eg-grid
+  idl-class="circ"
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.patron.items_out">
+
+  <eg-grid-action handler="print_receipt"
+    label="[% l('Print Item Receipt') %]"></eg-grid-action>
+  <eg-grid-action handler="edit_due_date"
+    label="[% l('Edit Due Date') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_lost"
+    label="[% l('Mark Lost (By Patron)') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_claims_returned"
+    label="[% l('Mark Claims Returned') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_claims_never_checked_out"
+    label="[% l('Mark Claims Never Checked Out') %]"></eg-grid-action>
+  <eg-grid-action handler="renew" label="[% l('Renew') %]"></eg-grid-action>
+  <eg-grid-action handler="renew_all" label="[% l('Renew All') %]"></eg-grid-action>
+  <eg-grid-action handler="renew_with_date" 
+    label="[% l('Renew With Specific Due Date') %]"></eg-grid-action>
+  <eg-grid-action handler="checkin" 
+    label="[% l('Check In') %]"></eg-grid-action>
+  <eg-grid-action handler="add_billing" 
+    label="[% l('Add Billing') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Circ ID') %]" path='id'></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]" path='target_copy.barcode'>
+    <a href="./cat/item/{{item.target_copy().id()}}" target="_self">
+      {{item.target_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Due Date') %]" path='due_date' dateformat='short'></eg-grid-field>
+  <eg-grid-field label="[% l('Checkout / Renewal Library') %]" path='circ_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Renewals Remaining') %]" path='renewal_remaining'></eg-grid-field>
+  <eg-grid-field label="[% l('Fines Stopped') %]" path='stop_fines'></eg-grid-field>
+  <eg-grid-field label="[% l('Title') %]" name="title">
+    <a href="[% ctx.base_path %]/opac/record/{{item.target_copy().call_number().record().id()}}">
+      {{item.target_copy().call_number().record().simple_record().title()}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field path="*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.record.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.record.simple_record.*" hidden></eg-grid-field>
+</eg-grid>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2
new file mode 100644
index 0000000..9a917d8
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2
@@ -0,0 +1,9 @@
+<div class="col-md-6">
+  <div ng-if="no_last" class="alert alert-warning">
+    [% l('No patrons recently accessed.') %]
+    <span class="pad-horiz">
+      <a href='./circ/patron/search'>[% l('Try Patron Search') %]</a>
+    </span>
+  </div>
+  <br/>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2
new file mode 100644
index 0000000..e23c258
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2
@@ -0,0 +1,52 @@
+
+<div class="strong-text-2">[% l('Staff-Generated Penalties / Messages') %]</div>
+<div class="pad-vert"></div>
+<eg-grid
+  idl-class="ausp"
+  grid-controls="activeGridControls">
+
+  <eg-grid-menu-item handler="createPenalty"
+    label="[% l('Apply Penalty / Message') %]"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Remove Penalty / Message') %]" 
+    handler="removePenalty"></eg-grid-action>
+  <eg-grid-action label="[% l('Modify Penalty / Message') %]" 
+    handler="editPenalty"></eg-grid-action>
+  <eg-grid-action label="[% l('Archive Penalty / Message') %]" 
+    handler="archivePenalty"></eg-grid-action>
+
+  <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+  <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+  <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+  <eg-grid-field path="note"></eg-grid-field>
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+<div class="pad-vert"><hr/></div>
+
+<div class="pad-vert flex-row padded">
+  <div class="strong-text-2">[% l('Archived Penalties / Messages') %]</div>
+  <div class="flex-cell"></div>
+  <div>[% l('Set Date Start:') %]</div>
+  <div><input eg-date-input class="form-control" ng-model="dates.start_date"/></div>
+  <div>[% l('Set Date End:') %]</div>
+  <div><input eg-date-input class="form-control" ng-model="dates.end_date"/></div>
+</div>
+<eg-grid
+  idl-class="ausp"
+  grid-controls="archiveGridControls">
+
+  <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+  <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+  <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+  <eg-grid-field path="note"></eg-grid-field>
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2
new file mode 100644
index 0000000..96e9733
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2
@@ -0,0 +1,28 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 ng-if="!outbound" class="modal-title">
+      [% l('Move user into this group?') %]
+    </h4>
+    <h4 ng-if="outbound" class="modal-title">
+      [% l("Move selected users to the following user's group?") %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <a href="./circ/patron/{{user.id()}}/checkout" target="_self">
+      [% 
+        l('[_1], [_2] [_3] : [_4]', 
+          '{{user.family_name()}}',
+          '{{user.first_given_name()}}',
+          '{{user.second_given_name()}}',
+          '{{user.card().barcode()}}') 
+      %]
+    </a>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-primary" ng-click="ok()">[% l('Move User') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+ 
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2
new file mode 100644
index 0000000..cfc5b40
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2
@@ -0,0 +1,42 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create a new note') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-title">[% l('Title') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-pub">[% l('Patron Visible?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-pub" ng-model="args.pub"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-value">[% l('Value') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-value" ng-model="args.value" placeholder="[% l('Value...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_notes.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_notes.tt2
new file mode 100644
index 0000000..b83f317
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_notes.tt2
@@ -0,0 +1,44 @@
+<div class="row">
+  <div class="col-md-12">
+    <button class="btn btn-default" ng-click="newNote()">
+      [% l('Add New Note') %]
+    </button>
+  </div>
+</div>
+
+<div class="row pad-vert" ng-repeat="note in notes">
+  <div class="col-md-12">
+    <div class="row">
+      <div class="col-md-6 strong-text">{{note.title()}}</div>
+      <div class="col-md-6">
+        <div class="pull-right">
+          <span class="pad-horiz alert alert-warning" ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+          <span class="pad-horiz alert alert-info" ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+          <span class="pad-horiz">{{note.create_date() | date:'short'}}</span>
+          <span>[% l('Created by [_1]', '{{note.creator().usrname()}}') %]</span>
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <!-- hmm, not sure why the margin-left is needed.. the well? -->
+      <div class="col-md-12 well" style="margin-left:12px">
+        <div class="row">
+          <div class="col-md-8">
+            <div class="">{{note.value()}}</div>
+          </div>
+          <div class="col-md-4">
+            <div class="pull-right">
+              <button ng-click="printNote(note)" class="btn btn-default">
+                [% l('Print') %]
+              </button>
+              <button ng-click="deleteNote(note)" class="btn btn-warning">
+                [% l('Delete') %]
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <hr/>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2
new file mode 100644
index 0000000..3809d12
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2
@@ -0,0 +1,34 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Pending Patrons') %]</span>
+  </div>
+</div>
+
+<span>[% l('Home Library: ' ) %]</span>
+<span><eg-org-selector selected="context_org"></eg-org-selector></span>
+<hr/>
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="grid_data_provider"
+  grid-controls="grid_controls"
+  persist-key="circ.pending_patrons.list">
+
+  <eg-grid-menu-item handler="load_patron" 
+    label="[% l('Load Patron') %]"></eg-grid-menu-item>
+
+  <eg-grid-field path='user.row_date' label="[% l('Create Date') %]"></eg-grid-field>
+  <eg-grid-field path='user.first_given_name' label="[% l('First Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.second_given_name' label="[% l('Middle Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.family_name' label="[% l('Last Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.email' label="[% l('Email') %]"></eg-grid-field>
+  <eg-grid-field path='user.home_ou.shortname' label="[% l('Home Library') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.street1' label="[% l('Street 1') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.city' label="[% l('City') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.post_code' label="[% l('Post Code') %]"></eg-grid-field>
+  <eg-grid-field path='user.usrname' label="[% l('Requested Username') %]"></eg-grid-field>
+  <eg-grid-field path='user.*' parent-idl-class="stgu" hidden></eg-grid-field>
+  <eg-grid-field path='mailing_address.*' parent-idl-class="stgma" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2
new file mode 100644
index 0000000..d8112d0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2
@@ -0,0 +1,24 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Renew Items with Specific Due Date') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div class="pad-vert row">
+    <div class="col-md-12">
+      [% l('Enter due date for items: [_1]', '{{args.barcodes.join(" ")}}') %]
+    </div>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-5">
+      <input eg-date-input required 
+        class="form-control" ng-model="args.date"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
new file mode 100644
index 0000000..29abb40
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
@@ -0,0 +1,158 @@
+
+<!-- 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">
+          <input type="submit" class="btn btn-default" value="[% l('Search') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="reset" class="btn btn-default" ng-click="searchArgs={}" 
+            value="[% l('Clear Form') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <button class="btn btn-default" ng-click="applyShowExtras($event, true)" 
+            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)" 
+            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" dropdown>
+              <button type="button" class="btn btn-default dropdown-toggle">
+                <span style="padding-right: 5px;">{{searchArgs.profile.name() || "[% l('Profile Group') %]"}}</span>
+                <span class="caret"></span>
+              </button>
+              <ul class="dropdown-menu">
+                <li ng-repeat="grp in profiles">
+                  <a href dropdown-toggle
+                    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">
+          </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>
+
+
+<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/circ/patron/t_search_results.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
new file mode 100644
index 0000000..ac83e6d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
@@ -0,0 +1,30 @@
+<eg-grid
+  idl-class="au" id-field="id"
+  features="-sort,-display,-multisort"
+  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="[% ('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>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2
new file mode 100644
index 0000000..21eb217
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2
@@ -0,0 +1,19 @@
+<div class="row pad-vert" ng-repeat="map in patron().stat_cat_entries()">
+  <div class="col-md-12 well" style="margin-left:12px">
+    <div class="row">
+      <div class="col-md-7">
+        <span class="strong-text">{{map.stat_cat().name()}}</span>
+        <span class="pad-horiz">{{map.stat_cat_entry()}}</span>
+      </div>
+      <div class="col-md-5">
+        <div class="pull-right">
+          <span class="pad-horiz alert alert-warning" 
+            ng-if="map.stat_cat().opac_visible() == 't'">[% l('Patron Visible') %]</span>
+          <span class="pad-horiz alert alert-info" 
+            ng-if="map.stat_cat().opac_visible() == 'f'">[% l('Staff Only') %]</span>
+          <span>[% l('@ [_1]', '{{map.stat_cat().owner().shortname()}}') %]</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2
new file mode 100644
index 0000000..1f09c77
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2
@@ -0,0 +1,143 @@
+
+<div ng-cloak>
+  <div ng-show="patron()" id="patron-summary-grid">
+    <div class="row" 
+      ng-class="{'patron-summary-divider' : !$index}"
+      ng-repeat="penalty in alert_penalties()">
+      <div 
+        class="col-md-9 patron-summary-alert"
+        title="{{penalty.standing_penalty().name()}}">
+        {{penalty.note() || penalty.standing_penalty().label()}}
+      </div>
+      <div class="col-md-3">
+        {{penalty.set_date() | date:'shortDate'}}
+      </div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-divider' : alert_penalties().length}">
+      <div class="col-md-5">[% l('Profile') %]</div>
+      <div class="col-md-7">{{patron().profile().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Home Library') %]</div>
+      <div class="col-md-7">{{patron().home_ou().shortname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Net Access') %]</div>
+      <div class="col-md-7">{{patron().net_access_level().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Last Activity') %]</div>
+      <div class="col-md-7">{{patron().usr_activity()[0].event_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Last Updated') %]</div>
+      <div class="col-md-7">{{patron().last_update_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Create Date') %]</div>
+      <div class="col-md-7">{{patron().create_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Expire Date') %]</div>
+      <div class="col-md-7">{{patron().expire_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row patron-summary-divider" 
+      ng-class="{'patron-summary-alert' : patron_stats().fines.balance_owed}">
+      <div class="col-md-5">[% l('Fines Owed') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().fines.balance_owed | currency}}
+      </div>
+    </div>
+    <div class="row"
+      ng-show="patron_stats().fines.group_balance_owed > patron_stats().fines.balance_owed"
+      ng-class="{'patron-summary-alert' : patron_stats().fines.group_balance_owed}">
+      <div class="col-md-5">[% l('Group Fines') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().fines.group_balance_owed | currency}}
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Items Out') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.out}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.overdue}">
+      <div class="col-md-5">[% l('Overdue') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.long_overdue}">
+      <div class="col-md-5">[% l('Long Overdue') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.long_overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.claims_returned}">
+      <div class="col-md-5">[% l('Claimed Returned') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.claims_returned}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.lost}">
+      <div class="col-md-5">[% l('Lost') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.lost}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Holds') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().holds.total}} / {{patron_stats().holds.ready}}
+      </div>
+    </div>
+    <div class="row patron-summary-divider">
+      <div class="col-md-5">[% l('Card') %]</div>
+      <div class="col-md-7">{{patron().card().barcode()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Username') %]</div>
+      <div class="col-md-7">{{patron().usrname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Day Phone') %]</div>
+      <div class="col-md-7">{{patron().day_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Evening Phone') %]</div>
+      <div class="col-md-7">{{patron().evening_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Other Phone') %]</div>
+      <div class="col-md-7">{{patron().other_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('ID1') %]</div>
+      <div class="col-md-7">{{patron().ident_type().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('ID2') %]</div>
+      <div class="col-md-7">{{patron().ident_type2().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Email') %]</div>
+      <div class="col-md-7">{{patron().email()}}</div>
+    </div>
+    <div class="row" ng-repeat="map in summary_stat_cats()">
+      <div class="col-md-5">{{map.stat_cat().name()}}</div>
+      <div class="col-md-7">{{map.stat_cat_entry()}}</div>
+    </div>
+  </div>
+
+  <div class="row" ng-repeat="addr in patron().addresses()">
+    <div class="panel">
+      <div class="panel-body">
+        <fieldset>
+          <legend>
+            {{addr.address_type()}} 
+            <a href class="pad-horiz patron-summary-act-link" 
+              ng-click="print_address(addr)">[% l('(print)') %]</a>
+          </legend>
+          <div>{{addr.street1()}} {{addr.street2()}}</div>
+          <div>{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</div>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2
new file mode 100644
index 0000000..fe5091f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2
@@ -0,0 +1,3 @@
+<!-- insert the patron registration UI -->
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
new file mode 100644
index 0000000..7bc04e7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
@@ -0,0 +1,144 @@
+<h3>[% l('Transaction #[_1]', '{{xact.id()}}') %]</h3>
+
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Billing Location') %]</div>
+  <div class="col-md-2">{{xact.billing_location().shortname()}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+  <div class="col-md-2">{{xact.summary().total_owed() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Title') %]</div>
+  <div class="col-md-2">
+    <a ng-if="title_id" href="[% ctx.base_path %]/opac/record/{{title_id}}">{{title}}</a>
+    <span ng-if="!title_id">{{title}}</span>
+  </div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Type') %]</div>
+  <div class="col-md-2">{{xact.summary().xact_type()}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Paid') %]</div>
+  <div class="col-md-2">{{xact.summary().total_paid() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Checked Out') %]</div>
+  <div class="col-md-2">{{xact.circulation().xact_start() | date:'short'}}</div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Start') %]</div>
+  <div class="col-md-2">{{xact.xact_start() | date:'short'}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+  <div class="col-md-2">{{xact.summary().balance_owed() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Due Date') %]</div>
+  <div class="col-md-2">{{xact.circulation().due_date() | date:'short'}}</div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Finish') %]</div>
+  <div class="col-md-2">{{xact.xact_finish() | date:'short'}}</div>
+  <div class="col-md-2 strong-text">[% l('Renewal?') %]</div>
+  <div class="col-md-2">
+    <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+    <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+    <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+  </div>
+  <div class="col-md-2 strong-text">[% l('Checked In') %]</div>
+  <div class="col-md-2">{{xact.circulation().checkin_time() | date:'short'}}</div>
+</div>
+
+<div ng-if="xact.circulation()">
+  <hr/>
+  <h3>[% l('Item Summary') %]</h3>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Barcode') %]</div>
+    <div class="col-md-2">
+      <a title="[% l('Item Details') %]" target="_self"
+        href='./cat/item/{{xact.circulation().target_copy().id()}}'>
+        {{xact.circulation().target_copy().barcode()}}
+      </a>
+    </div>
+    <div class="col-md-2 strong-text">[% l('Location') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().location().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Call Number') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().call_number().label()}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Status') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().status().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Circulate') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().circulate() == 't'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Reference') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().ref() == 't'}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Holdable') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().holdable() == 't'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('OPAC Visible') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().opac_visible() == 't'}}
+    </div>
+
+    <div class="col-md-2 strong-text">[% l('Created') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().create_date() | date:'short'}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Edited') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().edit_date() | date:'short'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Age Protect') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().age_protect().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Total Circulations') %]</div>
+    <div class="col-md-2">
+      TODO
+    </div>
+  </div>
+</div>
+
+
+<!-- set a lower default page size (limit) to allow for more space -->
+<hr/>
+<eg-grid
+  main-label="[% l('Bills') %]"
+  idl-class="mb"
+  id-field="id"
+  grid-controls="xactGridControls"
+  auto-fields="true"
+  page-size="10">
+
+  <eg-grid-action 
+    label="[% l('Void Billings') %]" handler="voidBillings"></eg-grid-action>
+
+  <eg-grid-action 
+    label="[% l('Edit Note') %]" handler="editBillNotes"></eg-grid-action>
+
+</eg-grid>
+
+<!-- TODO: this grid may contain objects (payments) of different types.. 
+    apply manual columns, see xul -->
+<!-- NOTE: sorting disabled since payments are fetched via non-sortable API -->
+<br/>
+<eg-grid
+  main-label="[% l('Payments') %]"
+  idl-class="mp"
+  id-field="id"
+  auto-fields="true"
+  grid-controls="paymentGridControls"
+  page-size="10">
+  <eg-grid-action 
+    label="[% l('Edit Note') %]" handler="editPaymentNotes"></eg-grid-action>
+
+  <eg-grid-field path="cash_payment.cash_drawer.name" 
+    label="[% l('Cash Drawer') %]"></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/renew/index.tt2 b/Open-ILS/src/templates/staff/circ/renew/index.tt2
new file mode 100644
index 0000000..415556b
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/renew/index.tt2
@@ -0,0 +1,20 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Renew"); 
+  ctx.page_app = "egRenewApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<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/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" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/renew/t_renew.tt2 b/Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
new file mode 100644
index 0000000..c2880c1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
@@ -0,0 +1,141 @@
+<!-- item renewal form / list -->
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    [% l('Renew Items') %]
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="renew(renewalArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <label class="input-group-addon" 
+          for="patron-renewal-barcode" >[% l('Barcode') %]</label>
+
+        <input focus-me="focusBarcode" class="form-control"
+          ng-model="renewalArgs.copy_barcode" 
+          id="patron-renewal-barcode" type="text"/> 
+
+        <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+      </div>
+    </form>
+  </div>
+  <div class="col-md-6">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="checkbox pad-horiz">
+        <label>
+          <input type="checkbox" ng-model="renewalArgs.sticky_date"/>
+          [% l('Specific Due Date') %]
+        </label>
+      </div>
+      <!-- FIXME: This needs a time component as well, but type="datetime" 
+            is not yet supported by any browsers -->
+      <div><input eg-date-input class="form-control" ng-model="renewalArgs.due_date"/>
+      </div>
+    </div>
+  </div>
+</div>
+<hr/>
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.renew">
+
+  <eg-grid-action 
+    handler="fetchLastCircPatron"
+    label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showLastFewCircs"
+    label="[% l('Show Last Few Circluations') %]">
+  </eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action 
+    handler="showMarkDamaged"
+    label="[% l('Mark Items Damaged') %]">
+  </eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action 
+    handler="abortTransit"
+    label="[% l('Abort Transits') %]">
+  </eg-grid-action>
+
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]" 
+    path='acp.location.name'> </eg-grid-field>
+
+  <eg-grid-field label="[% l('Remaining Renewals') %]" 
+    path='circ.renewal_remaining'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div class="pad-horiz">
+    <button class="btn btn-default" 
+      ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+  </div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="trim_list" type="checkbox"/>
+      [% l('Trim List (20 Rows)') %]
+    </label>
+  </div>
+  <div class="pad-horiz"></div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="strict_barcode" type="checkbox"/>
+      [% l('Strict Barcode') %]
+    </label>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/share/circ_strings.tt2 b/Open-ILS/src/templates/staff/circ/share/circ_strings.tt2
new file mode 100644
index 0000000..316e64a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/circ_strings.tt2
@@ -0,0 +1,39 @@
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.PATRON_CARD_INACTIVE =
+  "[% l('The card used to retrieve this account is inactive and may not be used to circulate items.') %]";
+s.PATRON_INACTIVE =
+  "[% l('This account is inactive and may not circulate items.') %]";
+s.PATRON_ACCOUNT_EXPIRED =
+  "[% l('This account has expired and may not circulate items.') %]";
+s.CIRC_CLAIMS_RETURNED = 
+  '[% l('Item "[_1]" is marked as Claims Returned', '{{barcode}}') %]';
+s.CHECKOUT_FAILED_GENERIC =
+  '[% l('Unable to checkout copy "[_1]" : [_2]', '{{barcode}}', '{{textcode}}') %]';
+s.COPY_ALERT_MSG_DIALOG_TITLE =
+  '[% l('Copy Alert Message for "[_1]"', '{{copy_barcode}}') %]';
+s.UNCAT_ALERT_DIALOG =
+  '[% l('Copy "[_1]" was mis-scanned or is not cataloged', '{{copy_barcode}}') %]';
+s.PERMISSION_DENIED = 
+  '[% l('Permission Denied : [_1]', '{{permission}}') %]';
+s.PRECAT_CHECKIN_MSG = 
+  '[% l("This item needs to be routed to CATALOGING") %]';
+s.LOCATION_ALERT_MSG =
+  '[% l("Item [_1] needs to be routed to [_2]", 
+    "{{copy.barcode()}}","{{copy.location().name()}}") %]';
+s.MARK_DAMAGED_CONFIRM = '[% l("Mark {{num_items}} items as DAMAGED?") %]';
+s.MARK_MISSING_CONFIRM = '[% l("Mark {{num_items}} items as MISSING?") %]';
+s.ABORT_TRANSIT_CONFIRM = '[% l("Abort {{num_transits}} transits?") %]';
+s.ROUTE_TO_HOLDS_SHELF = '[% l("Holds Shelf") %]';
+s.ROUTE_TO_CATALOGING = '[% l("Cataloging") %]';
+s.COPY_IN_TRANSIT = '[% l("Copy is In-Transit") %]';
+s.TOO_MANY_CLAIMS_RETURNED = 
+  '[% l("Patron exceeds claims returned count.  Force this action?") %]';
+s.MARK_NEVER_CHECKED_OUT = 
+  '[% l("Mark Never Checked Out: [_1]", "{{barcodes.toString()}}") %]'
+}]);
+</script>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/hold_strings.tt2 b/Open-ILS/src/templates/staff/circ/share/hold_strings.tt2
new file mode 100644
index 0000000..9e67e19
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/hold_strings.tt2
@@ -0,0 +1,30 @@
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s['HOLD_STATUS_-1'] = "[% l('Error (-1)') %]";
+s.HOLD_STATUS_1 = "[% l('Waiting for Copy') %]";
+s.HOLD_STATUS_2 = "[% l('Waiting for Capture') %]";
+s.HOLD_STATUS_3 = "[% l('In Transit') %]";
+s.HOLD_STATUS_4 = "[% l('Ready for Pickup') %]";
+s.HOLD_STATUS_5 = "[% l('Hold Shelf Delay') %]";
+s.HOLD_STATUS_6 = "[% l('Canceled') %]";
+s.HOLD_STATUS_7 = "[% l('Suspended') %]";
+s.HOLD_STATUS_8 = "[% l('Wrong Shelf') %]";
+s.ACTIVATE_HOLDS = "[% l('Activate [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SUSPEND_HOLDS = "[% l('Suspend [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SET_TOP_OF_QUEUE = 
+  "[% l('Move [_1] Hold(s) to the front of the holds queue above other holds that are not likewise flagged as Top of Queue?', 
+    '{{num_holds}}') %]";
+s.CLEAR_TOP_OF_QUEUE = 
+  "[% l('Unset the Top of Queue flag for [_1] Hold(s)?', '{{num_holds}}') %]";
+s.TRANSFER_HOLD_TO_TITLE = 
+  "[% l('Tranfer [_1] Hold(s) to bib record ID [_2]?', '{{num_holds}}', '{{bib_id}}') %]";
+s.NO_HOLD_TRANSFER_TITLE_MARKED = 
+  "[% l('No record is marked as a hold transfer target!') %]";
+s.RETARGET_HOLDS = 
+  "[% l('Reset hold(s) [_1]?', '{{hold_ids}}') %]";
+}]);
+</script>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2
new file mode 100644
index 0000000..034e909
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2
@@ -0,0 +1,24 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Backdate Already Checked-In Circulations') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div>[% l('Number of circulations selected: [_1]', '{{dialog.num_circs}}') %]</div>
+  <div class="pad-vert">
+    <progress max="dialog.num_circs" value="dialog.num_processed"></progress>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-6">[% l('Effective Date:') %]</div>
+    <div class="col-md-6">
+      <input eg-date-input required 
+        class="form-control" ng-model="dialog.backdate"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2
new file mode 100644
index 0000000..c304416
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2
@@ -0,0 +1,23 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Bad Barcode') %]</h4>
+  </div>
+  <div class="modal-body">
+    <img src="[% ctx.media_prefix %]/images/bad_barcode.png"/>
+    <div>
+[% |l('{{barcode}}') %]
+Bad check digit, possibly due to a bad scan.  Use this barcode ("[_1]") anyway?
+[% END %]
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('Accept Barcode') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2
new file mode 100644
index 0000000..b946b8c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2
@@ -0,0 +1,93 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(billArgs)" role="form" class="form-horizontal">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">
+        [% l('Bill Patron: [_1], [_2] [_3] : [_4]',
+            '{{patron.family_name()}}',
+            '{{patron.first_given_name()}}',
+            '{{patron.second_given_name()}}',
+            '{{patron.card().barcode()}}') %]
+      </h4>
+
+      <div ng-if="xact">
+        <hr/>
+        <div class="row">
+          <div class="col-md-3">[% l('Bill #') %]</div>
+          <div class="col-md-3">{{xact.id}}</div>
+          <div class="col-md-3">[% l('Total Billed') %]</div>
+          <div class="col-md-3">{{xact.summary.total_owed | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Type') %]</div>
+          <div class="col-md-3">{{xact.summary.xact_type}}</div>
+          <div class="col-md-3">[% l('Total Paid') %]</div>
+          <div class="col-md-3">{{xact.summary.total_paid | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Start') %]</div>
+          <div class="col-md-3">{{xact.xact_start | date:'short'}}</div>
+          <div class="col-md-3">[% l('Total Billed') %]</div>
+          <div class="col-md-3">{{xact.summary.balance_owed | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Finish') %]</div>
+          <div class="col-md-3">{{xact.xact_finish | date:'short'}}</div>
+          <div class="col-md-3">[% l('Renewal?') %]</div>
+          <div class="col-md-3">
+            <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+            <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+            <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="bill-dialog-location" class="control-label col-md-4">
+          [% l('Location:') %]
+        </label>
+        <div class="col-md-8">
+          <p class="form-control-static">{{location.shortname()}}</p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="bill-dialog-type" class="control-label col-md-4">
+          [% l('Billing Type:') %]
+        </label>
+        <div class="col-md-8">
+          <select ng-model="billArgs.billingType" class="form-control"
+            ng-change="updateDefaultPrice()">
+            <option ng-repeat="type in billingTypes" value="{{type.id()}}">
+              {{type.name()}}
+            </option>
+          </select>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="bill-dialog-amount" class="control-label col-md-4">[% l('Amount:') %]</label>
+        <div class="col-md-8">
+          <input type="number" min="0" step="any" class="form-control" 
+            focus-me='focus' required id="bill-dialog-amount" 
+            ng-model="billArgs.amount"/>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="bill-dialog-note" class="control-label col-md-4">[% l('Note:') %]</label>
+        <div class="col-md-8">
+          <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+            id="bill-dialog-note" ng-model="billArgs.note"></textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-success" value="[% l('Submit Bill') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div>
+</form>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2
new file mode 100644
index 0000000..81417f2
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2
@@ -0,0 +1,38 @@
+<form ng-submit="ok()" role="form" class="form-horizontal">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">
+        [% l('Cancel [_1] Hold(s)', '{{args.num_holds}}') %]
+      </h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="hold-cancel-reason" class="control-label col-md-4">
+          [% l('Cancel Reason:') %]
+        </label>
+        <div class="col-md-8">
+          <select class="form-control" id="hold-cancel-reason"
+            ng-model="args.cancel_reason"
+            ng-options="reason.label() for reason in args.cancel_reasons">
+          </select>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="hold-cancel-note" class="control-label col-md-4">
+          [% l('Note:') %]
+        </label>
+        <div class="col-md-8">
+          <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+            id="hold-cancel-note" ng-model="args.note"></textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-success" value="[% l('Cancel Hold') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Exit') %]</button>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2
new file mode 100644
index 0000000..3a8b5d1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2
@@ -0,0 +1,31 @@
+<form class="form-validated" novalidate ng-submit="ok()" name="form">
+  <div>
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" 
+          ng-click="cancel()" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">[% l('Open Circulation') %]</h4>
+      </div>
+      <div class="modal-body">
+        <div ng-if="sameUser">
+          [% |l("{{circDate | date:'shortDate'}}") %]
+          There is an open circulation on the requested item.  
+          This item was already checked out to this user on [_1].
+          [% END %]
+        </div>
+        <div ng-if="!sameUser">
+          [% |l("{{circDate | date:'shortDate'}}") %]
+          There is an open circulation on the requested item.  
+          This copy was checked out by another patron on [_1].
+          [% END %]
+        </div>
+      </div>
+      <div class="modal-footer">
+        <input type="submit" class="btn btn-primary" 
+            value="[% l('Normal Checkin then Checkout') %]"/>
+        <button class="btn btn-warning" 
+            ng-click="cancel($event)">[% l('Cancel') %]</button>
+      </div>
+    </div> <!-- modal-content -->
+  </div> <!-- modal-dialog -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2
new file mode 100644
index 0000000..4d38922
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2
@@ -0,0 +1,32 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Copy In Transit') %]</h4>
+  </div>
+  <div class="modal-body">
+    <div class="strong-text">
+      [% l('There is an open transit on copy [_1]', 
+        '{{transit.target_copy().barcode()}}') %]
+    </div>
+    <div class="pad-vert"></div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Date:') %]</div>
+      <div class="col-md-8">{{transit.source_send_time() | date:'short'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Source:') %]</div>
+      <div class="col-md-8">{{transit.source().shortname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Destination:') %]</div>
+      <div class="col-md-8">{{transit.dest().shortname()}}</div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" ng-click="ok()"
+        value="[% l('Abort Transit then Checkout') %]"/>
+    <button class="btn btn-warning" 
+        ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2
new file mode 100644
index 0000000..711e777
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2
@@ -0,0 +1,20 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Copy Not Available.') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="alert alert-warning">
+      [% l('Copy Status: [_1]', '{{copyStatus.name()}}') %]
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" ng-click="ok()"
+        value="[% l('Force this action?') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2
new file mode 100644
index 0000000..5850ac5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2
@@ -0,0 +1,27 @@
+<form ng-submit="ok()" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Exceptions occurred during checkout.') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="panel panel-danger">
+      <div class="panel-heading">{{evt.textcode}}</div>
+      <div class="panel-body">
+        <div ng-if="copy_barcode" class="strong-text-2">{{copy_barcode}}</div>
+        {{evt.desc}}
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <i ng-if="auto_override">[% |l %]If overridden, subsequent checkouts during this patron's 
+ session will auto-override this event[% END %]</i>
+    <br/><br/>
+    <input type="submit" class="btn btn-primary" 
+        value="[% l('Force Action?') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2
new file mode 100644
index 0000000..19aa885
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2
@@ -0,0 +1,24 @@
+<div class="modal-content">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Accept only "Good Quality" copies?') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <div class="col-md-4">
+        <button class="btn btn-default" ng-click="good()">[% l('Good Condition') %]</button>
+      </div>
+      <div class="col-md-4">
+        <button class="btn btn-default" ng-click="any()">[% l('Any Condition') %]</button>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2
new file mode 100644
index 0000000..b145d0e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2
@@ -0,0 +1,75 @@
+<div class="modal-content" id='hold-notify-settings'>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Modify Dates for [_1] Hold(s)', '{{num_holds}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="row header-row">
+      <div class="col-md-12">
+        [% l('Check the checkbox next to each field you wish to modify.') %]
+      </div>
+    </div>
+    <hr/>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_thaw_date" class="sr-only">[% l('Update Activate Email') %]</label>
+        <input id='modify_thaw_date' ng-model="args.modify_thaw_date" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='thaw_date'>[% l("Hold Activate Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='thaw_date' eg-date-input 
+          ng-disabled="!args.modify_thaw_date" ng-model="args.thaw_date"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_request_time" class="sr-only">[% l('Update Phone Number') %]</label>
+        <input id='modify_request_time' ng-model="args.modify_request_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='request_time'>[% l("Hold Request Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='request_time' eg-date-input 
+          ng-disabled="!args.modify_request_time" ng-model="args.request_time"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_expire_time" class="sr-only">[% l('Update Expire Time') %]</label>
+        <input id='modify_expire_time' ng-model="args.modify_expire_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='expire_time'>[% l("Hold Expire Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='expire_time' eg-date-input 
+          ng-disabled="!args.modify_expire_time" ng-model="args.expire_time"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_shelf_expire_time" class="sr-only">[% l('Update SMS Carrier') %]</label>
+        <input id='modify_shelf_expire_time' ng-model="args.modify_shelf_expire_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='shelf_expire_time'>[% l("Shelf Expire Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='shelf_expire_time' eg-date-input 
+          ng-disabled="!args.modify_shelf_expire_time" ng-model="args.shelf_expire_time"/>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2
new file mode 100644
index 0000000..1117ae1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2
@@ -0,0 +1,149 @@
+<!-- hold info -->
+<h4 class="pad-vert">[% l('Hold Details') %]</h4>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Request Date') %]</div>
+  <div class="flex-cell well">{{hold.request_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Capture Date') %]</div>
+  <div class="flex-cell well">{{hold.capture_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Available On') %]</div>
+  <div class="flex-cell well">{{hold.shelf_time() | date:'short'}}</div>
+ </div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Hold Type') %]</div>
+  <div class="flex-cell well">{{hold.hold_type()}}</div>
+  <div class="flex-cell">[% l('Current Copy') %]</div>
+  <div class="flex-cell well">
+    <a href="./cat/item/{{hold.current_copy().id()}}" target="_self">
+      {{hold.current_copy().barcode()}}
+    </a>
+  </div>
+  <div class="flex-cell">[% l('Call Number') %]</div>
+  <div class="flex-cell well">{{volume.label()}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Pickup Lib') %]</div>
+  <div class="flex-cell well">{{hold.pickup_lib().shortname()}}</div>
+  <div class="flex-cell">[% l('Status') %]</div>
+  <div class="flex-cell well">{{status_string}}</div>
+  <div class="flex-cell">[% l('Behind Desk') %]</div>
+  <div class="flex-cell well">{{hold.behind_desk() == 't'}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+  <div class="flex-cell well">{{hold.current_shelf_lib().shortname()}}</div>
+  <div class="flex-cell">[% l('Current Copy Location') %]</div>
+  <div class="flex-cell well">{{copy.location().name()}}</div>
+  <div class="flex-cell">[% l('Force Copy Quality') %]</div>
+  <div class="flex-cell well">{{hold.mint_condition() == 't'}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Email Notify') %]</div>
+  <div class="flex-cell well">{{hold.email_notify() == 't'}}</div>
+  <div class="flex-cell">[% l('Phone Notify') %]</div>
+  <div class="flex-cell well">{{hold.phone_notify()}}</div>
+  <div class="flex-cell">[% l('SMS Notify') %]</div>
+  <div class="flex-cell well">{{hold.sms_notify()}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Cancel Cause') %]</div>
+  <div class="flex-cell well">{{hold.cancel_cause().label()}}</div>
+  <div class="flex-cell">[% l('Cancel Time') %]</div>
+  <div class="flex-cell well">{{hold.cancel_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Cancel Note') %]</div>
+  <div class="flex-cell well">{{hold.cancel_note()}}</div>
+</div>
+
+<ul class="nav nav-tabs pad-vert" ng-init="detail_tab='notes'">
+  <li ng-class="{active : detail_tab == 'notes'}">
+    <a href ng-click="detail_tab = 'notes'">[% l('Notes') %]</a>
+  </li>
+  <li ng-class="{active : detail_tab == 'notify'}">
+    <a href ng-click="show_notify_tab()">
+      [% l('Staff Notifications') %]
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <div ng-if="detail_tab == 'notes'">
+      
+      <button class="btn btn-default" ng-click="new_note()">
+        [% l('New Note') %]
+      </button>
+
+      <div class="row pad-vert" ng-repeat="note in hold.notes()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 strong-text">{{note.title()}}</div>
+            <div class="col-md-6">
+              <div class="pull-right">
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.slip() == 't'">[% l('Print on Slip') %]</span>
+                <span class="pad-horiz alert alert-warning" 
+                  ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.staff() == 't'">[% l('Staff Created') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.staff() == 'f'">[% l('Patron Created') %]</span>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <!-- hmm, not sure why the margin-left is needed.. the well? -->
+            <div class="col-md-12 well" style="margin-left:12px">
+              <div class="row">
+                <div class="col-md-8">
+                  <div class="">{{note.body()}}</div>
+                </div>
+                <div class="col-md-4">
+                  <div class="pull-right">
+                    <button ng-click="delete_note(note)" class="btn btn-warning">
+                      [% l('Delete') %]
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div><!-- notes tab content -->
+
+    <div ng-if="detail_tab == 'notify'">
+      
+      <button class="btn btn-default" ng-click="new_notification()">
+        [% l('Add Record of Notification') %]
+      </button>
+
+      <div class="row pad-vert" 
+          ng-repeat="notify in hold.notifications()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 strong-text">{{notify.method()}}</div>
+            <div class="col-md-6">
+              <div class="pull-right">
+                <span class="pad-horiz">{{notify.notify_time() | date:'short'}}</span>
+                <span>[% l('Created by [_1]', '{{notify.notify_staff().usrname()}}') %]</span>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <!-- hmm, not sure why the margin-left is needed.. the well? -->
+            <div class="col-md-12 well" style="margin-left:12px">
+              <div class="row">
+                <div class="col-md-8">
+                  <div class="">{{notify.note()}}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div><!-- notes tab content -->
+
+  </div><!-- tab pane -->
+</div><!-- tab-content -->
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2
new file mode 100644
index 0000000..895cda0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2
@@ -0,0 +1,23 @@
+<div class="modal-content">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Edit hold pickup library') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <div class="col-md-4">[% l('Select Library:') %]</div>
+      <div class="col-md-8">
+        <eg-org-selector selected="args.org_unit"></eg-org-selector>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2
new file mode 100644
index 0000000..92113da
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2
@@ -0,0 +1,51 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create a new note') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-pub">[% l('Patron Visible?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-pub" ng-model="args.pub"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-slip">[% l('Print on Slip?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-slip" ng-model="args.slip"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-title">[% l('Title') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-body">[% l('Note Body') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-body" ng-model="args.body" placeholder="[% l('Note Body...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2
new file mode 100644
index 0000000..01b5b48
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2
@@ -0,0 +1,33 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create Record of Hold Notification') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-method">[% l('Notification Method') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-method" ng-model="args.method" placeholder="[% l('Notification Method...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-note">[% l('Note') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-note" ng-model="args.note" placeholder="[% l('Note') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2
new file mode 100644
index 0000000..7f2659d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2
@@ -0,0 +1,78 @@
+<div class="modal-content" id='hold-notify-settings'>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Edit Notification Settings for [_1] Hold(s)', '{{num_holds}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="row header-row">
+      <div class="col-md-12">
+        [% l('Check the checkbox next to each field you wish to modify.') %]
+      </div>
+    </div>
+    <hr/>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-use-email" class="sr-only">[% l('Update Activate Email') %]</label>
+        <input id='activate-use-email' ng-model="args.update_email_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='use-email'>[% l("Send Emails") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='use-email' ng-model="args.email_notify" 
+          type="checkbox" ng-disabled="!args.update_email_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-phone-number" class="sr-only">[% l('Update Phone Number') %]</label>
+        <input id='activate-phone-number' ng-model="args.update_phone_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='phone-number'>[% l("Phone #") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id="phone-number" type='tel' 
+          ng-model="args.phone_notify" ng-disabled="!args.update_phone_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-sms-number" class="sr-only">[% l('Update SMS Number') %]</label>
+        <input id='activate-sms-number' ng-model="args.update_sms_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='sms-number'>[% l("Text/SMS #") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id="sms-number" type='tel' 
+          ng-model="args.sms_notify" ng-disabled="!args.update_sms_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-sms-carrier" class="sr-only">[% l('Update SMS Carrier') %]</label>
+        <input id='activate-sms-carrier' ng-model="args.update_sms_carrier" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='sms-carrier'>[% l("SMS Carrier") %]</label>
+      </div>
+      <div class="col-md-7">
+        <select id='sms-carrier'
+          ng-model="args.sms_carrier" 
+          ng-disabled="!args.update_sms_carrier"
+          ng-options="carrier.name() for carrier in sms_carriers">
+        </select>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2
new file mode 100644
index 0000000..22e74e1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2
@@ -0,0 +1,63 @@
+<div class="">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="ok()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Hold Slip') %]</h4>
+  </div>
+  <div class="modal-body">
+    <div ng-switch on="hold.behind_desk">
+      <div ng-switch-when="t">
+        [% l('This item should be routed to the [_1]Private Holds Shelf[_2]',
+          '<strong>','</strong>') %]
+      </div>
+      <div ng-switch-when="f">
+        [% l('This item should be routed to the [_1]Public Holds Shelf[_2]',
+          '<strong>','</strong>') %]
+      </div>
+    </div>
+    <br/>
+    <div>
+      <span>[% l('Item Barcode:') %]</span>
+      <span>{{copy.barcode}}</span>
+    </div>
+    <div>
+      <span>[% l('Title:') %]</span>
+      <span>{{title}}</span>
+    </div>
+    <div>
+      <span>[% l('Author:') %]</span>
+      <span>{{author}}</span>
+    </div>
+    <br/>
+    <div>
+    
+    <div ng-show="patron.alias">
+      [% l('Hold for patron {{patron.alias}}') %]
+    </div>
+    <div ng-hide="patron.alias">
+      [% |l %]
+      Hold for patron {{patron.family_name}}, 
+      {{patron.first_given_name}} {{patron.second_given_name}}
+      [% END %]
+    </div>
+    <div>
+      <span>[% l('Patron Barcode:') %]</span>
+      <span>{{patron.card.barcode}}</span>
+    </div>
+    <br/>
+    <div>
+      <span>[% l('Request Date:') %]</span>
+      <span>{{hold.request_time | date:'shortDate'}}</span>
+    </div>
+    <div>
+      <span>[% l('Slip Date:') %]</span>
+      <span>{{today | date:'shortDate'}}</span>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="button" class="btn btn-primary"
+      ng-click="print()" value="[% l('Print') %]"/>
+    <input type="submit" class="btn btn-warning"
+      ng-click="ok()" value="[% l('Do Not Print') %]"/>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2
new file mode 100644
index 0000000..858e242
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2
@@ -0,0 +1,25 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Mark Item(s) Claims Returned') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div class="pad-vert row">
+    <div class="col-md-12">
+      [% l('Enter claims returned date for items: [_1]', 
+        '{{args.barcodes.toString()}}') %]
+    </div>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-5">
+      <input eg-date-input required 
+        class="form-control" ng-model="args.date"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2
new file mode 100644
index 0000000..1cd94f0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2
@@ -0,0 +1,45 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Apply Standing Penalty / Message') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-8">
+          <ul class="nav nav-pills">
+            <!-- 21 == SILENT_NOTE -->
+            <li ng-class="{active : args.penalty == 21}">
+              <a href ng-click="args.penalty=21">[% l('Note') %]</a>
+            </li>
+            <!-- 20 == ALERT_NOTE -->
+            <li ng-class="{active : args.penalty == 20}">
+              <a href ng-click="args.penalty=20">[% l('Alert') %]</a>
+            </li>
+            <!-- 25 == STAFF_CHR -->
+            <li ng-class="{active : args.penalty == 25}">
+              <a href ng-click="args.penalty=25">[% l('Block') %]</a>
+            </li>
+          </ul>
+        </div>
+        <div class="col-md-4 pull-right">
+          <select class="form-control" ng-model="args.penalty">
+            <option ng-selected="args.penalty < 100"></option>
+            <option ng-repeat="penalty in penalties" 
+              value="{{penalty.id()}}">{{penalty.label()}}</option>
+          </select>
+        </div>
+      </div>
+      <div class="form-group row pad-vert">
+        <div class="col-md-12">
+          <textarea class="form-control" 
+            ng-model="args.note" placeholder="[% l('Note...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2
new file mode 100644
index 0000000..345848e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2
@@ -0,0 +1,25 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(count)" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Enter the number of {{type.name()}} circulating') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <label for="noncat-count" class="sr-only">[% l('Count') %]</label>
+      <input type="number" class="form-control" focus-me='focusMe' 
+        required id="noncat-title" ng-model="count" 
+        min="1" max="{{noncatMax}}"
+        placeholder="[% l('Count...') %]"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+        ng-disabled="form.$invalid" value="[% l('OK') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2
new file mode 100644
index 0000000..fc14ec3
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2
@@ -0,0 +1,44 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(precatArgs)" role="form">
+  <div class="">
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" 
+          ng-click="cancel()" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">
+          [% l('Barcode "{{precatArgs.copy_barcode}}" was mis-scanned or is a non-cataloged item.') %]
+        </h4>
+      </div>
+      <div class="modal-body">
+        <div class="form-group">
+          <label for="precat-title">[% l('Title') %]</label>
+          <input type="text" class="form-control" focus-me='focusMe' required
+            id="precat-title" ng-model="precatArgs.dummy_title" placeholder="[% l('Title...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-author">[% l('Author') %]</label>
+          <input type="text" class="form-control" id="precat-author"
+            ng-model="precatArgs.dummy_author" placeholder="[% l('Author...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-isbn">[% l('ISBN') %]</label>
+          <input type="text" class="form-control" id="precat-isbn"
+            ng-model="precatArgs.dummy_isbn" placeholder="[% l('ISBN...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-circmod">[% l('Circulation Modifier') %]</label>
+          <select class="form-control" id="precat-circmod" 
+            ng-model="precatArgs.circ_modifier">
+            <option ng-repeat="mod in circModifiers" 
+              value="{{mod.code()}}">{{mod.name()}}</option>
+          </select>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <input type="submit" class="btn btn-primary" value="[% l('Precat Checkout') %]"/>
+        <button class="btn btn-warning" ng-click="cancel()"
+            ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+      </div>
+    </div> <!-- modal-content -->
+  </div> <!-- modal-dialog -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2
new file mode 100644
index 0000000..1491fb2
--- /dev/null
+++ b/Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2
@@ -0,0 +1,65 @@
+<div class="">
+  <div class="">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="ok()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Transit Slip') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div>
+        <span>[% l('Destination') %]</span>
+        <strong>{{dest_location.shortname}}</strong>
+      </div>
+      <br/>
+      <div>
+        <address>
+          <strong>{{dest_location.name}}</strong><br>
+          {{dest_address.street1}} {{dest_address.street2}}<br/>
+          {{dest_address.city}}, {{dest_address.state}} {{dest_address.post_code}}<br/>
+          <abbr title="[% l('Phone') %]">P:</abbr> {{dest_location.phone}}
+        </address>
+      </div>
+      <div>
+        <span>[% l('Item Barcode:') %]</span>
+        <span>{{copy.barcode}}</span>
+      </div>
+      <div>
+        <span>[% l('Title:') %]</span>
+        <span>{{title}}</span>
+      </div>
+      <div>
+        <span>[% l('Author:') %]</span>
+        <span>{{author}}</span>
+      </div>
+      <div ng-if="patron">
+        <br/>
+        <div>[% |l %]
+          Hold for patron {{patron.family_name}}, 
+          {{patron.first_given_name}} {{patron.second_given_name}}
+          [% END %]
+        </div>
+        <div>
+          <span>[% l('Patron Barcode:') %]</span>
+          <span>{{patron.card.barcode}}</span>
+        </div>
+        <br/>
+        <div>
+          <span>[% l('Request Date:') %]</span>
+          <span>{{hold.request_time | date:'shortDate'}}</span>
+        </div>
+      </div>
+      <div>
+        <div>
+          <span>[% l('Slip Date:') %]</span>
+          <span>{{today | date:'shortDate'}}</span>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="button" class="btn btn-primary"
+        ng-click="print()" value="[% l('Print') %]"/>
+      <input type="submit" class="btn btn-warning"
+        ng-click="ok()" value="[% l('Do Not Print') %]"/>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/config.tt2 b/Open-ILS/src/templates/staff/config.tt2
new file mode 100644
index 0000000..c563d7a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/config.tt2
@@ -0,0 +1,13 @@
+[%- 
+
+# FIXME: update via build process
+EVERGREEN_VERSION='0.0.1' 
+
+# create script / css refs to individual files instead of using
+# compressed build files.  Use this for development and debugging.
+EXPAND_WEB_IMPORTS = 1; 
+
+# path to build files (js, css, fonts)
+WEB_BUILD_PATH = ctx.media_prefix _ '/js/ui/default/staff/build/';
+
+%]
diff --git a/Open-ILS/src/templates/staff/css/circ.css.tt2 b/Open-ILS/src/templates/staff/css/circ.css.tt2
new file mode 100644
index 0000000..8d6c139
--- /dev/null
+++ b/Open-ILS/src/templates/staff/css/circ.css.tt2
@@ -0,0 +1,60 @@
+/** style to make a grid look like a striped table */
+#patron-summary-grid div.row {padding: 3px; border-right: 2px solid rgb(248, 248, 248);}
+#patron-summary-grid div.row:nth-child(odd) {background-color: rgb(248, 248, 248);}
+
+/* there are bootstrap tyles for error, warning, etc., 
+but the ones I'm finding aren't quite cutting it..*/
+.patron-summary-alert {color: red; font-weight:bold}
+.patron-summary-alert-small {color: red}
+.patron-summary-divider { border-top: 1px solid #CCC}
+.patron-summary-act-link {font-size: .8em;}
+
+/* FIXME: use .barcode instead */
+#patron-checkout-barcode,
+#patron-renewal-barcode,
+#patron-checkin-barcode { width: 16em; }
+
+#patron-search-form div.form-group {
+  margin-bottom: 5px;
+}
+
+/* let search form elements fill their containers w/ slight padding */
+#patron-search-form-row {margin-left: 0px;}
+#patron-search-form div.col-md-2 { padding: 2px; }
+#patron-search-form input:not([type="checkbox"]) { width: 100%; }
+#patron-search-form .eg-org-selector,
+#patron-search-form .eg-org-selector button,
+#patron-search-form .patron-search-selector,
+  #patron-search-form .patron-search-selector button { 
+  width: 100%; 
+  text-align: left
+}
+
+
+#patron-payments-spreadsheet {
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #aaa;
+}
+
+#patron-payments-spreadsheet .flex-cell {
+  margin: 2px;
+}
+
+#patron-payments-spreadsheet .flex-cell.well {
+  min-height: 1.5em;
+  margin-bottom: 0px; /* bootstrap default is 20px */
+}
+
+#hold-notify-settings div.row { margin-top: 12px; }
+#hold-notify-settings div.row:not(.header-row):nth-child(odd) {
+  background-color: rgb(248, 248, 248);
+}
+#hold-notify-settings div.row:not(.header-row) {
+  border-bottom: 1px solid #CCC;
+}
+
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/css/print.css.tt2 b/Open-ILS/src/templates/staff/css/print.css.tt2
new file mode 100644
index 0000000..5410ba9
--- /dev/null
+++ b/Open-ILS/src/templates/staff/css/print.css.tt2
@@ -0,0 +1,13 @@
+
+/* hide everything but the print div */
+head { display: none; } /* just to be safe */
+body div:not([id="print-div"]) { display:none }
+
+div { display: none }
+#print-div { display: block }
+#print-div div { display: block }
+#print-div pre { border: none }
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/css/style.css.tt2 b/Open-ILS/src/templates/staff/css/style.css.tt2
new file mode 100644
index 0000000..300668c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/css/style.css.tt2
@@ -0,0 +1,412 @@
+/* --------------------------------------------------------------------------
+ * Simple default navbar style adjustements to apply the Evergreen color.
+ * TODO: style other components to match EG color scheme
+ */
+#top-navbar.navbar-default {
+    background: -webkit-linear-gradient(#00593d, #007a54);
+    background-color: #007a54;
+    color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a {
+    color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a:hover {
+    color: #ddd;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a .caret {
+    border-top-color: #fff;
+    border-bottom-color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a:hover .caret {
+    border-top-color: #ddd;
+    border-bottom-color: #ddd;
+}
+
+/* status bar along the bottom of the page ------------------------ */
+/* decrease padding to decrease overall height */
+
+/** TODO:move status bar items into navbar config entry (top-right)
+ * to avoid body padding weirdness.  Or if we want a permenently
+ * visible status bar, maybe put it just below the navbar.. */
+
+/* bottom padding ensures no body content is hidden behind the status
+ * bar.  When content reaches the status bar a scroll bar appears */
+/*body { padding-bottom: 26px; }*/
+
+#status-bar {
+  min-height:1.8em !important;
+}
+#status-bar > ul {
+  margin-right:6px; 
+}
+#status-bar li {
+  padding-left: 10px;
+}
+#status-bar > li > a {
+  padding-top:5px !important; 
+  padding-bottom:5px !important;
+}
+.status-bar-connected {
+  color: rgb(92, 184, 92); /* success */
+}
+
+/* --------------------------------------------------------------------------
+ * Structural modifications
+ */
+
+#top-content-container {
+    /* allow the primary container to occupy most of the page,
+     * but leave some narrow gutters along the side, much 
+     * narrower than the default Bootstrapp container gutters.
+     */
+    width: 95%;
+}
+
+
+/* --------------------------------------------------------------------------
+ * Temporaray local CSS required to make angular-ui-bootstrap
+ * version 0.6.0 look right with Bootstrap CSS 3.0
+ */
+.nav, .pagination, .carousel a { cursor: pointer; }
+/*
+.modal {
+    display: block;
+    height: 0;
+    overflow: visible;
+}
+.modal-body:before,
+.modal-body:after {
+    display: table;
+    content: " ";
+}
+.modal-header:before,
+.modal-header:after {
+    display: table;
+    content: " ";
+}
+*/
+
+/* --------------------------------------------------------------------------
+/* Form Validation CSS - http://docs.angularjs.org/guide/forms
+ * TODO: these colors are harsh and don't fit the EG color scheme
+ */
+.form-validated input.ng-invalid.ng-dirty {
+  background-color: #FA787E;
+}
+.form-validated input.ng-valid.ng-dirty {
+  background-color: #78FA89;
+}
+
+/* --------------------------------------------------------------------------
+ * Local style
+ */
+
+#splash-nav .panel-body div {
+    padding-bottom: 10px;
+}
+
+table.list tr.selected td { /* deprecated? */
+    color: #2a6496;
+    background-color: #F5F5F5;
+}
+
+.pad-horiz {padding : 0px 10px 0px 10px; }
+.pad-vert {padding : 20px 0px 10px 0px;}
+.pad-left {padding-left: 10px;}
+.pad-right {padding-right: 10px;}
+.pad-all-min {padding : 5px; }
+.pad-all {padding : 10px; }
+
+#print-div { display: none; }
+
+/* by default, give all tab panes some top padding */
+.tab-pane { padding-top: 20px; }
+
+.nav-pills-like-tabs {
+    border-bottom:1px solid #CCC;
+}
+
+.btn-pad {
+  /* sometimes you don't want buttons scrunched together -- add some margin */
+  margin-left: 10px;
+}
+
+.strong-text {
+  font-weight: bold;
+}
+.strong-text-1 {
+  font-size: 110%;
+  font-weight: bold;
+}
+.strong-text-2 {
+  font-size: 120%;
+  font-weight: bold;
+}
+.strong-text-3 {
+  font-size: 130%;
+  font-weight: bold;
+}
+.strong-text-4 {
+  font-size: 140%;
+  font-weight: bold;
+}
+
+.currency-input {
+  width: 8em;
+}
+
+/* barcode inputs are everywhere.  Let's have a consistent style. */
+.barcode { width: 16em !important; }
+
+/* bootstrap alerts are heavily padded.  use this to reduce */
+.alert-less-pad {padding: 5px;}
+
+/* text displayed inside a <progressbar>, typically the max/progress values */
+.progressbar-text {
+  color:black;
+  white-space:nowrap;
+}
+
+/* embedded UI iframe */
+.eg-embed-frame {
+  width: 100%;
+}
+.eg-embed-frame iframe {
+  width: 100%;
+  border: none;
+  margin: 0px;
+  padding: 0px;
+}
+
+/* ----------------------------------------------------------------------
+ * Grid
+ * ---------------------------------------------------------------------- */
+
+.eg-grid-primary-label {
+  font-weight: bold;
+  font-size: 120%;
+}
+
+/* odd/even row styling */
+.eg-grid-content-body > div:nth-child(odd):not(.eg-grid-row-selected) {
+  background-color: rgb(248, 248, 248);
+}
+
+.eg-grid-row {
+  width: 100%;
+  display: flex;
+  border: 1px solid #ccc;
+}
+
+.eg-grid-row:not(.eg-grid-header-row):not(.eg-grid-conf-row) {
+  /* TODO: remove, pretty sure this is no longer needed w/ nowrap */
+  /*height: 1.8em;*/
+}
+
+.eg-grid-action-row {
+  border: none;
+  /* margin should not have to be this large; something's up */
+  margin-bottom: 12px;
+}
+
+.eg-grid-header-row { 
+  font-weight: bold; 
+}
+
+.eg-grid-header-row > .eg-grid-cell {
+  border-right: 1px solid #CCC;
+  text-align: center;
+
+  /* vertically align header cell text by treating 
+     each header cell as a vertical flex container */
+  display:flex;
+  flex-direction:column;
+  justify-content:flex-end;
+}
+
+.eg-grid-cell {
+  /* avoid text flowing into adjacent cells */
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+/* in config display, make cells more obvious */
+.eg-grid-as-conf .eg-grid-row {
+  border: 1px solid #777;
+}
+.eg-grid-as-conf .eg-grid-cell {
+  border-right: 1px solid #777;
+}
+
+/* stock columns need fixed-width controls */
+.eg-grid-cell-stock {
+  width: 2.2em;
+  text-align: center;
+}
+
+/* the conf header must be twice the stock flex */
+.eg-grid-cell-conf-header {
+  width: 4.4em;
+  font-weight: bold;
+}
+
+.eg-grid-row-selected {
+  color: rgb(51, 51, 51);
+  background-color: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+/* Improve ::selection styling by only allowing selection on text
+ * content cells within the main body of the grid.  Otherwise, the browser 
+ * styles row background and text (all dark blue?) when shift-click or 
+ * click-drag is used.
+ */
+.eg-grid-content-body .eg-grid-row {
+  user-select:none;
+  -moz-user-select: none;
+  -webkit-user-select: none;
+}
+.eg-grid-content-body .eg-grid-cell-content {
+  user-select:text;
+  -moz-user-select: text;
+  -webkit-user-select: text;
+}
+.eg-grid-cell-content::-moz-selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+.eg-grid-cell-content::selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+.eg-grid-conf-cell-entry {
+  width:98%;
+  text-align:center;
+  padding: 3px;
+}
+
+.eg-grid-conf-cell-entry:not(:first-child) {
+  border-top:1px solid #ccc;
+}
+
+.eg-grid-conf-row {
+  background-color: #dff0d8;
+  border-color: #d6e9c6;
+}
+
+.eg-grid-conf-row:first-child {
+  /* alignment fix; account for one missing border */
+  padding-right: 1px;
+}
+
+.eg-grid-column-move-handle:hover {
+  cursor: move;
+}
+
+.eg-grid-column-move-handle-active,
+.eg-grid-column-move-handle-active:active {
+  /* similar to label-primary, sans padding */
+  background-color: rgb(66, 139, 202);
+  color: #fff;
+}
+
+.eg-grid-col-hover {
+  /* similar to label-success, sans padding */
+  background-color: rgb(92, 184, 92);
+  color: #fff;
+}
+
+.eg-grid-column-resize-handle {
+  height: 100%;
+}
+.eg-grid-column-resize-handle:hover {
+  cursor: col-resize;
+}
+
+/* for these to be useful, they would have to be applied 
+ * to the dragover targets.  not yet done */
+.eg-grid-column-resize-handle-west {
+  cursor: w-resize;
+}
+.eg-grid-column-resize-handle-east {
+  cursor: e-resize;
+}
+
+.eg-grid-menu-item {
+  margin-right: 10px;
+}
+
+
+/* hack to make the header columns line up with the content columns
+   when the scroll bar is visible along the right side of the content
+   columns. TODO: if this varies enough by browser, we'll need to
+   calculate the width instead. */
+/*
+.eg-grid-scroll > .eg-grid-header-row, 
+.eg-grid-scroll > .eg-grid-conf-row { 
+  padding-right: 15px;
+}
+.eg-grid-scroll > .eg-grid-content-body {
+  overflow-y:scroll; 
+  height: 600px; 
+}
+*/
+.eg-grid-column-picker {
+  height: auto;
+  max-height: 400px;
+  overflow: auto;
+  box-shadow: none;
+}
+
+
+/* ----------------------------------------------------------------------
+ * /Grid
+ * ---------------------------------------------------------------------- */
+
+
+/* simple flex container for consistent-width cell-based structures */
+.flex-container-striped > .flex-row:nth-child(odd) {
+  background-color: #f5f5f5;
+}
+.flex-container-bordered .flex-cell {
+  border-bottom: 1px solid #ddd;
+}
+.flex-row {
+  display: flex;
+}
+.flex-row.padded div {
+  padding: 5px;
+}
+.flex-row.left-anchored > div {
+  margin-right: 10px;
+}
+.flex-cell {
+  flex: 1;
+  padding: 4px; /* bootstrap default is much bigger */
+}
+.flex-cell.well {
+  min-height: 2.5em; /* don't let empty wells scrunch down */
+  margin-bottom: 5px; /* bootstrap default is 20px */
+}
+.flex-2 { /* meh, convience */
+  flex: 2;
+}
+
+/* TODO: match media size to Bootstrap "md" col resizing */
+ at media all and (max-width: 800px) {
+  .flex-row {
+    flex-direction: column;
+  }
+  .eg-grid-row {
+    flex-direction: column;
+  }
+}
+
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/index.tt2 b/Open-ILS/src/templates/staff/index.tt2
new file mode 100644
index 0000000..b2e25f8
--- /dev/null
+++ b/Open-ILS/src/templates/staff/index.tt2
@@ -0,0 +1,17 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Home"); 
+  ctx.page_app = "egHome";
+%]
+
+[% BLOCK APP_JS %]
+<!-- needed for login -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+<!-- splash / login page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/app.js"></script>
+[% END %]
+
+<div ng-view></div> 
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2
new file mode 100644
index 0000000..6d0e6d8
--- /dev/null
+++ b/Open-ILS/src/templates/staff/navbar.tt2
@@ -0,0 +1,244 @@
+<!-- 
+  main navigation bar
+    
+  note the use of target="_self" for navigation links.
+  this tells angular to treat the href as a new page 
+  and not an intra-app route.  This is necessary when
+  moving between applications.
+
+  For icons, see http://getbootstrap.com/components/#glyphicons
+-->
+
+<div id="top-navbar" role="navigation"
+  class="navbar navbar-default navbar-static-top" role="navigation">
+
+  <!-- navbar-header here needed for supporting angular-ui-bootstrap -->
+  <div class="navbar-header">
+    <button type="button" class="navbar-toggle" 
+        ng-init="navCollapsed = true" ng-click="navCollapsed = !navCollapsed">
+      <span class="sr-only">[% l('Toggle navigation') %]</span>
+      <span class="icon-bar"></span>
+      <span class="icon-bar"></span>
+      <span class="icon-bar"></span>
+    </button>
+  </div>
+
+  <div class="navbar-collapse collapse" ng-class="!navCollapsed && 'in'">
+    <ul class="nav navbar-nav">
+      <li><a href='./' title="[% l('Home') %]" target="_self"
+        class="glyphicon glyphicon-home"></a><li>
+
+      <!-- search -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle"
+          data-toggle="dropdown">[% l('Search') %]
+          <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/search" target="_self"
+              eg-accesskey="[% l('alt+s') %]" 
+              eg-accesskey-desc="[% l('Patron search by name, address, etc.') %]">
+              <span class="glyphicon glyphicon-user"></span>
+              <span eg-accesskey-label>[% l('Search for Patrons') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./cat/item/search" target="_self">
+              <span class="glyphicon glyphicon-barcode"></span>
+              <span>[% l('Search for Copies by Barcode') %]</span>
+            </a>
+          </li>
+        </ul>
+      </li>
+
+
+      <!-- circulation -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle"
+          data-toggle="dropdown">[% l('Circulation') %]
+          <b class="caret"></b>
+        </a>
+
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/bcsearch" target="_self">
+              <span class="glyphicon glyphicon-export"></span>
+              [% l('Check Out') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/checkin/checkin" target="_self">
+              <span class="glyphicon glyphicon-import"></span>
+              [% l('Check In') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/checkin/capture" target="_self">
+              <span class="glyphicon glyphicon-pushpin"></span>
+              [% l('Capture Holds') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/holds/pull" target="_self">
+              <span class="glyphicon glyphicon-th-list"></span>
+              [% l('Pull List for Hold Requests') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/renew/renew" target="_self">
+              <span class="glyphicon glyphicon-refresh"></span>
+              [% l('Renew Items') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/register" target="_self">
+              <span class="glyphicon glyphicon-user"></span>
+              [% l('Register Patron') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/last" target="_self">
+              <span class="glyphicon glyphicon-share-alt"></span>
+              [% l('Retrieve Last Patron') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/pending/list" target="_self">
+              <span class="glyphicon glyphicon-thumbs-up"></span>
+              [% l('Pending Patrons') %]
+            </a>
+          </li>
+          <li class="divider"></li>
+          <li>
+            <a href="./circ/patron/credentials" target="_self">
+              <span class="glyphicon glyphicon-ok"></span>
+              <span>[% l('Verify Credentials') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./circ/in_house_use/index" target="_self">
+              <span class="glyphicon glyphicon-pencil"></span>
+              <span>[% l('Record In-House Use') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./circ/holds/shelf" target="_self">
+              <span class="glyphicon glyphicon-tasks"></span>
+              <span>[% l('Holds Shelf') %]</span>
+            </a>
+          </li>
+          <li class="divider"></li>
+          <li>
+            <a href="./cat/item/replace_barcode/index" target="_self">
+              <span class="glyphicon glyphicon-barcode"></span>
+              <span>[% l('Replace Barcode') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./cat/item/missing_pieces" target="_self">
+              <span class="glyphicon glyphicon-th"></span>
+              <span>[% l('Scan Item as Missing Pieces') %]</span>
+            </a>
+          </li>
+        </ul>
+      </li><!-- circ -->
+
+      <!-- cataloging -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">[% l('Cataloging') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./cat/catalog/index" target="_self">
+              <span class="glyphicon glyphicon-search"></span>
+              [% l('Search the Catalog') %]
+            </a>
+          </li>
+          <li>
+            <a href="./cat/bucket/record/view" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('Record Buckets') %]
+            </a>
+          </li>
+       </ul>
+      </li>
+
+      <!-- admin -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">[% l('Administration') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./admin/workstation/index" target="_self">
+              <span class="glyphicon glyphicon-hdd"></span>
+              [% l('Workstation') %]
+            </a>
+          </li>
+          <li>
+            <a href="./admin/user_perms" target="_self">
+              <span class="glyphicon glyphicon-user"></span>
+              [% l('User Permission Editor') %]
+            </a>
+          </li>
+        </ul> <!-- admin dropdown -->
+      </li>
+    </ul> <!-- end left side entries -->
+
+    <!-- entries along the right side of the navbar -->
+    <ul class="nav navbar-nav navbar-right" style='margin-right: 6px;'>
+      <li>
+        <a ng-cloak ng-show="username" 
+          ng-init="workstation = '[% l('<no workstation>') %]'">
+            [% l('{{username}} @ {{workstation}}') %]
+        </a>
+      </li>
+
+      <!-- locale selector.  
+        only shown if multiple locales are registered -->
+      [% IF ctx.locales.keys.size > 1 %]
+      <li class="dropdown">
+        <a href='' class="dropdown-toggle" data-toggle="dropdown">
+            [% lcl = ctx.locale;  ctx.locales.$lcl %]
+            <span class="glyphicon glyphicon-flag"></span>
+        </a>
+        <ul class="dropdown-menu">
+        [% FOR locale IN ctx.locales.keys.sort %]
+          <!-- disable the selected locale -->
+          <li ng-class="{disabled : '[% ctx.locale %]'=='[% locale %]'}">
+            <a href="" ng-click="applyLocale('[% locale %]')">
+                [% ctx.locales.$locale %]
+            </a>
+          </li>
+        [% END %]
+        </ul>
+      </li>
+      [% END %]
+
+      <li class="dropdown" ng-show="username">
+        <a href='' class="dropdown-toggle glyphicon glyphicon-list" 
+          data-toggle="dropdown"></a>
+        <ul class="dropdown-menu">
+          <li class="disabled">
+            <a href="" ng-click="" target="_self">
+              <span class="glyphicon glyphicon-random"></span>
+              [% l('Change Operator') %]
+            </a>
+          </li>
+          <li>
+            <a href="./login" ng-click="logout()" target="_self">
+              <span class="glyphicon glyphicon-log-out"></span>
+              [% l('Log Out') %]
+            </a>
+          </li>
+        </ul>
+      </li>
+    </ul>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/share/README b/Open-ILS/src/templates/staff/share/README
new file mode 100644
index 0000000..bcbddf5
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/README
@@ -0,0 +1,5 @@
+Location for globally shared template files.  These are generally used 
+by AngularJS directives.
+
+App-specific shared templates should live within the application's
+directory.
diff --git a/Open-ILS/src/templates/staff/share/print_templates/index.tt2 b/Open-ILS/src/templates/staff/share/print_templates/index.tt2
new file mode 100644
index 0000000..0bcedf0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/index.tt2
@@ -0,0 +1,2 @@
+[% USE CGI %]
+[% l('Print Template Not Found: [_1]', CGI.url("-path",1,"-relative",1)) %]
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2
new file mode 100644
index 0000000..fe6851a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2
@@ -0,0 +1,69 @@
+Welcome to {{current_location.name}}!<br/>
+A receipt of your  transaction:<hr/>
+
+<table style="width:100%"> 
+  <tr> 
+    <td>[% l('Original Balance:') %]</td> 
+    <td align="right">{{previous_balance | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Method:') %]</td> 
+    <td align="right">
+      <div ng-switch="payment_type">
+        <div ng-switch-when="cash_payment">[% l('Cash') %]</div>
+        <div ng-switch-when="check_payment">[% l('Check') %]</div>
+        <div ng-switch-when="credit_card_payment">[% l('Credit Card') %]</div>
+        <div ng-switch-when="credit_payment">[% l('Patron Credit') %]</div>
+        <div ng-switch-when="work_payment">[% l('Work') %]</div>
+        <div ng-switch-when="forgive_payment">[% l('Forgive') %]</div>
+        <div ng-switch-when="goods_payment">[% l('Goods') %]</div>
+      </div>
+    </td>
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Received:') %]</td> 
+    <td align="right">{{payment_total | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Applied:') %]</td> 
+    <td align="right">{{payment_applied | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Billings Voided:') %]</td> 
+    <td align="right">{{amount_voided | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Change Given:') %]</td> 
+    <td align="right">{{change_given | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('New Balance:') %]</td> 
+    <td align="right">{{new_balance | currency}}</td> 
+  </tr> 
+</table> 
+
+<p>[% l('Note: [_1]', '{{payment_note}}') %]</p>
+
+<p>
+[% l('Specific Bills') %]
+  <blockquote>
+    <div ng-repeat="payment in payments">
+      <table style="width:100%">
+        <tr>
+          <td>[% l('Bill # [_1]', '{{payment.xact.id}}') %]</td>
+          <td>{{payment.xact.summary.last_billing_type}}</td>
+          <td>[% l('Received: [_1]', '{{payment.amount | currency}}') %]</td>
+        </tr>
+        <tr>
+          <td colspan="5">
+            {{payment.xact.copy_barcode}} {{payment.xact.title}}
+          </td>
+        </tr>
+      </table>
+      <br/>
+    </div>
+  </blockquote>
+</p> 
+<hr/>
+<br/><br/> 
+{{current_location.shortname}} {{today | date:'short'}}
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2
new file mode 100644
index 0000000..c99cb4d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2
@@ -0,0 +1,49 @@
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+  <div ng-repeat="xact in transactions">
+    <dt><b>Bill #{{xact.id}}</b></dt>
+    <dd>
+    <table> 
+      <tr valign="top">
+        <td>[% l('Date:') %]</td>
+        <td>{{xact.xact_start | date:'short'}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Type') %]:</td>
+        <td>{{xact.summary.xact_type}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Billing') %]:</td>
+        <td>{{xact.summary.last_billing_type}}<br/>
+            {{xact.summary.last_billing_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Billed') %]:</td>
+        <td>{{xact.summary.total_owed | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Payment') %]:</td>
+        <td>{{xact.summary.last_payment_type}}<br/>
+            {{xact.summary.last_payment_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Paid') %]:</td>
+        <td>{{xact.summary.total_paid | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td><b>[% l('Balance') %]:</b></td>
+        <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+      </tr> 
+    </table>
+    </dd>
+    <br/>
+  </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2
new file mode 100644
index 0000000..c99cb4d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2
@@ -0,0 +1,49 @@
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+  <div ng-repeat="xact in transactions">
+    <dt><b>Bill #{{xact.id}}</b></dt>
+    <dd>
+    <table> 
+      <tr valign="top">
+        <td>[% l('Date:') %]</td>
+        <td>{{xact.xact_start | date:'short'}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Type') %]:</td>
+        <td>{{xact.summary.xact_type}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Billing') %]:</td>
+        <td>{{xact.summary.last_billing_type}}<br/>
+            {{xact.summary.last_billing_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Billed') %]:</td>
+        <td>{{xact.summary.total_owed | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Payment') %]:</td>
+        <td>{{xact.summary.last_payment_type}}<br/>
+            {{xact.summary.last_payment_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Paid') %]:</td>
+        <td>{{xact.summary.total_paid | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td><b>[% l('Balance') %]:</b></td>
+        <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+      </tr> 
+    </table>
+    </dd>
+    <br/>
+  </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2
new file mode 100644
index 0000000..7bc56e4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You checked in the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkin in checkins">
+      <div>{{checkin.title}}</div>
+      <span>[% l('Barcode: ') %]</span>
+      <span>{{checkin.copy_barcode}}</span>
+      <span>[% l('Call Number: ') %]</span>
+      <span>{{checkin.call_number.label || "[% l("Not Cataloged") %]"}}</span>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <br/>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2
new file mode 100644
index 0000000..1f1218e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You checked out the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in circulations">
+      <div>{{checkout.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.copy.barcode}}',
+        '{{checkout.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2
new file mode 100644
index 0000000..ce23fe4
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2
@@ -0,0 +1,29 @@
+<table id='pull-list-template-table'>
+  <style>
+    #pull-list-template-table td,
+    #pull-list-template-table th {
+      padding: 5px;
+      border: 1px solid #000;
+    }
+  </style>
+  <thead>
+    <tr>
+      <th>[% l('Type') %]</th>
+      <th>[% l('Title') %]</th>
+      <th>[% l('Author') %]</th>
+      <th>[% l('Shelf Location') %]</th>
+      <th>[% l('Call Number') %]</th>
+      <th>[% l('Barcode/Part') %]</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr ng-repeat="hold_data in holds">
+      <td>{{hold_data.hold.hold_type}}</td>
+      <td>{{hold_data.title}}</td>
+      <td>{{hold_data.author}}</td>
+      <td>{{hold_data.copy.location.name}}</td>
+      <td>{{hold_data.volume.label}}</td>
+      <td>{{hold_data.copy.barcode}} {{hold_data.part.label}}</td>
+    </tr>
+  </tbody>
+</table>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2
new file mode 100644
index 0000000..f1980d7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2
@@ -0,0 +1,37 @@
+<div>
+  <div ng-switch on="hold.behind_desk">
+    <div ng-switch-when="t">
+      [% l('This item needs to be routed to the [_1]Private Holds Shelf[_2].',
+        '<strong>','</strong>') %]
+    </div>
+    <div ng-switch-when="f">
+      [% l('This item needs to be routed to the [_1]Public Holds Shelf[_2].',
+        '<strong>','</strong>') %]
+    </div>
+  </div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+
+  <br/>
+  <br/>
+
+  <div>[% l('Hold for patron [_1], [_2] [_3]',
+    '{{patron.family_given_name}}',
+    '{{patron.first_given_name}}',
+    '{{patron.second_given_name}}') %]</div>
+  <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+  <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+  <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+  <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Request Date: [_1]', 
+    '{{hold.request_time | date:"short"}}') %]</div>
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2
new file mode 100644
index 0000000..6b36c25
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2
@@ -0,0 +1,34 @@
+<div>
+  <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+  <div>{{dest_location.name}}</div>
+  <div>{{dest_address.street1}}
+  <div>{{dest_address.street2}}</div>
+  <div>{{dest_address.city}},
+       {{dest_address.state}}
+       {{dest_address.post_code}}</div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+  [% l('Author: [_1]', '{{author}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Hold for patron [_1], [_2] [_3]',
+    '{{patron.family_given_name}}',
+    '{{patron.first_given_name}}',
+    '{{patron.second_given_name}}') %]</div>
+  <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+  <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+  <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+  <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Request Date: [_1]', 
+    '{{hold.request_time | date:"short"}}') %]</div>
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2
new file mode 100644
index 0000000..8d3061c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2
@@ -0,0 +1,29 @@
+<div>
+  <div>[% l('Holds for record: [_1]', '{{holds[0].title}}') %]</div>
+  <hr/>
+  <style>#holds-for-bib-table td { padding: 5px; }</style>
+  <table id="holds-for-bib-table">
+    <thead>
+      <tr>
+        <th>[% l('Request Date') %]</th>
+        <th>[% l('Patron Barcode') %]</th>
+        <th>[% l('Patron Last') %]</th>
+        <th>[% l('Patron Alias') %]</th>
+        <th>[% l('Current Copy') %]</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="hold in holds">
+        <td>{{hold.hold.request_time | date:'short'}}</td>
+        <td>{{hold.patron_barcode}}</td>
+        <td>{{hold.patron_last}}</td>
+        <td>{{hold.patron_alias}}</td>
+        <td>{{hold.copy.barcode}}</td>
+      </tr>
+    </tbody>
+  </table>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('Printed by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2
new file mode 100644
index 0000000..492454c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2
@@ -0,0 +1,14 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You have the following title on hold:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="hold in holds">
+      <div>{{hold.title}}</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2
new file mode 100644
index 0000000..fee903b
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You have the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in circulations">
+      <div>{{checkout.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.copy.barcode}}',
+        '{{checkout.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2
new file mode 100644
index 0000000..c1a3e37
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2
@@ -0,0 +1,12 @@
+<div>
+  <div>
+    {{patron.first_given_name}} 
+    {{patron.second_given_name}} 
+    {{patron.family_name}}
+  </div>
+  <div>{{address.street1}}</div>
+  <div ng-if="address.street2">{{address.street2}}</div>
+  <div>
+    {{address.city}}, {{address.state}} {{address.post_code}}
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2
new file mode 100644
index 0000000..5b98683
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2
@@ -0,0 +1,11 @@
+<h3>[% l(
+  'Pertaining to [_1], [_2] [_3] : [_4]',
+  '{{note.usr.family_name}}',
+  '{{note.usr.first_given_name}}',
+  '{{note.usr.second_given_name}}',
+  '{{note.usr.card.barcode}}') %]</h3>
+
+<p>[% l('Created on [_1]', '{{note.create_date | date:"short"}}') %]</p>
+<b>{{note.title}}</b>
+<br/>
+<p>{{note.value}}</p>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2
new file mode 100644
index 0000000..8e96445
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You renewed the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="renewal in circulations">
+      <div>{{renewal.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{renewal.copy.barcode}}',
+        '{{renewal.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2
new file mode 100644
index 0000000..324ad5e
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2
@@ -0,0 +1,21 @@
+<div>
+  <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+  <div>{{dest_location.name}}</div>
+  <div>{{dest_address.street1}}
+  <div>{{dest_address.street2}}</div>
+  <div>{{dest_address.city}},
+       {{dest_address.state}}
+       {{dest_address.post_code}}</div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+  [% l('Author: [_1]', '{{author}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_alert_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_alert_dialog.tt2
new file mode 100644
index 0000000..b71614a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_alert_dialog.tt2
@@ -0,0 +1,16 @@
+<!-- 
+  Generic alert dialog.
+  The only user action allowed is the 'OK' button.
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="ok()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-danger">[% l('Alert') %]</h4>
+  </div>
+  <div class="modal-body">{{message}}</div>
+  <div class="modal-footer">
+    <input type="submit" 
+      class="btn btn-primary" ng-click="ok()" value="[% l('OK') %]"/>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_autogrid.tt2 b/Open-ILS/src/templates/staff/share/t_autogrid.tt2
new file mode 100644
index 0000000..136252c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_autogrid.tt2
@@ -0,0 +1,297 @@
+
+<!-- 
+  Actions row.
+  This sits above the grid and contains the column picker, etc.
+-->
+
+<div class="eg-grid-row eg-grid-action-row">
+
+  <div class="eg-grid-primary-label">{{mainLabel}}</div>
+
+  <div class="btn-group eg-grid-menuiitem" 
+    is-open="gridMenuIsOpen" ng-if="menuLabel" dropdown>
+    <button type="button" class="btn btn-default dropdown-toggle">
+      {{menuLabel}}<span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu">
+      <li ng-repeat="item in menuItems" ng-class="{divider: item.divider}">
+        <a ng-if="!item.divider" href ng-disabled="item.disabled"
+          ng-click="item.handler()">{{item.label}}</a>
+      </li>
+    </ul>
+  </div>
+
+  <!-- if no menu label is present, present menu-items as a 
+       horizontal row of buttons -->
+  <div class="btn-group" ng-if="!menuLabel">
+    <button ng-if="!item.hidden()"
+      class="btn btn-default eg-grid-menu-item"
+      ng-disabled="item.disabled()"
+      ng-repeat="item in menuItems"
+      ng-click="item.handler(item, item.handlerData)">
+      {{item.label}}
+    </button>
+  </div>
+
+  <!-- putting a flex div here forces the remaining content to float right -->
+  <div class="flex-cell"></div>
+
+  <!-- column picker, pager, etc. -->
+  <div class="btn-group column-picker">
+
+    <!-- first page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : onFirstPage()}" 
+      ng-click="offset(0);collect()"
+      title="[% l('Start') %]">
+        <span class="glyphicon glyphicon-fast-backward"></span>
+    </button>
+
+    <!-- previous page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : onFirstPage()}"
+      ng-click="decrementPage()"
+      title="[% l('Previous Page') %]">
+        <span class="glyphicon glyphicon-backward"></span>
+    </button>
+
+    <!-- next page -->
+    <!-- todo: paging needs a total count value to be fully functional -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : !hasNextPage()}"
+      ng-click="incrementPage()"
+      title="[% l('Next Page') %]">
+        <span class="glyphicon glyphicon-forward"></span>
+    </button>
+
+    <!-- actions drop-down menu -->
+    <div class="btn-group" ng-if="actions.length" dropdown>                                                  
+      <button type="button" class="btn btn-default dropdown-toggle"
+        ng-class="{disabled : false}">
+        [% l('Actions') %] <span class="caret"></span>                       
+      </button>                                                              
+      <ul class="dropdown-menu pull-right">                                  
+        <li ng-repeat="action in actions" ng-class="{divider: action.divider}">
+          <a ng-if="!action.divider" href dropdown-toggle
+            ng-click="actionLauncher(action)">{{action.label}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridRowCountIsOpen">
+      <button type="button" title="[% ('Select Row Count') %]"
+        class="btn btn-default dropdown-toggle">
+        [% l('Rows [_1]', '{{limit()}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li ng-repeat="t in [5,10,25,50,100]">
+          <a href ng-click='offset(0);limit(t);collect()'>
+            {{t}}
+          </a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridPageSelectIsOpen">
+      <button type="button" title="[% ('Select Page') %]"
+        class="btn btn-default dropdown-toggle">
+        [% l('Page [_1]', '{{page()}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li>
+          <div class="input-group">
+            <input type="text" class="form-control"
+              ng-model="pageFromUI"
+              ng-click="$event.stopPropagation()"/>
+            <span class="input-group-btn">
+              <button class="btn btn-default" type="button"
+                ng-click="goToPage(pageFromUI);pageFromUI='';">
+                [% l('Go To...') %]
+              </button>
+            </span>
+          </div>
+        </li>
+        <li role="presentation" class="divider"></li>
+        <li ng-repeat="t in [1,2,3,4,5,10,25,50,100]">
+          <a href ng-click='goToPage(t);gridPageSelectIsOpen=false;'>{{t}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridColumnPickerIsOpen">
+      <button type="button" 
+        class="btn btn-default dropdown-toggle">
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu pull-right eg-grid-column-picker">
+        <li><a href ng-click="toggleConfDisplay()">
+          <span class="glyphicon glyphicon-wrench"></span>
+          [% l('Configure Columns') %]
+        </a></li>
+        <li><a href ng-click="saveConfig()">
+          <span class="glyphicon glyphicon-floppy-save"></span>
+          [% l('Save Columns') %]
+        </a></li>
+        <li><a href ng-click="showAllColumns()">
+          <span class="glyphicon glyphicon-resize-full"></span>
+          [% l('Show All Columns') %]
+        </a></li>
+        <li><a href ng-click="hideAllColumns()">
+          <span class="glyphicon glyphicon-resize-small"></span>
+          [% l('Hide All Columns') %]
+        </a></li>
+        <li><a href ng-click="resetColumns()">
+          <span class="glyphicon glyphicon-refresh"></span>
+          [% l('Reset Columns') %]
+        </a></li>
+        <li><a ng-click="generateCSVExportURL()" 
+          download="{{csvExportFileName}}.csv" ng-href="{{csvExportURL}}">
+          <span class="glyphicon glyphicon-download"></span>
+          [% l('Download CSV') %]
+        </a></li>
+        <li><a href ng-click="printCSV()">
+          <span class="glyphicon glyphicon-print"></span>
+          [% l('Print CSV') %]
+        </a></li>
+        <li role="presentation" class="divider"></li>
+        <li ng-repeat="col in columns">
+          <a href ng-click="toggleColumnVisibility(col)">
+              <span ng-if="col.visible" 
+                class="label label-success">&#x2713;</span>
+              <span ng-if="!col.visible" 
+                class="label label-warning">&#x2717;</span>
+              <span>{{col.label}}</span>
+          </a>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+<!-- Grid -->
+<div class="eg-grid" ng-class="{'eg-grid-as-conf' : showGridConf}">
+
+  <!-- import our eg-grid-field defs -->
+  <div ng-transclude></div>
+
+  <div class="eg-grid-row eg-grid-header-row">
+    <div class="eg-grid-cell eg-grid-cell-stock">
+      <div title="[% l('Row Number Column') %]">[% l('#') %]</div>
+    </div>
+    <div class="eg-grid-cell eg-grid-cell-stock">
+      <div>
+        <input title="[% l('Row Selector Column') %]"
+          focus-me="gridControls.focusRowSelector"
+          type='checkbox' ng-model="selectAll"/> 
+      </div>
+    </div>
+    <div class="eg-grid-cell"
+        eg-grid-column-drag-dest
+        column="{{col.name}}"
+        eg-right-click="onContextMenu($event)"
+        ng-repeat="col in columns"
+        style="flex:{{col.flex}}"
+        ng-show="col.visible">
+
+        <div style="display:flex">
+          <div style="flex:1" class="eg-grid-column-move-handle">
+            <div ng-if="col.sortable">
+              <a column="{{col.name}}" href
+                eg-grid-column-drag-source
+                ng-click="quickSort(col.name)">{{col.label}}</a>
+            </div>
+            <div ng-if="!col.sortable">
+              <div column="{{col.name}}" eg-grid-column-drag-source>{{col.label}}</div>
+            </div>
+          </div>
+          <div eg-grid-column-drag-source 
+            drag-type="resize" column="{{col.name}}" 
+            class="eg-grid-column-resize-handle">&nbsp;</div>
+        </div>
+    </div>
+  </div>
+
+  <!-- Inline grid configuration row -->
+  <div class="eg-grid-row eg-grid-conf-row" ng-show="showGridConf">
+    <div class="eg-grid-cell eg-grid-cell-conf-header">
+      <div class="eg-grid-conf-cell-entry">[% l('Expand') %]</div>
+      <div class="eg-grid-conf-cell-entry">[% l('Shrink') %]</div>
+      <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">[% l('Sort') %]</div>
+    </div>
+    <div class="eg-grid-cell"
+      ng-repeat="col in columns"
+      style="flex:{{col.flex}}"
+      ng-show="col.visible">
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column wider') %]"
+          ng-click="modifyColumnFlex(col,1)">
+          <span class="glyphicon glyphicon-fast-forward"></span>
+        </a>
+      </div>
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column narrower') %]"
+          ng-click="modifyColumnFlex(col,-1)">
+          <span class="glyphicon glyphicon-fast-backward"></span>
+        </a>
+      </div>
+      <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">
+        <div ng-if="col.multisortable">
+          <input type='number' ng-model="col.sort"
+            title="[% l('Sort Priority / Direction') %]" style='width:2.3em'/>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="eg-grid-content-body">
+    <div ng-show="items.length == 0" 
+      class="alert alert-info">[% l('No Items To Display') %]</div>
+
+    <div class="eg-grid-row" 
+        id="eg-grid-row-{{$index + 1}}"
+        ng-repeat="item in items"
+        ng-show="items.length > 0"
+        ng-class="{'eg-grid-row-selected' : selected[indexValue(item)]}">
+      <div class="eg-grid-cell eg-grid-cell-stock"
+        ng-click="handleRowClick($event, item)" title="[% l('Row Index') %]">
+        <a href ng-show="gridControls.activateItem" 
+          ng-click="gridControls.activateItem(item)" style="font-weight:bold">
+          {{$index + offset() + 1}}
+        </a>
+        <div ng-hide="gridControls.activateItem">{{$index + offset() + 1}}</div>
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-stock">
+        <!-- ng-click=handleRowClick here has unintended 
+             consequences and is unnecessary, avoid it -->
+        <div>
+          <input type='checkbox' title="[% l('Select Row') %]"
+            ng-model="selected[indexValue(item)]"/>
+        </div>
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-content"
+          ng-click="handleRowClick($event, item)"
+          ng-dblclick="gridControls.activateItem(item)"
+          ng-repeat="col in columns"
+          style="flex:{{col.flex}}"
+          ng-show="col.visible">
+
+          <!-- if the cell comes with its own template,
+               translate that content into HTML and insert it here -->
+          <span ng-if="col.template" 
+            ng-bind-html="translateCellTemplate(col, item)">
+          </span>
+
+          <!-- otherwise, simply display the item value, which may 
+               pass through datatype-specific filtering. -->
+          <span ng-if="!col.template">
+            {{itemFieldValue(item, col) | egGridValueFilter:col}}
+          </span>
+      </div>
+    </div>
+  </div>
+
+
+</div>
+
diff --git a/Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2
new file mode 100644
index 0000000..45e8ca1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2
@@ -0,0 +1,18 @@
+<!--
+  Generic confirmation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{title}}</h4> 
+  </div>
+  <div class="modal-body">{{message}}</div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_eframe.tt2 b/Open-ILS/src/templates/staff/share/t_eframe.tt2
new file mode 100644
index 0000000..8fc95ca
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_eframe.tt2
@@ -0,0 +1,9 @@
+<div class="eg-embed-frame">
+  <!-- height is calculated at render time -->
+  <iframe 
+    src="{{url}}" 
+    style="height:{{height}}px"
+    onload="egEmbedFrameLoader(this)">
+  </iframe>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2
new file mode 100644
index 0000000..ce19832
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2
@@ -0,0 +1,21 @@
+<!--
+  Generic confirmation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{message}}</h4> 
+  </div>
+  <div class="modal-body">
+    <div class="col-md-12">
+      <input type='text' ng-model="args.value" class="form-control" focus-me="focus"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/statusbar.tt2 b/Open-ILS/src/templates/staff/statusbar.tt2
new file mode 100644
index 0000000..7eef88b
--- /dev/null
+++ b/Open-ILS/src/templates/staff/statusbar.tt2
@@ -0,0 +1,33 @@
+<!-- Status bar along the bottom of the page -->
+
+<div id="status-bar" 
+  class="navbar navbar-default navbar-fixed-bottom" 
+  role="navigation">
+
+  <!-- 
+    Define the status bar as a directive so it may be used globally.
+    The template is defined inline (below) to leverage i18n and 
+    so one less network fetch is required.
+  -->
+  <eg-status-bar></eg-status-bar>
+  <script type="text/ng-template" id="eg-status-bar-template">
+    <ul class="nav navbar-nav navbar-right">
+      <li>{{messages[0]}}</li>
+      <li>
+        <span 
+          ng-click="hatchConnect()"
+          title="[% l('Print/Store Connection Status') %]"
+          class="glyphicon glyphicon-transfer"
+          ng-class="{'status-bar-connected' : hatchConnected()}">
+        </span>
+      </li>
+      <li>
+        <span 
+          title="[% l('Network Connection Status') %]"
+          class="glyphicon glyphicon-signal"
+          ng-class="{'status-bar-connected' : netConnected()}">
+        </span>
+      </li>
+    </ul>    
+  </script>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2
new file mode 100644
index 0000000..135e9da
--- /dev/null
+++ b/Open-ILS/src/templates/staff/t_login.tt2
@@ -0,0 +1,57 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-3"></div><!-- offset? -->
+      <div class="col-md-6">
+        <fieldset>
+          <legend>[% l('Sign In') %]</legend>
+          <!-- 
+            login() hangs off the page $scope.
+            Values entered by the user are put into 'args', 
+            which is is autovivicated if needed.
+            The input IDs are there to match the labels.  
+            They are not referenced in the Login controller.
+          -->
+          <form ng-submit="login(args)" name="login-form" class="form-horizontal" role="form">
+            <div class="form-group">
+              <label class="col-md-4 control-label" for="login-username">[% l('Username') %]</label>
+              <div class="col-md-8">
+                <input type="text" id="login-username" class="form-control" 
+                  focus-me="focusMe" select-me="focusMe"
+                  placeholder="Username" ng-model="args.username"/>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-md-4 control-label" for="login-password">[% l('Password') %]</label>
+              <div class="col-md-8">
+                <input type="password" id="login-password" class="form-control"
+                  placeholder="Password" ng-model="args.password"/>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-md-4 control-label" 
+                for="login-workstation">[% l('Workstation') %]</label>
+              <div class="col-md-8">
+                <select class="form-control" ng-model="args.workstation"
+                  ng-options="ws for ws in workstations">
+                  <option>[% l('Select Workstation') %]</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <div class="col-md-offset-4 col-md-2">
+                <button type="submit" class="btn btn-default">[% l('Sign in') %]</button>
+              </div>
+              <div class="col-md-2">
+                <span ng-show="loginFailed" class="label label-warning">[% l('Login Failed') %]</span>
+              </div>
+            </div>
+
+          </form>
+        </fieldset>
+      </div>
+    <div class="col-md-3"></div><!-- offset? -->
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_splash.tt2 b/Open-ILS/src/templates/staff/t_splash.tt2
new file mode 100644
index 0000000..d259698
--- /dev/null
+++ b/Open-ILS/src/templates/staff/t_splash.tt2
@@ -0,0 +1,69 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-12 text-center">
+      <img src="/xul/server/skin/media/images/portal/logo.png"/>
+    </div>
+  </div>
+  <br/>
+  <div class="row" id="splash-nav">
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Circulation and Patrons') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/forward.png"/>
+            <a target="_self" href="./circ/patron/bcsearch">[% l('Check Out Items') %]</a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/back.png"/>
+            <a target="_self" href="./circ/checkin/index">[% l('Check In Items') %]</a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/retreivepatron.png"/>
+            <a target="_self" href="./circ/patron/search">[% l('Search For Patron By Name') %]</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Item Search and Cataloging') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/bucket.png"/>
+            <a target="_self" href="./cat/bucket/record/">[% l('Record Buckets') %]</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Administration') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+            <a target="_top" href="http://docs.evergreen-ils.org/">
+              [% l('Evergreen Documentation') %]
+            </a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+            <a target="_top" href="./admin/workstation/index">
+              [% l('Workstation Administration') %]
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</div>
diff --git a/Open-ILS/web/js/ui/default/opac/staff.js b/Open-ILS/web/js/ui/default/opac/staff.js
index ebed5e5..d281ca2 100644
--- a/Open-ILS/web/js/ui/default/opac/staff.js
+++ b/Open-ILS/web/js/ui/default/opac/staff.js
@@ -1,4 +1,11 @@
 /* staff client integration functions */
+
+// Browser staff client runs the TPAC within an iframe, whose onload
+// is not called until after the page onload is called. window.onload
+// actions are wrapped in timeouts (below) to ensure the wrapping page
+// has a chance to insert the necessary xulG, etc. functions into the
+// window.
+
 function debug(msg){dump(msg+'\n')}
 var eventCache={};
 function attachEvt(scope, name, action) {
@@ -34,107 +41,137 @@ function staff_hold_usr_barcode_changed(isload) {
         return;
     }
 
-    if(typeof xulG != 'undefined' && xulG.get_barcode_and_settings) {
-        var cur_hold_barcode = undefined;
-        var barcode = isload;
-        if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
-        var only_settings = true;
-        if(!document.getElementById('hold_usr_is_requestor').checked) {
-            if(!isload) {
-                barcode = document.getElementById('hold_usr_input').value;
-                only_settings = false;
-            }
-            if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
-                document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
-        }
-        if(barcode == undefined || barcode == '') {
-            document.getElementById('patron_name').innerHTML = '';
-            // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
-            document.getElementById('place_hold_submit').disabled = true;
-            document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
-            cur_hold_barcode = null;
-            return;
+    if (!window.xulG) return;
+
+    var cur_hold_barcode = undefined;
+    var barcode = isload;
+    if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
+    var only_settings = true;
+    if(!document.getElementById('hold_usr_is_requestor').checked) {
+        if(!isload) {
+            barcode = document.getElementById('hold_usr_input').value;
+            only_settings = false;
         }
-        if(barcode == cur_hold_barcode)
-            return;
-        // No submitting until we think the barcode is valid
+        if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
+            document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
+    }
+    if(barcode == undefined || barcode == '') {
+        document.getElementById('patron_name').innerHTML = '';
+        // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
         document.getElementById('place_hold_submit').disabled = true;
+        document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+        cur_hold_barcode = null;
+        return;
+    }
+    if(barcode == cur_hold_barcode)
+        return;
+    // No submitting until we think the barcode is valid
+    document.getElementById('place_hold_submit').disabled = true;
+
+    if (window.IAMBROWSER) {
+        // Browser client operates asynchronously
+        if (!xulG.get_barcode_and_settings_async) return;
+        xulG.get_barcode_and_settings_async(barcode, only_settings)
+        .then(
+            function(load_info) { // load succeeded
+                staff_hold_usr_barcode_changed2(
+                    isload, only_settings, barcode, cur_hold_barcode, load_info);
+            },
+            function() { 
+                // load failed (rejected).  Call staff_hold_usr_barcode_changed2
+                // anyway, since it handles clearing the form
+                staff_hold_usr_barcode_changed2(
+                    isload, only_settings, barcode, cur_hold_barcode, false);
+            }
+        )
+    } else {
+        // XUL version is synchronous
+        if (!xulG.get_barcode_and_settings) return;
         var load_info = xulG.get_barcode_and_settings(window, barcode, only_settings);
-        if(load_info == false || load_info == undefined) {
-            document.getElementById('patron_name').innerHTML = '';
-            document.getElementById("patron_usr_barcode_not_found").style.display = '';
-            cur_hold_barcode = null;
-            return;
-        }
-        cur_hold_barcode = load_info.barcode;
-        if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
-        if(load_info.settings['opac.default_pickup_location'])
-            document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
-        if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
-        if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
-        if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
-        if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
-            var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
-            var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
-            var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
-            var update_elements = document.getElementsByName('email_notify');
-            for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
-            update_elements = document.getElementsByName('phone_notify_checkbox');
-            for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
-            update_elements = document.getElementsByName('sms_notify_checkbox');
-            for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
-        }
-        update_elements = document.getElementsByName('phone_notify');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
-        update_elements = document.getElementsByName('sms_notify');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
-        update_elements = document.getElementsByName('sms_carrier');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
-        update_elements = document.getElementsByName('email_notify');
-        for(var i in update_elements) {
-            update_elements[i].disabled = (load_info.user_email ? false : true);
-            if(update_elements[i].disabled) update_elements[i].checked = false;
-        }
-        update_elements = document.getElementsByName('email_address');
-        for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
-        if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
-            document.getElementById('patron_name').innerHTML = load_info.patron_name;
-            document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
-        }
-        // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
-
-        // update the advanced hold options link to propagate the patron
-        // barcode if clicked.  This is needed when the patron barcode
-        // is manually entered (i.e. the staff client does not provide one).
-        var adv_link = document.getElementById('advanced_hold_link');
-        if (adv_link) { // not present on MR hold pages
-            var href = adv_link.getAttribute('href').replace(
-                /;usr_barcode=[^;\&]+|$/, 
-                ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
-            adv_link.setAttribute('href', href);
-        }
+        staff_hold_usr_barcode_changed2(isload, only_settings, barcode, cur_hold_barcode, load_info);
+    }
+}
+
+function staff_hold_usr_barcode_changed2(
+    isload, only_settings, barcode, cur_hold_barcode, load_info) {
+
+    if(load_info == false || load_info == undefined) {
+        document.getElementById('patron_name').innerHTML = '';
+        document.getElementById("patron_usr_barcode_not_found").style.display = '';
+        cur_hold_barcode = null;
+        return;
+    }
+    cur_hold_barcode = load_info.barcode;
+    if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
+    if(load_info.settings['opac.default_pickup_location'])
+        document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
+    if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
+    if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
+    if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
+    if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
+        var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
+        var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
+        var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
+        var update_elements = document.getElementsByName('email_notify');
+        for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
+        update_elements = document.getElementsByName('phone_notify_checkbox');
+        for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
+        update_elements = document.getElementsByName('sms_notify_checkbox');
+        for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
+    }
+    update_elements = document.getElementsByName('phone_notify');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
+    update_elements = document.getElementsByName('sms_notify');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
+    update_elements = document.getElementsByName('sms_carrier');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
+    update_elements = document.getElementsByName('email_notify');
+    for(var i in update_elements) {
+        update_elements[i].disabled = (load_info.user_email ? false : true);
+        if(update_elements[i].disabled) update_elements[i].checked = false;
+    }
+    update_elements = document.getElementsByName('email_address');
+    for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
+    if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
+        document.getElementById('patron_name').innerHTML = load_info.patron_name;
+        document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+    }
+    // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
 
-        if (isload !== true)
-            document.getElementById('place_hold_submit').disabled = false;
+    // update the advanced hold options link to propagate the patron
+    // barcode if clicked.  This is needed when the patron barcode
+    // is manually entered (i.e. the staff client does not provide one).
+    var adv_link = document.getElementById('advanced_hold_link');
+    if (adv_link) { // not present on MR hold pages
+        var href = adv_link.getAttribute('href').replace(
+            /;usr_barcode=[^;\&]+|$/, 
+            ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
+        adv_link.setAttribute('href', href);
     }
+
+    if (isload !== true)
+        document.getElementById('place_hold_submit').disabled = false;
 }
 window.onload = function() {
     // record details page events
-    var rec = location.href.match(/\/opac\/record\/(\d+)/);
-    if(rec && rec[1]) { 
-        runEvt('rdetail', 'recordRetrieved', rec[1]); 
-        runEvt('rdetail', 'MFHDDrawn');
-    }
-    if(location.href.match(/place_hold/)) {
-        // patron barcode may come from XUL or a CGI param
-        var patron_barcode = xulG.patron_barcode ||
-            document.getElementById('hold_usr_input').value;
-        if(patron_barcode) {
-            staff_hold_usr_barcode_changed(patron_barcode);
-        } else {
-            staff_hold_usr_barcode_changed(true);
+
+    setTimeout(function() {
+        var rec = location.href.match(/\/opac\/record\/(\d+)/);
+        if(rec && rec[1]) { 
+            runEvt('rdetail', 'recordRetrieved', rec[1]); 
+            runEvt('rdetail', 'MFHDDrawn');
         }
-    }
+        if(location.href.match(/place_hold/)) {
+            // patron barcode may come from XUL or a CGI param
+            var patron_barcode = xulG.patron_barcode ||
+                document.getElementById('hold_usr_input').value;
+            if(patron_barcode) {
+                staff_hold_usr_barcode_changed(patron_barcode);
+            } else {
+                staff_hold_usr_barcode_changed(true);
+            }
+        }
+    });
 }
 
 function rdetail_next_prev_actions(index, count, prev, next, start, end, results) {
@@ -167,6 +204,8 @@ function rdetail_next_prev_actions(index, count, prev, next, start, end, results
     ol = window.onload;
     window.onload = function() {
         if(ol) ol(); 
-        runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count)); 
+        setTimeout(function() {
+            runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count)); 
+        });
     };
 }
diff --git a/Open-ILS/web/js/ui/default/staff/Gruntfile.js b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
new file mode 100644
index 0000000..d55d71f
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
@@ -0,0 +1,164 @@
+module.exports = function(grunt) {
+
+  // Project configuration.
+  var config = { 
+    pkg: grunt.file.readJSON('package.json'),
+
+    // copy the files we care about from bower-fetched dependencies
+    // into our build directory
+    copy: {
+
+      js : {
+        files: [{ 
+          dest: 'build/js/', 
+          flatten: true,
+          filter: 'isFile',
+          expand : true,
+          src: [
+            'bower_components/angular/angular.min.js',
+            'bower_components/angular/angular.min.js.map',
+            'bower_components/angular-route/angular-route.min.js',
+            'bower_components/angular-route/angular-route.min.js.map',
+            'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js',
+            'bower_components/angular-hotkeys/build/hotkeys.min.js',
+          ]
+        }]
+      },
+
+      css : {
+        files : [{
+          dest : 'build/css/',
+          flatten : true,
+          filter : 'isFile',
+          expand : true,
+          src : [
+            'bower_components/angular-hotkeys/build/hotkeys.min.css',
+            'bower_components/bootstrap/dist/css/bootstrap.min.css' 
+          ]
+        }]
+      },
+
+      fonts : {
+        files : [{
+          dest : 'build/fonts/',
+          flatten : true,
+          filter : 'isFile',
+          expand : true,
+          src : [
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff'
+          ]
+        }]
+      }
+    },
+
+    // combine our CSS deps
+    // note: minification also supported, but not required (yet).
+    cssmin: {
+      combine: {
+        files: {
+          'build/css/evergreen-staff-client-deps.<%= pkg.version %>.min.css' : [
+            'build/css/hotkeys.min.css',
+            'build/css/bootstrap.min.css'
+          ]
+        }
+      }
+    },
+
+    // concatenation + minification
+    uglify: {
+      options: {
+        banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
+      },
+      build: {
+        src: [
+            // These are concatenated in order in the final build file.
+            // The order is important.
+            'build/js/angular.min.js',
+            'build/js/angular-route.min.js',
+            'build/js/ui-bootstrap-tpls.min.js',
+            'build/js/hotkeys.min.js',
+            // NOTE: OpenSRF must be installed
+            '/openils/lib/javascript/JSON_v1.js',
+            '/openils/lib/javascript/opensrf.js',
+            '/openils/lib/javascript/opensrf_ws.js',
+            'services/core.js',
+            'services/strings.js',
+            'services/idl.js',
+            'services/event.js',
+            'services/net.js',
+            'services/auth.js',
+            'services/pcrud.js',
+            'services/env.js',
+            'services/org.js',
+            'services/startup.js',
+            'services/hatch.js',
+            'services/print.js',
+            'services/coresvc.js',
+            'services/navbar.js',
+            'services/statusbar.js',
+            'services/ui.js',
+        ],
+        dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js'
+      }
+    },
+
+    // bare concat operation; useful for testing concat w/o minification
+    // to more easily detect if concat order is incorrect
+    concat: {
+      options: {
+       separator: ';',
+      }
+    },
+
+    exec : {
+
+      // Generate test/data/IDL2js.js for unit tests.
+      // note: the output of this script is *not* part of the final build.
+      idl2js : {
+        command : 'cd test/data && perl idl2js.pl',
+      },
+
+      // Remove the unit test IDL2js.js file.  We don't need it after testing
+      rmidl2js : {
+        command : 'rm test/data/IDL2js.js',
+      }
+    },
+
+    // unit tests configuration
+    karma : {
+      unit: {
+        configFile: 'test/karma.conf.js',
+        //background: true  // for now, visually babysit unit tests
+      }
+    }
+  };
+
+  // tell concat about our uglify build options (instead of repeating them)
+  config.concat.build = config.uglify.build;
+
+  // apply our configuration
+  grunt.initConfig(config);
+
+  // Load our modules
+  grunt.loadNpmTasks('grunt-contrib-uglify');
+  grunt.loadNpmTasks('grunt-contrib-concat');
+  grunt.loadNpmTasks('grunt-contrib-copy');
+  grunt.loadNpmTasks('grunt-contrib-cssmin');
+  grunt.loadNpmTasks('grunt-karma');
+  grunt.loadNpmTasks('grunt-exec');
+
+  // note: "grunt concat" is not requried 
+  grunt.registerTask('build', ['copy', 'cssmin', 'uglify']);
+
+  // test only, no minification
+  grunt.registerTask('test', ['copy', 'exec:idl2js', 'karma:unit', 'exec:rmidl2js']);
+
+  // note: "grunt concat" is not requried 
+  grunt.registerTask('all', ['test', 'cssmin', 'uglify']);
+
+};
+
+// vim: ts=2:sw=2:softtabstop=2
diff --git a/Open-ILS/web/js/ui/default/staff/README.install b/Open-ILS/web/js/ui/default/staff/README.install
new file mode 100644
index 0000000..62668c1
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/README.install
@@ -0,0 +1,93 @@
+= Building, Testing, Packaging the Browser Client =
+:Author:    Bill Erickson
+:Email:     berick at esilibrary.com
+:Date:      2014-05-07
+
+== Prerequisites ==
+
+ * http://bower.io/[Bower] 
+  ** Dependency retrieval
+ * http://jasmine.github.io/[Jasmine] 
+  ** Headless unit tests runner
+ * http://gruntjs.com/[Grunt]
+  ** Coordinating the build
+  ** Concatenation + minification of JS and CSS
+
+These are all Node.js plugins, so start by installing Node.js
+
+=== Install Node.js ===
+
+Node.js does not have have Debian Wheezy build target.  For now, I've opted
+to install from source.  For more, see also 
+https://github.com/joyent/node/wiki/installation[Node.js Installation]
+
+[source,sh]
+------------------------------------------------------------------------------
+% git clone https://github.com/joyent/node.git
+% cd node
+% git checkout -b v0.10.28 v0.10.28
+
+# set -j to number of CPU cores + 1
+% ./configure && make -j5 && sudo make install
+
+# update packages
+% sudo npm update
+------------------------------------------------------------------------------
+
+=== Install Grunt CLI ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g grunt-cli
+------------------------------------------------------------------------------
+
+=== Install Bower ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g bower
+------------------------------------------------------------------------------
+
+== Building, Testing, Minification == 
+
+The remaining steps all take place within the staff JS web root:
+
+[source,sh]
+------------------------------------------------------------------------------
+% cd $EVERGREEN_ROOT/Open-ILS/web/js/ui/default/staff/
+------------------------------------------------------------------------------
+
+=== Install Project-local Dependencies ===
+
+npm inspects the 'package.json' file for dependencies and fetches them
+from the Node package network.
+
+[source,sh]
+------------------------------------------------------------------------------
+% npm install   # fetch Grunt dependencies
+% bower install # fetch JS dependencies
+------------------------------------------------------------------------------
+
+=== Running the Build Scripts ===
+
+[source,sh]
+------------------------------------------------------------------------------
+
+# build, run tests
+% grunt test
+
+# build, concat+minify
+% grunt uglify
+
+# build, run tests, concat+minify 
+% grunt all
+------------------------------------------------------------------------------
+
+== TODO ==
+
+ * Minification of app-specific JS files
+ * Integrate this into the Evergreen Makefile test and install targets
+   ** Avoid installing test, node_modules, etc. into the web dir.
+ * Support fetching JS deps (angularjs, etc.) via direct retrieval for 
+   installation without test + concat + minify (i.e. w/o requiring Node.js)?
+
diff --git a/Open-ILS/web/js/ui/default/staff/admin/user_perms.js b/Open-ILS/web/js/ui/default/staff/admin/user_perms.js
new file mode 100644
index 0000000..460e8d1
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/user_perms.js
@@ -0,0 +1,100 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egUserPermsEditor',
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider', 
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); 
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/admin/user_perms', {
+        templateUrl: './admin/t_user_perms_lookup',
+        controller: 'UserPermsLookupCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/user_perms/:user_id', {
+        templateUrl: 'user-perms-template',
+        controller: 'UserPermsCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : 'user-perms-template',
+        controller: 'UserPermsCtrl',
+        resolve : resolver
+    });
+}])
+
+.controller('UserPermsLookupCtrl',
+       ['$scope','$window','$location','egCore',
+function($scope , $window , $location , egCore) {
+    
+    $scope.selectMe = true; // focus text input
+    $scope.args = {};
+
+    // find the user by barcode, the jump to the editor
+    $scope.submitBarcode = function(args) {
+
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        $scope.selectMe = false;
+
+        // lookup barcode
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'actor', args.barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = args.barcode;
+                $scope.selectMe = true;
+                return;
+            }
+
+            // see if an opt-in request is needed
+            user_id = resp[0].id;
+            $location.path($location.path() + '/' + user_id);
+        });
+    }
+
+}])
+
+.controller('UserPermsCtrl',
+       ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+    var user_id = $routeParams.user_id;
+
+    var url = $location.absUrl().replace(
+        /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+    url += '?usr=' + encodeURIComponent(user_id);
+
+    // user_edit does not load the session via cookie.  It uses URL 
+    // params or xulG instead.  Pass via xulG.
+    $scope.funcs = {
+        ses : egCore.auth.token(),
+        on_patron_save : function() {
+            $scope.funcs.reload();
+        }
+    }
+
+    $scope.user_perms_url = url;
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
new file mode 100644
index 0000000..51a4240
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
@@ -0,0 +1,557 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egWorkstationAdmin', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider', 
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); 
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/admin/workstation/print/config', {
+        templateUrl: './admin/workstation/t_print_config',
+        controller: 'PrintConfigCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/workstation/print/templates', {
+        templateUrl: './admin/workstation/t_print_templates',
+        controller: 'PrintTemplatesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/workstation/stored_prefs', {
+        templateUrl: './admin/workstation/t_stored_prefs',
+        controller: 'StoredPrefsCtrl',
+        resolve : resolver
+    });
+
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './admin/workstation/t_splash',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+}])
+
+.controller('SplashCtrl',
+       ['$scope','$window','$location','egCore','egConfirmDialog',
+function($scope , $window , $location , egCore , egConfirmDialog) {
+
+    var allWorkstations = [];
+    var permMap = {};
+    $scope.contextOrg = egCore.org.get(egCore.auth.user().ws_ou());
+
+    egCore.perm.hasPermAt('REGISTER_WORKSTATION', true)
+    .then(function(orgList) { 
+
+        // hide orgs in the context org selector where this login
+        // does not have the reg_ws perm
+        $scope.wsOrgHidden = function(id) {
+            return orgList.indexOf(id) == -1;
+        }
+        $scope.userHasRegPerm = 
+            orgList.indexOf($scope.contextOrg.id()) > -1;
+    });
+
+    // fetch the stored WS info
+    egCore.hatch.getItem('eg.workstation.all')
+    .then(function(all) {
+        allWorkstations = all || [];
+        $scope.workstations = 
+            allWorkstations.map(function(w) { return w.name });
+        return egCore.hatch.getItem('eg.workstation.default');
+    })
+    .then(function(def) { 
+        $scope.defaultWS = def;
+        $scope.activeWS = $scope.selectedWS = egCore.auth.workstation() || def;
+    });
+
+    $scope.getWSLabel = function(ws) {
+        return ws == $scope.defaultWS ? 
+            egCore.strings.$replace(egCore.strings.DEFAULT_WS_LABEL, {ws:ws}) : ws;
+    }
+
+    $scope.setDefaultWS = function() {
+        egCore.hatch.setItem(
+            'eg.workstation.default', $scope.selectedWS)
+        .then(function() { $scope.defaultWS = $scope.selectedWS });
+    }
+
+    // redirect the user to the login page using the current
+    // workstation as the workstation URL param
+    $scope.useWS = function() {
+        $window.location.href = $location
+            .path('/login')
+            .search({ws : $scope.selectedWS})
+            .absUrl();
+    }
+
+    $scope.registerWS = function() {
+        register_workstation(
+            $scope.newWSName,
+            $scope.contextOrg.shortname() + '-' + $scope.newWSName,
+            $scope.contextOrg.id()
+        );
+    }
+
+    function register_workstation(base_name, name, org_id, override) {
+
+        var method = 'open-ils.actor.workstation.register';
+        if (override) method += '.override';
+
+        egCore.net.request(
+            'open-ils.actor', method, egCore.auth.token(), name, org_id)
+
+        .then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                console.log('register returned ' + evt.toString());
+
+                if (evt.textcode == 'WORKSTATION_NAME_EXISTS' && !override) {
+                    egConfirmDialog.open(
+                        egCore.strings.WS_EXISTS, base_name, {  
+                            ok : function() {
+                                register_workstation(base_name, name, org_id, true);
+                            },
+                            cancel : function() {} 
+                        }
+                    );
+
+                } else {
+                    // TODO: provide permission error display
+                    alert(evt.toString());
+                }
+            } else if (resp) {
+                $scope.workstations.push(name);
+
+                allWorkstations.push({   
+                    id : resp,
+                    name : name,
+                    owning_lib : org_id
+                });
+
+                egCore.hatch.setItem(
+                    'eg.workstation.all', allWorkstations)
+                .then(function() {
+                    if (allWorkstations.length == 1) {
+                        // first one registerd, also mark it as the default
+                        $scope.selectedWS = name;
+                        $scope.setDefaultWS();
+                    }
+                });
+            }
+        });
+    }
+
+    $scope.wsOrgChanged = function(org) {
+        $scope.contextOrg = org;
+    }
+
+    // ---------------------
+    // Hatch Configs
+    $scope.hatchURL = egCore.hatch.hatchURL();
+    $scope.hatchRequired = 
+        egCore.hatch.getLocalItem('eg.hatch.required');
+
+    $scope.updateHatchRequired = function() {
+        egCore.hatch.setLocalItem(
+            'eg.hatch.required', $scope.hatchRequired);
+    }
+
+    $scope.updateHatchURL = function() {
+        egCore.hatch.setLocalItem(
+            'eg.hatch.url', $scope.hatchURL);
+    }
+}])
+
+.controller('PrintConfigCtrl',
+       ['$scope','egCore',
+function($scope , egCore) {
+    console.log('PrintConfigCtrl');
+
+    $scope.actionPending = false;
+    $scope.isTestView = false;
+
+    $scope.setContext = function(ctx) { 
+        $scope.context = ctx; 
+        $scope.isTestView = false;
+        $scope.actionPending = false;
+    }
+    $scope.setContext('default');
+
+    $scope.getPrinterByAttr = function(attr, value) {
+        var printer;
+        angular.forEach($scope.printers, function(p) {
+            if (p[attr] == value) printer = p;
+        });
+        return printer;
+    }
+
+    $scope.currentPrinter = function() {
+        if ($scope.printConfig && $scope.printConfig[$scope.context]) {
+            return $scope.getPrinterByAttr(
+                'name', $scope.printConfig[$scope.context].printer
+            );
+        }
+    }
+
+    // fetch info on all remote printers
+    egCore.hatch.getPrinters()
+    .then(function(printers) { 
+        $scope.printers = printers;
+        $scope.defaultPrinter = 
+            $scope.getPrinterByAttr('is-default', true);
+    })
+    .then(function() { return egCore.hatch.getPrintConfig() })
+    .then(function(config) {
+        $scope.printConfig = config;
+
+        var pname = '';
+        if ($scope.defaultPrinter) {
+            pname = $scope.defaultPrinter.name;
+
+        } else if ($scope.printers.length == 1) {
+            // if the OS does not report a default printer, but only
+            // one printer is available, treat it as the default.
+            pname = $scope.printers[0].name;
+        }
+
+        // apply the default printer to every context which has
+        // no printer configured.
+        angular.forEach(
+            ['default','receipt','label','mail','offline'],
+            function(ctx) {
+                if (!$scope.printConfig[ctx]) {
+                    $scope.printConfig[ctx] = {
+                        context : ctx,
+                        printer : pname
+                    }
+                }
+            }
+        );
+    });
+
+    $scope.printerConfString = function() {
+        if ($scope.printConfigError) return $scope.printConfigError;
+        if (!$scope.printConfig) return;
+        if (!$scope.printConfig[$scope.context]) return;
+        return JSON.stringify(
+            $scope.printConfig[$scope.context], undefined, 2);
+    }
+
+    $scope.resetConfig = function() {
+        $scope.actionPending = true;
+        $scope.printConfigError = null;
+        $scope.printConfig[$scope.context] = {
+            context : $scope.context
+        }
+        
+        if ($scope.defaultPrinter) {
+            $scope.printConfig[$scope.context].printer = 
+                $scope.defaultPrinter.name;
+        }
+
+        egCore.hatch.setPrintConfig($scope.printConfig)
+        .finally(function() {$scope.actionPending = false});
+    }
+
+    $scope.configurePrinter = function() {
+        $scope.printConfigError = null;
+        $scope.actionPending = true;
+        egCore.hatch.configurePrinter(
+            $scope.context,
+            $scope.printConfig[$scope.context].printer
+        )
+        .then(
+            function(config) {$scope.printConfig = config},
+            function(error) {$scope.printConfigError = error}
+        )
+        .finally(function() {$scope.actionPending = false});
+    }
+
+    $scope.setPrinter = function(name) {
+        $scope.printConfig[$scope.context].printer = name;
+    }
+
+    // for testing
+    $scope.setContentType = function(type) { $scope.contentType = type }
+
+    $scope.testPrint = function(withDialog) {
+        if ($scope.contentType == 'text/plain') {
+            egCore.print.print({
+                context : $scope.context, 
+                content_type : $scope.contentType, 
+                content : $scope.textPrintContent,
+                show_dialog : withDialog
+            });
+        } else {
+            egCore.print.print({
+                context : $scope.context,
+                content_type : $scope.contentType, 
+                content : $scope.htmlPrintContent, 
+                scope : {
+                    value1 : 'Value One', 
+                    value2 : 'Value Two',
+                    date_value : '2015-02-04T14:04:34-0400'
+                },
+                show_dialog : withDialog
+            });
+        }
+    }
+
+    $scope.setContentType('text/plain');
+
+}])
+
+.controller('PrintTemplatesCtrl',
+       ['$scope','$q','egCore',
+function($scope , $q , egCore) {
+
+    $scope.print = {
+        template_name : 'bills_current',
+        template_output : ''
+    };
+
+    // print preview scope data
+    // TODO: consider moving the template-specific bits directly
+    // into the templates or storing template- specific script files
+    // alongside the templates.
+    // NOTE: A lot of this data can be shared across templates.
+    var seed_user = {
+        first_given_name : 'Slow',
+        second_given_name : 'Joe',
+        family_name : 'Jones',
+        card : {
+            barcode : '30393830393'
+        }
+    }
+    var seed_addr = {
+        street1 : '123 Apple Rd',
+        street2 : 'Suite B',
+        city : 'Anywhere',
+        state : 'XX',
+        country : 'US',
+        post_code : '12345'
+    }
+
+    var seed_record = {
+        title : 'Traveling Pants!!',
+        author : 'Jane Jones',
+        isbn : '1231312123'
+    };
+
+    var seed_copy = {
+        barcode : '33434322323'
+    }
+
+    var one_hold = {
+        behind_desk : 'f',
+        phone_notify : '111-222-3333',
+        sms_notify : '111-222-3333',
+        email_notify : 'user at example.org',
+        request_time : new Date().toISOString()
+    }
+
+
+    $scope.preview_scope = {
+        //bills
+        transactions : [
+            {
+                id : 1,
+                xact_start : new Date().toISOString(),
+                summary : {
+                    xact_type : 'circulation',
+                    last_billing_type : 'Overdue materials',
+                    total_owed : 1.50,
+                    last_payment_note : 'Test Note 1',
+                    total_paid : 0.50,
+                    balance_owed : 1.00
+                }
+            }, {
+                id : 2,
+                xact_start : new Date().toISOString(),
+                summary : {
+                    xact_type : 'circulation',
+                    last_billing_type : 'Overdue materials',
+                    total_owed : 2.50,
+                    last_payment_note : 'Test Note 2',
+                    total_paid : 0.50,
+                    balance_owed : 2.00
+                }
+            }
+        ],
+
+        circulations : [
+            {   
+                due_date : new Date().toISOString(), 
+                target_copy : seed_copy,
+                title : seed_record.title
+            },
+        ],
+
+        previous_balance : 8.45,
+        payment_total : 2.00,
+        payment_applied : 2.00,
+        new_balance : 6.45,
+        amount_voided : 0,
+        change_given : 0,
+        payment_type : 'cash_payment',
+        payment_note : 'Here is a payment note',
+        note : {
+            create_date : new Date().toISOString(), 
+            title : 'Test Note Title',
+            usr : seed_user,
+            value : 'This patron is super nice!'
+        },
+
+        transit : {
+            dest : {
+                name : 'Library X',
+                shortname : 'LX',
+                holds_address : seed_addr
+            },
+            target_copy : seed_copy
+        },
+        title : seed_record.title,
+        author : seed_record.author,
+        patron : egCore.idl.toHash(egCore.auth.user()),
+        address : seed_addr,
+        hold : one_hold,
+        holds : [
+            {hold : one_hold, title : 'Some Title 1', author : 'Some Author 1'},
+            {hold : one_hold, title : 'Some Title 2', author : 'Some Author 2'},
+            {hold : one_hold, title : 'Some Title 3', author : 'Some Author 3'}
+        ]
+    }
+
+    $scope.preview_scope.payments = [
+        {amount : 1.00, xact : $scope.preview_scope.transactions[0]}, 
+        {amount : 1.00, xact : $scope.preview_scope.transactions[1]}
+    ]
+    $scope.preview_scope.payments[0].xact.title = 'Hali Bote Azikaban de tao fan';
+    $scope.preview_scope.payments[0].xact.copy_barcode = '334343434';
+    $scope.preview_scope.payments[1].xact.title = seed_record.title;
+    $scope.preview_scope.payments[1].xact.copy_barcode = seed_copy.barcode;
+
+    // today, staff, current_location, etc.
+    egCore.print.fleshPrintScope($scope.preview_scope);
+
+    $scope.template_changed = function() {
+        $scope.print.load_failed = false;
+        egCore.print.getPrintTemplate($scope.print.template_name)
+        .then(
+            function(html) { 
+                $scope.print.template_content = html;
+                console.log('set template content');
+            },
+            function() {
+                $scope.print.template_content = '';
+                $scope.print.load_failed = true;
+            }
+        );
+    }
+
+    $scope.save_locally = function() {
+        egCore.hatch.storePrintTemplate(
+            $scope.print.template_name,
+            $scope.print.template_content
+        );
+    }
+
+    $scope.template_changed(); // load the default
+}])
+
+// 
+.directive('egPrintTemplateOutput', ['$compile',function($compile) {
+    return function(scope, element, attrs) {
+        scope.$watch(
+            function(scope) {
+                return scope.$eval(attrs.content);
+            },
+            function(value) {
+                // create an isolate scope and copy the print context
+                // data into the new scope.
+                // TODO: see also print security concerns in egHatch
+                var result = element.html(value);
+                var context = scope.$eval(attrs.context);
+                var print_scope = scope.$new(true);
+                angular.forEach(context, function(val, key) {
+                    print_scope[key] = val;
+                })
+                $compile(element.contents())(print_scope);
+            }
+        );
+    };
+}])
+
+.controller('StoredPrefsCtrl',
+       ['$scope','$q','egCore','egConfirmDialog',
+function($scope , $q , egCore , egConfirmDialog) {
+    console.log('StoredPrefsCtrl');
+
+    $scope.setContext = function(ctx) {
+        $scope.context = ctx;
+    }
+    $scope.setContext('local');
+
+    // grab the edit perm
+    $scope.userHasDeletePerm = false;
+    egCore.perm.hasPermHere('ADMIN_WORKSTATION')
+    .then(function(bool) { $scope.userHasDeletePerm = bool });
+
+    // fetch the keys
+
+    function refreshKeys() {
+        $scope.keys = {local : [], remote : []};
+
+        egCore.hatch.getRemoteKeys().then(
+            function(keys) { $scope.keys.remote = keys.sort() })
+    
+        // local calls are non-async
+        $scope.keys.local = egCore.hatch.getLocalKeys();
+    }
+    refreshKeys();
+
+    $scope.selectKey = function(key) {
+        $scope.currentKey = key;
+        $scope.currentKeyContent = null;
+
+        if ($scope.context == 'local') {
+            $scope.currentKeyContent = egCore.hatch.getLocalItem(key);
+        } else {
+            egCore.hatch.getRemoteItem(key)
+            .then(function(content) {
+                $scope.currentKeyContent = content
+            });
+        }
+    }
+
+    $scope.getCurrentKeyContent = function() {
+        return JSON.stringify($scope.currentKeyContent, null, 2);
+    }
+
+    $scope.removeKey = function(key) {
+        egConfirmDialog.open(
+            egCore.strings.PREFS_REMOVE_KEY_CONFIRM, '',
+            {   deleteKey : key,
+                ok : function() {
+                    if ($scope.context == 'local') {
+                        egCore.hatch.removeLocalItem(key);
+                        refreshKeys();
+                    } else {
+                        egCore.hatch.removeItem(key)
+                        .then(function() { refreshKeys() });
+                    }
+                },
+                cancel : function() {} // user canceled, nothing to do
+            }
+        );
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/app.js b/Open-ILS/web/js/ui/default/staff/app.js
new file mode 100644
index 0000000..5c155a9
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/app.js
@@ -0,0 +1,125 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(
+       ['$routeProvider','$locationProvider',
+function($routeProvider , $locationProvider) {
+    $locationProvider.html5Mode(true);
+
+    /**
+     * Route resolvers allow us to run async commands
+     * before the page controller is instantiated.
+     */
+    var resolver = {delay : ['egCore', 
+        function(egCore) {return egCore.startup.go()}]};
+
+    $routeProvider.when('/login', {
+        templateUrl: './t_login',
+        controller: 'LoginCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './t_splash',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+}])
+
+/**
+ * Login controller.  
+ * Reads the login form and submits the login request
+ */
+.controller('LoginCtrl', 
+    /* inject services into our controller.  Spelling them
+     * out like this allows the auto-magic injector to work
+     * even if the code has been minified */
+           ['$scope','$location','$window','egCore',
+    function($scope , $location , $window , egCore) {
+        $scope.focusMe = true;
+
+        // if the user is already logged in, jump to splash page
+        if (egCore.auth.user()) $location.path('/');
+
+        egCore.hatch.getItem('eg.workstation.all')
+        .then(function(all) {
+            if (all && all.length) {
+                $scope.workstations = all.map(function(a) { return a.name });
+
+                if (ws = $location.search().ws) {
+                    // user requested a workstation via URL
+                    var match = all.filter(
+                        function(w) {return ws == w.name} )[0];
+
+                    if (match) {
+                        // requested WS registered on this client
+                        $scope.args = {workstation : match.name};
+                    } else {
+                        // the requested WS is not registered on this client
+                        $scope.wsNotRegistered = true;
+                    }
+                } else {
+                    // no workstation requested; use the default
+                    egCore.hatch.getItem('eg.workstation.default')
+                    .then(function(ws) {
+                        $scope.args = {workstation : ws}
+                    });
+                }
+            } 
+        })
+
+        $scope.login = function(args) {
+            $scope.loginFailed = false;
+
+            if (!args) args = {}; // see FF note below
+
+            if (!args.username) {
+                /* 
+                 Issues with form autofill / auto-complete                          
+                 https://github.com/angular/angular.js/issues/1460                  
+                 http://timothy.userapp.io/post/63412334209/form-autocomplete-and-remember-password-with-angularjs
+                 For now, since FF will save the values, we should 
+                 honor them, even if it's hacky. */
+                args.username = document.getElementById("login-username").value;
+                args.password = document.getElementById("login-password").value;
+            }
+
+            if (! (args.username && args.password) ) return;
+
+            args.type = 'staff';
+            egCore.auth.login(args).then(
+
+                function() { 
+                    // after login, send the user back to the originally
+                    // requested page or, if none, the home page.
+                    // TODO: this is a little hinky because it causes 2 
+                    // redirects if no route_to is defined.  Improve.
+                    $window.location.href = 
+                        $location.search().route_to || 
+                        $location.path('/').absUrl()
+                },
+                function() {
+                    $scope.args.password = '';
+                    $scope.loginFailed = true;
+                    $scope.focusMe = true;
+                }
+            );
+        }
+    }
+])
+
+/**
+ * Splash page dynamic content.
+ */
+.controller('SplashCtrl', ['$scope',
+    function($scope) {
+        console.log('SplashCtrl');
+    }
+]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/bower.json b/Open-ILS/web/js/ui/default/staff/bower.json
new file mode 100644
index 0000000..8c2ed1f
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/bower.json
@@ -0,0 +1,30 @@
+{
+  "name": "evergreen-staff-client",
+  "version": "0.0.1",
+  "authors": [
+    "Bill Erickson <berick at esilibrary.com>"
+  ],
+  "description": "Evergreen HTML Staff Client",
+  "keywords": [
+    "evergreen"
+  ],
+  "license": "GPL",
+  "homepage": "http://evergreen-ils.org",
+  "ignore": [
+    "**/.*",
+    "node_modules",
+    "bower_components",
+    "test",
+    "tests"
+  ],
+  "devDependencies": {
+    "bootstrap": "~3.1.1",
+    "angular": "~1.2.16",
+    "angular-route": "~1.2.16",
+    "angular-mocks": "~1.2.16",
+    "angular-bootstrap": "~0.11.0"
+  },
+  "dependencies": {
+    "angular-hotkeys": "chieffancypants/angular-hotkeys#~1.2.0"
+  }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
new file mode 100644
index 0000000..de59a45
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
@@ -0,0 +1,535 @@
+/**
+ * Catalog Record Buckets
+ *
+ * Known Issues
+ *
+ * add-all actions only add visible/fetched items.
+ * remove all from bucket UI leaves busted pagination 
+ *   -- apply a refresh after item removal?
+ * problems with bucket view fetching by record ID instead of bucket item:
+ *   -- dupe bibs always sort to the bottom
+ *   -- dupe bibs result in more records displayed per page than requested
+ *   -- item 'pos' ordering is not honored on initial load.
+ */
+
+angular.module('egCatRecordBuckets', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/cat/bucket/record/search/:id', {
+        templateUrl: './cat/bucket/record/t_search',
+        controller: 'SearchCtrl',
+        resolve : resolver
+    });
+    
+    $routeProvider.when('/cat/bucket/record/search', {
+        templateUrl: './cat/bucket/record/t_search',
+        controller: 'SearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/pending/:id', {
+        templateUrl: './cat/bucket/record/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/pending', {
+        templateUrl: './cat/bucket/record/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/view/:id', {
+        templateUrl: './cat/bucket/record/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/view', {
+        templateUrl: './cat/bucket/record/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/cat/bucket/record/view'});
+})
+
+/**
+ * bucketSvc allows us to communicate between the search,
+ * pending, and view controllers.  It also allows us to cache
+ * data for each so that data reloads are not needed on every 
+ * tab click (i.e. route persistence).
+ */
+.factory('bucketSvc', ['$q','egCore', function($q,  egCore) { 
+
+    var service = {
+        allBuckets : [], // un-fleshed user buckets
+        queryString : '', // last run query
+        queryRecords : [], // last run query results
+        currentBucket : null, // currently viewed bucket
+
+        // per-page list collections
+        searchList  : [],
+        pendingList : [],
+        viewList  : [],
+
+        // fetches all staff/biblio buckets for the authenticated user
+        // this function may only be called after startup.
+        fetchUserBuckets : function(force) {
+            if (this.allBuckets.length && !force) return;
+            var self = this;
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.retrieve_by_class.authoritative',
+                egCore.auth.token(), egCore.auth.user().id(), 
+                'biblio', 'staff_client'
+            ).then(function(buckets) { self.allBuckets = buckets });
+        },
+
+        createBucket : function(name, desc) {
+            var deferred = $q.defer();
+            var bucket = new egCore.idl.cbreb();
+            bucket.owner(egCore.auth.user().id());
+            bucket.name(name);
+            bucket.description(desc || '');
+            bucket.btype('staff_client');
+
+            egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.create',
+                egCore.auth.token(), 'biblio', bucket
+            ).then(function(resp) {
+                if (resp) {
+                    if (typeof resp == 'object') {
+                        console.error('bucket create error: ' + js2JSON(resp));
+                        deferred.reject();
+                    } else {
+                        deferred.resolve(resp);
+                    }
+                }
+            });
+
+            return deferred.promise;
+        },
+
+        // edit the current bucket.  since we edit the 
+        // local object, there's no need to re-fetch.
+        editBucket : function(args) {
+            var bucket = service.currentBucket;
+            bucket.name(args.name);
+            bucket.description(args.desc);
+            bucket.pub(args.pub);
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.update',
+                egCore.auth.token(), 'biblio', bucket
+            );
+        }
+    }
+
+    // returns 1 if full refresh is needed
+    // returns 2 if list refresh only is needed
+    service.bucketRefreshLevel = function(id) {
+        if (!service.currentBucket) return 1;
+        if (service.bucketNeedsRefresh) {
+            service.bucketNeedsRefresh = false;
+            service.currentBucket = null;
+            return 1;
+        }
+        if (service.currentBucket.id() != id) return 1;
+        return 2;
+    }
+
+    // returns a promise, resolved with bucket, rejected if bucket is
+    // not fetch-able
+    service.fetchBucket = function(id) {
+        var refresh = service.bucketRefreshLevel(id);
+        if (refresh == 2) return $q.when(service.currentBucket);
+
+        var deferred = $q.defer();
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.flesh.authoritative',
+            egCore.auth.token(), 'biblio', id
+        ).then(function(bucket) {
+            var evt = egCore.evt.parse(bucket);
+            if (evt) {
+                console.debug(evt);
+                deferred.reject(evt);
+                return;
+            }
+            service.currentBucket = bucket;
+            deferred.resolve(bucket);
+        });
+
+        return deferred.promise;
+    }
+
+    // deletes a single container item from a bucket by container item ID.
+    // promise is rejected on failure
+    service.detachRecord = function(itemId) {
+        var deferred = $q.defer();
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.item.delete',
+            egCore.auth.token(), 'biblio', itemId
+        ).then(function(resp) { 
+            var evt = egCore.evt.parse(resp);
+            if (evt) {
+                console.error(evt);
+                deferred.reject(evt);
+                return;
+            }
+            console.log('detached bucket item ' + itemId);
+            deferred.resolve(resp);
+        });
+
+        return deferred.promise;
+    }
+
+    // delete bucket by ID.
+    // resolved w/ response on successful delete,
+    // rejected otherwise.
+    service.deleteBucket = function(id) {
+        var deferred = $q.defer();
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.full_delete',
+            egCore.auth.token(), 'biblio', id
+        ).then(function(resp) {
+            var evt = egCore.evt.parse(resp);
+            if (evt) {
+                console.error(evt);
+                deferred.reject(evt);
+                return;
+            }
+            deferred.resolve(resp);
+        });
+        return deferred.promise;
+    }
+
+    return service;
+}])
+
+/**
+ * Top-level controller.  
+ * Hosts functions needed by all controllers.
+ */
+.controller('RecordBucketCtrl',
+       ['$scope','$location','$q','$timeout','$modal',
+        '$window','egCore','bucketSvc',
+function($scope,  $location,  $q,  $timeout,  $modal,  
+         $window,  egCore,  bucketSvc) {
+
+    $scope.bucketSvc = bucketSvc;
+    $scope.bucket = function() { return bucketSvc.currentBucket }
+
+    // tabs: search, pending, view
+    $scope.setTab = function(tab) { 
+        $scope.tab = tab;
+
+        // for bucket selector; must be called after route resolve
+        bucketSvc.fetchUserBuckets(); 
+    };
+
+    $scope.loadBucketFromMenu = function(item, bucket) {
+        if (bucket) return $scope.loadBucket(bucket.id());
+    }
+
+    $scope.loadBucket = function(id) {
+        $location.path(
+            '/cat/bucket/record/' + 
+                $scope.tab + '/' + encodeURIComponent(id));
+    }
+
+    $scope.addToBucket = function(recs) {
+        if (recs.length == 0) return;
+        bucketSvc.bucketNeedsRefresh = true;
+
+        angular.forEach(recs,
+            function(rec) {
+                var item = new egCore.idl.cbrebi();
+                item.bucket(bucketSvc.currentBucket.id());
+                item.target_biblio_record_entry(rec.id);
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.container.item.create', 
+                    egCore.auth.token(), 'biblio', item
+                ).then(function(resp) {
+
+                    // HACK: add the IDs of the added items so that the size
+                    // of the view list will grow (and update any UI looking at
+                    // the list size).  The data stored is inconsistent, but since
+                    // we are forcing a bucket refresh on the next rendering of 
+                    // the view pane, the list will be repaired.
+                    bucketSvc.currentBucket.items().push(resp);
+                });
+            }
+        );
+    }
+
+    $scope.openCreateBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_bucket_create',
+            controller: 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }]
+        }).result.then(function (args) {
+            if (!args || !args.name) return;
+            bucketSvc.createBucket(args.name, args.desc).then(
+                function(id) {
+                    if (!id) return;
+                    bucketSvc.viewList = [];
+                    bucketSvc.allBuckets = []; // reset
+                    bucketSvc.currentBucket = null;
+                    $location.path(
+                        '/cat/bucket/record/' + $scope.tab + '/' + id);
+                }
+            );
+        });
+    }
+
+    $scope.openEditBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_bucket_edit',
+            controller: 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.args = {
+                    name : bucketSvc.currentBucket.name(),
+                    desc : bucketSvc.currentBucket.description(),
+                    pub : bucketSvc.currentBucket.pub() == 't'
+                };
+                $scope.ok = function(args) { 
+                    if (!args) return;
+                    $scope.actionPending = true;
+                    args.pub = args.pub ? 't' : 'f';
+                    // close the dialog after edit has completed
+                    bucketSvc.editBucket(args).then(
+                        function() { $modalInstance.close() });
+                }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }]
+        })
+    }
+
+
+    // opens the delete confirmation and deletes the current
+    // bucket if the user confirms.
+    $scope.openDeleteBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_bucket_delete',
+            controller : 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.bucket = function() { return bucketSvc.currentBucket }
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function () {
+            bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
+            .then(function() {
+                bucketSvc.allBuckets = [];
+                $location.path('/cat/bucket/record/view');
+            });
+        });
+    }
+
+    // retrieves the requested bucket by ID
+    $scope.openSharedBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_load_shared',
+            controller : 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.ok = function(args) { 
+                    if (args && args.id) {
+                        $modalInstance.close(args.id) 
+                    }
+                }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function(id) {
+            // RecordBucketCtrl $scope is not inherited by the
+            // modal, so we need to call loadBucket from the 
+            // promise resolver.
+            $scope.loadBucket(id);
+        });
+    }
+
+    // opens the record export dialog
+    $scope.openExportBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_bucket_export',
+            controller : 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function (args) {
+            if (!args) return;
+            args.containerid = bucketSvc.currentBucket.id();
+
+            var url = '/exporter?containerid=' + args.containerid + 
+                '&format=' + args.format + '&encoding=' + args.encoding;
+
+            if (args.holdings) url += '&holdings=1';
+
+            // TODO: improve auth cookie handling so this isn't necessary.
+            // today the cookie path is too specific (/eg/staff) for non-staff
+            // UIs to access it.  See services/auth.js
+            url += '&ses=' + egCore.auth.token(); 
+
+            $timeout(function() { $window.open(url) });
+        });
+    }
+}])
+
+.controller('SearchCtrl',
+       ['$scope','$routeParams','egCore','bucketSvc',
+function($scope,  $routeParams,  egCore , bucketSvc) {
+
+    $scope.setTab('search');
+    $scope.focusMe = true;
+    var idQueryHash = {};
+
+    function generateQuery() {
+        if (bucketSvc.queryRecords.length)
+            return {id : bucketSvc.queryRecords};
+        else 
+            return null;
+    }
+
+    $scope.gridControls = {
+        setQuery : function() {return generateQuery()},
+        setSort : function() {return ['id']}
+    }
+
+    // add selected items directly to the pending list
+    $scope.addToPending = function(recs) {
+        angular.forEach(recs, function(rec) {
+            if (bucketSvc.pendingList.filter( // remove dupes
+                function(r) {return r.id == rec.id}).length) return;
+            bucketSvc.pendingList.push(rec);
+        });
+    }
+
+    $scope.search = function() {
+        $scope.searchList = [];
+        $scope.searchInProgress = true;
+        bucketSvc.queryRecords = [];
+
+        egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.multiclass.query', {   
+                limit : 500 // meh
+            }, bucketSvc.queryString, true
+        ).then(function(resp) {
+            bucketSvc.queryRecords = 
+                resp.ids.map(function(id){return id[0]});
+            $scope.gridControls.setQuery(generateQuery());
+        })['finally'](function() {
+            $scope.searchInProgress = false;
+        });
+    }
+
+    if ($routeParams.id && 
+        (!bucketSvc.currentBucket || 
+            bucketSvc.currentBucket.id() != $routeParams.id)) {
+        // user has accessed this page cold with a bucket ID.
+        // fetch the bucket for display, then set the totalCount
+        // (also for display), but avoid fully fetching the bucket,
+        // since it's premature, in this UI.
+        bucketSvc.fetchBucket($routeParams.id);
+    }
+}])
+
+.controller('PendingCtrl',
+       ['$scope','$routeParams','bucketSvc','egGridDataProvider',
+function($scope,  $routeParams,  bucketSvc , egGridDataProvider) {
+    $scope.setTab('pending');
+
+    var provider = egGridDataProvider.instance({});
+    provider.get = function(offset, count) {
+        return provider.arrayNotifier(
+            bucketSvc.pendingList, offset, count);
+    }
+    $scope.gridDataProvider = provider;
+
+    $scope.resetPendingList = function() {
+        bucketSvc.pendingList = [];
+    }
+    
+
+    if ($routeParams.id && 
+        (!bucketSvc.currentBucket || 
+            bucketSvc.currentBucket.id() != $routeParams.id)) {
+        // user has accessed this page cold with a bucket ID.
+        // fetch the bucket for display, then set the totalCount
+        // (also for display), but avoid fully fetching the bucket,
+        // since it's premature, in this UI.
+        bucketSvc.fetchBucket($routeParams.id);
+    }
+}])
+
+.controller('ViewCtrl',
+       ['$scope','$q','$routeParams','bucketSvc',
+function($scope,  $q , $routeParams,  bucketSvc) {
+
+    $scope.setTab('view');
+    $scope.bucketId = $routeParams.id;
+
+    var query;
+    $scope.gridControls = {
+        setQuery : function(q) {
+            if (q) query = q;
+            return query;
+        }
+    };
+
+    function drawBucket() {
+        return bucketSvc.fetchBucket($scope.bucketId).then(
+            function(bucket) {
+                var ids = bucket.items().map(
+                    function(i){return i.target_biblio_record_entry()}
+                );
+                if (ids.length) {
+                    $scope.gridControls.setQuery({id : ids});
+                } else {
+                    $scope.gridControls.setQuery({});
+                }
+            }
+        );
+    }
+
+    $scope.detachRecords = function(records) {
+        var promises = [];
+        angular.forEach(records, function(rec) {
+            var item = bucketSvc.currentBucket.items().filter(
+                function(i) {
+                    return (i.target_biblio_record_entry() == rec.id)
+                }
+            );
+            if (item.length)
+                promises.push(bucketSvc.detachRecord(item[0].id()));
+        });
+
+        bucketSvc.bucketNeedsRefresh = true;
+        return $q.all(promises).then(drawBucket);
+    }
+
+    // fetch the bucket;  on error show the not-allowed message
+    if ($scope.bucketId) 
+        drawBucket()['catch'](function() { $scope.forbidden = true });
+}])
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
new file mode 100644
index 0000000..8c06ad5
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
@@ -0,0 +1,209 @@
+/**
+ * TPAC Frame App
+ *
+ * currently, this app doesn't use routes for each sub-ui, because 
+ * reloading the catalog each time is sloooow.  better so far to 
+ * swap out divs w/ ng-if / ng-show / ng-hide as needed.
+ *
+ */
+
+angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/cat/catalog/index', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    // create some catalog page-specific mappings
+    $routeProvider.when('/cat/catalog/record/:record_id', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    // create some catalog page-specific mappings
+    $routeProvider.when('/cat/catalog/record/:record_id/:record_tab', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/cat/catalog/index'});
+})
+
+
+/**
+ * */
+.controller('CatalogCtrl',
+       ['$scope','$routeParams','$location','$q','egCore','egHolds',
+        'egGridDataProvider','egHoldGridActions',
+function($scope , $routeParams , $location , $q , egCore , egHolds, 
+         egGridDataProvider , egHoldGridActions) {
+
+    // set record ID on page load if available...
+    $scope.record_id = $routeParams.record_id;
+
+    // also set it when the iframe changes to a new record
+    $scope.handle_page = function(url) {
+
+        if (!url || url == 'about:blank') {
+            // nothing loaded.  If we already have a record ID, leave it.
+            return;
+        }
+
+        var match = url.match(/\/+opac\/+record\/+(\d+)/);
+        if (match) {
+            $scope.record_id = match[1];
+
+            // force the record_id to show up in the page.  
+            // not sure why a $digest isn't occuring here.
+            try { $scope.$apply() } catch(E) {}
+        } else {
+            delete $scope.record_id;
+        }
+    }
+
+    // xulG catalog handlers
+    $scope.handlers = { }
+
+    // ------------------------------------------------------------------
+    // Holds 
+    var provider = egGridDataProvider.instance({});
+    $scope.hold_grid_data_provider = provider;
+    $scope.grid_actions = egHoldGridActions;
+    $scope.hold_grid_controls = {};
+
+    var hold_ids = []; // current list of holds
+    function fetchHolds(offset, count) {
+        var ids = hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                return hold_data;
+            }
+        );
+    }
+
+    provider.get = function(offset, count) {
+        if ($scope.record_tab != 'holds') return $q.when();
+        var deferred = $q.defer();
+        hold_ids = []; // no caching ATM
+
+        // fetch the IDs
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.holds.retrieve_all_from_title',
+            egCore.auth.token(), $scope.record_id, 
+            {pickup_lib : $scope.pickup_ou.id()}
+        ).then(
+            function(hold_data) {
+                angular.forEach(hold_data, function(list, type) {
+                    hold_ids = hold_ids.concat(list);
+                });
+                fetchHolds(offset, count).then(
+                    deferred.resolve, null, deferred.notify);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $scope.detail_hold_id = h.hold.id();
+        }
+    }
+
+    $scope.list_view = function(items) {
+         $scope.detail_hold_id = null;
+    }
+
+    // refresh the list of record holds when the pickup lib is changed.
+    $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+    $scope.pickup_ou_changed = function(org) {
+        $scope.pickup_ou = org;
+        provider.refresh();
+    }
+
+    $scope.print_holds = function() {
+        var holds = [];
+        angular.forEach($scope.hold_grid_controls.allItems(), function(item) {
+            holds.push({
+                hold : egCore.idl.toHash(item.hold),
+                patron_last : item.patron_last,
+                patron_alias : item.patron_alias,
+                patron_barcode : item.patron_barcode,
+                copy : egCore.idl.toHash(item.copy),
+                volume : egCore.idl.toHash(item.volume),
+                title : item.mvr.title(),
+                author : item.mvr.author()
+            });
+        });
+
+        egCore.print.print({
+            context : 'receipt', 
+            template : 'holds_for_bib', 
+            scope : {holds : holds}
+        });
+    }
+
+    $scope.mark_hold_transfer_dest = function() {
+        egCore.hatch.setLocalItem(
+            'eg.circ.hold.title_transfer_target', $scope.record_id);
+    }
+
+    // UI presents this option as "all holds"
+    $scope.transfer_holds_to_marked = function() {
+        var hold_ids = $scope.hold_grid_controls.allItems().map(
+            function(hold_data) {return hold_data.hold.id()});
+        egHolds.transfer_to_marked_title(hold_ids);
+    }
+
+    // ------------------------------------------------------------------
+    // Initialize the selected tab
+
+    function init_cat_url() {
+        // Set the initial catalog URL.  This only happens once.
+        // The URL is otherwise generated through user navigation.
+        if ($scope.catalog_url) return; 
+
+        var url = $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+        // A record ID in the path indicates a request for the record-
+        // specific page.
+        if ($routeParams.record_id) {
+            url = url.replace(/advanced/, '/record/' + $scope.record_id);
+        }
+
+        $scope.catalog_url = url;
+    }
+
+    $scope.set_record_tab = function(tab) {
+        $scope.record_tab = tab;
+
+        switch(tab) {
+
+            case 'catalog':
+                init_cat_url();
+                break;
+
+            case 'holds':
+                $scope.detail_hold_record_id = $scope.record_id; 
+                // refresh the holds grid
+                provider.refresh();
+                break;
+        }
+    }
+
+    var tab = $routeParams.record_tab || 'catalog';
+    $scope.set_record_tab(tab);
+
+}])
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/app.js b/Open-ILS/web/js/ui/default/staff/cat/item/app.js
new file mode 100644
index 0000000..82ed923
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/item/app.js
@@ -0,0 +1,540 @@
+/**
+ * Item Display
+ */
+
+angular.module('egItemStatus', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    // search page shows the list view by default
+    $routeProvider.when('/cat/item/search', {
+        templateUrl: './cat/item/t_list',
+        controller: 'ListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/item/:id', {
+        templateUrl: './cat/item/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/item/:id/:tab', {
+        templateUrl: './cat/item/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/cat/item/search'});
+})
+
+.factory('itemSvc', 
+       ['egCore',
+function(egCore) {
+
+    var service = {
+        copies : [], // copy barcode search results
+        index : 0 // search grid index
+    };
+
+    service.flesh = {   
+        flesh : 3, 
+        flesh_fields : {
+            acp : ['call_number','location','status','location'],
+            acn : ['record','prefix','suffix'],
+            bre : ['simple_record','creator','editor']
+        },
+        select : { 
+            // avoid fleshing MARC on the bre
+            // note: don't add simple_record.. not sure why
+            bre : ['id','tcn_value','creator','editor'],
+        } 
+    }
+
+    // resolved with the last received copy
+    service.fetch = function(barcode, id, noListDupes) {
+        var promise;
+
+        if (barcode) {
+            promise = egCore.pcrud.search('acp', 
+                {barcode : barcode, deleted : 'f'}, service.flesh);
+        } else {
+            promise = egCore.pcrud.retrieve('acp', id, service.flesh);
+        }
+
+        var lastRes;
+        return promise.then(
+            function() {return lastRes},
+            null, // error
+
+            // notify reads the stream of copies, one at a time.
+            function(copy) {
+
+                var flatCopy;
+                if (noListDupes) {
+                    // use the existing copy if possible
+                    flatCopy = service.copies.filter(
+                        function(c) {return c.id == copy.id()})[0];
+                }
+
+                if (!flatCopy) {
+                    flatCopy = egCore.idl.toHash(copy, true);
+                    flatCopy.index = service.index++;
+                    service.copies.unshift(flatCopy);
+                }
+
+                return lastRes = {
+                    copy : copy, 
+                    index : flatCopy.index
+                }
+            }
+        );
+    }
+
+    return service;
+}])
+
+/**
+ * Search bar along the top of the page.
+ * Parent scope for list and detail views
+ */
+.controller('SearchCtrl', 
+       ['$scope','$location','egCore','egGridDataProvider','itemSvc',
+function($scope , $location , egCore , egGridDataProvider , itemSvc) {
+    $scope.args = {}; // search args
+
+    // sub-scopes (search / detail-view) apply their version 
+    // of retrieval function to $scope.context.search
+    // and display toggling via $scope.context.toggleDisplay
+    $scope.context = {
+        selectBarcode : true
+    };
+
+    $scope.toggleView = function($event) {
+        $scope.context.toggleDisplay();
+        $event.preventDefault(); // avoid form submission
+    }
+}])
+
+/**
+ * List view - grid stuff
+ */
+.controller('ListCtrl', 
+       ['$scope','$q','$location','$timeout','egCore','egGridDataProvider','itemSvc',
+function($scope , $q , $location , $timeout , egCore , egGridDataProvider , itemSvc) {
+    $scope.context.page = 'list';
+
+    /*
+    var provider = egGridDataProvider.instance();
+    provider.get = function(offset, count) {
+    }
+    */
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            //return provider.arrayNotifier(itemSvc.copies, offset, count);
+            return this.arrayNotifier(itemSvc.copies, offset, count);
+        }
+    });
+
+    // If a copy was just displayed in the detail view, ensure it's
+    // focused in the list view.
+    var selected = false;
+    var copyGrid = $scope.gridControls = {
+        itemRetrieved : function(item) {
+            if (selected || !itemSvc.copy) return;
+            if (itemSvc.copy.id() == item.id) {
+                copyGrid.selectItems([item.index]);
+                selected = true;
+            }
+        }
+    };
+
+    $scope.$watch('barcodesFromFile', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.args.barcode = '';
+            var barcodes = [];
+
+            angular.forEach(newVal.split(/\n/), function(line) {
+                if (!line) return;
+                // scrub any trailing spaces or commas from the barcode
+                line = line.replace(/(.*?)($|\s.*|,.*)/,'$1');
+                barcodes.push(line);
+            });
+
+            itemSvc.fetch(barcodes).then(
+                function() {
+                    copyGrid.refresh();
+                    copyGrid.selectItems([itemSvc.copies[0].index]);
+                }
+            );
+        }
+    });
+
+    $scope.context.search = function(args) {
+        if (!args.barcode) return;
+        $scope.context.itemNotFound = false;
+        itemSvc.fetch(args.barcode).then(function(res) {
+            if (res) {
+                copyGrid.refresh();
+                copyGrid.selectItems([res.index]);
+                $scope.args.barcode = '';
+            } else {
+                $scope.context.itemNotFound = true;
+            }
+            $scope.context.selectBarcode = true;
+        })
+    }
+
+    $scope.context.toggleDisplay = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item) 
+            $location.path('/cat/item/' + item.id);
+    }
+
+    $scope.context.show_triggered_events = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item) 
+            $location.path('/cat/item/' + item.id + '/triggered_events');
+    }
+
+}])
+
+/**
+ * Detail view -- shows one copy
+ */
+.controller('ViewCtrl', 
+       ['$scope','$q','$location','$routeParams','egCore','itemSvc','egBilling',
+function($scope , $q , $location , $routeParams , egCore , itemSvc , egBilling) {
+    var copyId = $routeParams.id;
+    $scope.tab = $routeParams.tab || 'summary';
+    $scope.context.page = 'detail';
+
+    // use the cached record info
+    if (itemSvc.copy)
+        $scope.summaryRecord = itemSvc.copy.call_number().record();
+
+    function loadCopy(barcode) {
+        $scope.context.itemNotFound = false;
+
+        // Avoid re-fetching the same copy while jumping tabs.
+        // In addition to being quicker, this helps to avoid flickering
+        // of the top panel which is always visible in the detail view.
+        //
+        // 'barcode' represents the loading of a new item - refetch it
+        // regardless of whether it matches the current item.
+        if (!barcode && itemSvc.copy && itemSvc.copy.id() == copyId) {
+            $scope.copy = itemSvc.copy;
+            $scope.recordId = itemSvc.copy.call_number().record().id();
+            return $q.when();
+        }
+
+        delete $scope.copy;
+        delete itemSvc.copy;
+
+        var deferred = $q.defer();
+        itemSvc.fetch(barcode, copyId, true).then(function(res) {
+            $scope.context.selectBarcode = true;
+
+            if (!res) {
+                copyId = null;
+                $scope.context.itemNotFound = true;
+                deferred.reject(); // avoid propagation of data fetch calls
+                return;
+            }
+
+            var copy = res.copy;
+            itemSvc.copy = copy;
+
+
+            $scope.copy = copy;
+            $scope.recordId = copy.call_number().record().id();
+            $scope.summaryRecord = itemSvc.copy.call_number().record();
+            $scope.args.barcode = '';
+
+            // locally flesh org units
+            copy.circ_lib(egCore.org.get(copy.circ_lib()));
+            copy.call_number().owning_lib(
+                egCore.org.get(copy.call_number().owning_lib()));
+
+            var r = copy.call_number().record();
+            if (r.owner()) r.owner(egCore.org.get(r.owner())); 
+
+            // make boolean for auto-magic true/false display
+            angular.forEach(
+                ['ref','opac_visible','holdable','floating','circulate'],
+                function(field) { copy[field](Boolean(copy[field]() == 't')) }
+            );
+
+            // finally, if this is a different copy, redirect.
+            // Note that we flesh first since the copy we just
+            // fetched will be used after the redirect.
+            if (copyId && copyId != copy.id()) {
+                // if a new barcode is scanned in the detail view,
+                // update the url to match the ID of the new copy
+                $location.path('/cat/item/' + copy.id() + '/' + $scope.tab);
+                deferred.reject(); // avoid propagation of data fetch calls
+                return;
+            }
+            copyId = copy.id();
+
+            deferred.resolve();
+        });
+
+        return deferred.promise;
+    }
+
+    // if loadPrev load the two most recent circulations
+    function loadCurrentCirc(loadPrev) {
+        delete $scope.circ;
+        delete $scope.circ_summary;
+        delete $scope.prev_circ_summary;
+        if (!copyId) return;
+        
+        egCore.pcrud.search('circ', 
+            {target_copy : copyId},
+            {   flesh : 2,
+                flesh_fields : {
+                    circ : [
+                        'usr',
+                        'workstation',                                         
+                        'checkin_workstation',                                 
+                        'duration_rule',                                       
+                        'max_fine_rule',                                       
+                        'recurring_fine_rule'   
+                    ],
+                    au : ['card']
+                },
+                order_by : {circ : 'xact_start desc'}, 
+                limit :  1
+            }
+
+        ).then(null, null, function(circ) {
+            $scope.circ = circ;
+
+            // load the chain for this circ
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.renewal_chain.retrieve_by_circ.summary',
+                egCore.auth.token(), $scope.circ.id()
+            ).then(function(summary) {
+                $scope.circ_summary = summary.summary;
+            });
+
+            if (!loadPrev) return;
+
+            // load the chain for the previous circ, plus the user
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.prev_renewal_chain.retrieve_by_circ.summary',
+                egCore.auth.token(), $scope.circ.id()
+
+            ).then(null, null, function(summary) {
+                $scope.prev_circ_summary = summary.summary;
+
+                egCore.pcrud.retrieve('au', summary.usr,
+                    {flesh : 1, flesh_fields : {au : ['card']}})
+
+                .then(function(user) {
+                    $scope.prev_circ_usr = user;
+                });
+            });
+        });
+    }
+
+    var maxHistory;
+    function fetchMaxCircHistory() {
+        if (maxHistory) return $q.when(maxHistory);
+        return egCore.org.settings(
+            'circ.item_checkout_history.max')
+        .then(function(set) {
+            maxHistory = set['circ.item_checkout_history.max'] || 4;
+            return maxHistory;
+        });
+    }
+
+    $scope.addBilling = function(circ) {
+        egBilling.showBillDialog({
+            xact_id : circ.id(),
+            patron : circ.usr()
+        });
+    }
+
+    function loadCircHistory() {
+        $scope.circ_list = [];
+
+        var copy_org = 
+            itemSvc.copy.call_number().id() == -1 ?
+            itemSvc.copy.circ_lib().id() :
+            itemSvc.copy.call_number().owning_lib().id()
+
+        // there is an extra layer of permissibility over circ
+        // history views
+        egCore.perm.hasPermAt('VIEW_COPY_CHECKOUT_HISTORY', true)
+        .then(function(orgIds) {
+
+            if (orgIds.indexOf(copy_org) == -1) {
+                console.log('User is not allowed to view circ history');
+                return $q.when(0);
+            }
+
+            return fetchMaxCircHistory();
+
+        }).then(function(count) {
+
+            egCore.pcrud.search('circ', 
+                {target_copy : copyId},
+                {   flesh : 2,
+                    flesh_fields : {
+                        circ : [
+                            'usr',
+                            'workstation',                                         
+                            'checkin_workstation',                                 
+                            'recurring_fine_rule'   
+                        ],
+                        au : ['card']
+                    },
+                    order_by : {circ : 'xact_start desc'}, 
+                    limit :  count
+                }
+
+            ).then(null, null, function(circ) {
+
+                // flesh circ_lib locally
+                circ.circ_lib(egCore.org.get(circ.circ_lib()));
+                circ.checkin_lib(egCore.org.get(circ.checkin_lib()));
+                $scope.circ_list.push(circ);
+            });
+        });
+    }
+
+
+    function loadCircCounts() {
+
+        delete $scope.circ_counts;
+        $scope.total_circs = 0;
+        $scope.total_circs_this_year = 0;
+        $scope.total_circs_prev_year = 0;
+        if (!copyId) return;
+
+        egCore.pcrud.search('circbyyr', 
+            {copy : copyId}, null, {atomic : true})
+
+        .then(function(counts) {
+            $scope.circ_counts = counts;
+
+            angular.forEach(counts, function(count) {
+                $scope.total_circs += Number(count.count());
+            });
+
+            var this_year = counts.filter(function(c) {
+                return c.year() == new Date().getFullYear();
+            });
+
+            $scope.total_circs_this_year = 
+                this_year.length ? this_year[0].count() : 0;
+
+            var prev_year = counts.filter(function(c) {
+                return c.year() == new Date().getFullYear() - 1;
+            });
+
+            $scope.total_circs_prev_year = 
+                prev_year.length ? prev_year[0].count() : 0;
+
+        });
+    }
+
+    function loadHolds() {
+        delete $scope.hold;
+        if (!copyId) return;
+
+        egCore.pcrud.search('ahr', 
+            {   current_copy : copyId, 
+                cancel_time : null, 
+                fulfillment_time : null,
+                capture_time : {'<>' : null}
+            }, {
+                flesh : 2,
+                flesh_fields : {
+                    ahr : ['requestor', 'usr'],
+                    au  : ['card']
+                }
+            }
+        ).then(null, null, function(hold) {
+            $scope.hold = hold;
+            hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+            if (hold.current_shelf_lib()) {
+                hold.current_shelf_lib(
+                    egCore.org.get(hold.current_shelf_lib()));
+            }
+            hold.behind_desk(Boolean(hold.behind_desk() == 't'));
+        });
+    }
+
+    function loadTransits() {
+        delete $scope.transit;
+        delete $scope.hold_transit;
+        if (!copyId) return;
+
+        egCore.pcrud.search('atc', 
+            {target_copy : copyId},
+            {order_by : {atc : 'source_send_time DESC'}}
+
+        ).then(null, null, function(transit) {
+            $scope.transit = transit;
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+        })
+    }
+
+
+    // we don't need all data on all tabs, so fetch what's needed when needed.
+    function loadTabData() {
+        switch($scope.tab) {
+            case 'summary':
+                loadCurrentCirc();
+                loadCircCounts();
+                break;
+
+            case 'circs':
+                loadCurrentCirc(true);
+                break;
+
+            case 'circ_list':
+                loadCircHistory();
+                break;
+
+            case 'holds':
+                loadHolds()
+                loadTransits();
+                break;
+
+            case 'triggered_events':
+                var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+                url += '?copy_id=' + encodeURIComponent(copyId);
+                $scope.triggered_events_url = url;
+                $scope.funcs = {};
+        }
+    }
+
+    $scope.context.toggleDisplay = function() {
+        $location.path('/cat/item/search');
+    }
+
+    // handle the barcode scan box, which will replace our current copy
+    $scope.context.search = function(args) {
+        loadCopy(args.barcode).then(loadTabData);
+    }
+
+    $scope.context.show_triggered_events = function() {
+        $location.path('/cat/item/' + copyId + '/triggered_events');
+    }
+
+    loadCopy().then(loadTabData);
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js b/Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js
new file mode 100644
index 0000000..06fad21
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js
@@ -0,0 +1,123 @@
+angular.module('egItemMissingPieces',
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('MissingPiecesCtrl',
+       ['$scope','$q','$window','$location','egCore','egConfirmDialog','egAlertDialog','egCirc',
+function($scope , $q , $window , $location , egCore , egConfirmDialog , egAlertDialog , egCirc) {
+    
+    $scope.selectMe = true; // focus text input
+    $scope.args = {};
+
+    function get_copy(barcode) {
+
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'asset', barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return $q.reject();
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = barcode;
+                $scope.selectMe = true;
+                return $q.reject();
+            }
+
+            return egCore.pcrud.search('acp', {id : resp[0].id}, {
+                flesh : 3, 
+                flesh_fields : {
+                    acp : ['call_number'],
+                    acn : ['record'],
+                    bre : ['simple_record']
+                },
+                select : { 
+                    // avoid fleshing MARC on the bre
+                    // note: don't add simple_record.. not sure why
+                    bre : ['id']
+                } 
+            })
+        })
+    }
+
+    function mark_missing_pieces(copy) {
+
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_MARK_MISSING_TITLE,
+            egCore.strings.CONFIRM_MARK_MISSING_BODY, {
+            barcode : copy.barcode(), 
+            title : copy.call_number().record().simple_record().title()
+
+        }).result.then(function() {
+
+            // kick off mark missing
+            return egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.mark_item_missing_pieces',
+                egCore.auth.token(), copy.id()
+            )
+
+        }).then(function(resp) {
+            var evt = egCore.evt.parse(resp); // should always produce event
+
+            if (evt.textcode == 'ACTION_CIRCULATION_NOT_FOUND') {
+                return egAlertDialog.open(
+                    egCore.strings.CIRC_NOT_FOUND, {barcode : copy.barcode()});
+            }
+
+            var payload = evt.payload;
+
+            // TODO: open copy editor inline?  new tab?
+
+            // print the missing pieces slip
+            var promise = $q.when();
+            if (payload.slip) {
+                // wait for completion, since it may spawn a confirm dialog
+                promise = egCore.print.print({
+                    context : 'default', 
+                    content_type : 'text/html',
+                    content : payload.slip.template_output().data()
+                });
+            }
+
+            if (payload.letter) {
+                $scope.letter = payload.letter.template_output().data();
+            }
+
+            // apply patron penalty
+            if (payload.circ) {
+                promise.then(function() {
+                    egCirc.create_penalty(payload.circ.usr())
+                });
+            }  
+
+        });
+    }
+
+    $scope.print_letter = function() {
+        egCore.print.print({
+            context : 'mail',
+            content_type : 'text/plain',
+            content : $scope.letter
+        });
+    }
+
+    // find the item by barcode, then proceed w/ missing pieces
+    $scope.submitBarcode = function(args) {
+
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        $scope.selectMe = false;
+        $scope.letter = null;
+
+        get_copy(args.barcode).then(mark_missing_pieces);
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js b/Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js
new file mode 100644
index 0000000..33d1cb6
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js
@@ -0,0 +1,39 @@
+/**
+ * Item Display
+ */
+
+angular.module('egItemReplaceBarcode', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('ReplaceItemBarcodeCtrl',
+       ['$scope','egCore',
+function($scope , egCore) {
+    egCore.startup.go();
+
+    $scope.focusBarcode = true;
+
+    $scope.updateBarcode = function() {
+        $scope.copyNotFound = false;
+        $scope.updateOK = false;
+
+        egCore.pcrud.search('acp', 
+            {deleted : 'f', barcode : $scope.barcode1})
+        .then(function(copy) {
+
+            if (!copy) {
+                $scope.focusBarcode = true;
+                $scope.copyNotFound = true;
+                return;
+            }
+
+            $scope.copyId = copy.id();
+            copy.barcode($scope.barcode2);
+
+            egCore.pcrud.update(copy).then(function(stat) {
+                $scope.updateOK = stat;
+                $scope.focusBarcode = true;
+            });
+        });
+    }
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/cat/services/record.js b/Open-ILS/web/js/ui/default/staff/cat/services/record.js
new file mode 100644
index 0000000..6975b27
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/cat/services/record.js
@@ -0,0 +1,106 @@
+/**
+ * Simple directive for rending the HTML view of a bib record.
+ *
+ * <eg-record-html record-id="myRecordIdScopeVariable"></eg-record-id>
+ *
+ * The value of myRecordIdScopeVariable is watched internally and the 
+ * record is updated to match.
+ */
+angular.module('egCoreMod')
+
+.directive('egRecordHtml', function() {
+    return {
+        restrict : 'AE',
+        scope : {recordId : '='},
+        link : function(scope, element, attrs) {
+            scope.element = angular.element(element);
+
+            // kill refs to destroyed DOM elements
+            element.bind("$destroy", function() {
+                delete scope.element;
+            });
+        },
+        controller : 
+                   ['$scope','egCore',
+            function($scope , egCore) {
+
+                function loadRecordHtml() {
+                    egCore.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.html',
+                        $scope.recordId
+                    ).then(function(html) {
+                        if (!html) return;
+
+                        // Remove those pesky non-i8n labels / actions.
+                        // Note: for printing, use the browser print page
+                        // option.  The end result is the same.
+                        html = html.replace(
+                            /<button onclick="window.print(.*?)<\/button>/,'');
+                        html = html.replace(/<title>(.*?)<\/title>/,'');
+
+                        // remove reference to nonexistant CSS file
+                        html = html.replace(/<link(.*?)\/>/,'');
+
+                        $scope.element.html(html);
+                    });
+                }
+
+                $scope.$watch('recordId', 
+                    function(newVal, oldVal) {
+                        if (newVal && newVal !== oldVal) {
+                            loadRecordHtml();
+                        }
+                    }
+                );
+
+                if ($scope.recordId) 
+                    loadRecordHtml();
+            }
+        ]
+    }
+})
+
+/*
+ * A record='foo' attribute is required as a storage location of the 
+ * retrieved record
+ */
+.directive('egRecordSummary', function() {
+    return {
+        restrict : 'AE',
+        scope : {
+            recordId : '=',
+            record : '='
+        },
+        templateUrl : './cat/share/t_record_summary',
+        controller : 
+                   ['$scope','egCore',
+            function($scope , egCore) {
+
+                function loadRecord() {
+                    egCore.pcrud.retrieve('bre', $scope.recordId, {
+                        flesh : 1,
+                        flesh_fields : {
+                            bre : ['simple_record','creator','editor']
+                        }
+                    }).then(function(rec) {
+                        rec.owner(egCore.org.get(rec.owner()));
+                        $scope.record = rec;
+                    });
+                }
+
+                $scope.$watch('recordId', 
+                    function(newVal, oldVal) {
+                        if (newVal && newVal !== oldVal) {
+                            loadRecord();
+                        }
+                    }
+                );
+
+
+                if ($scope.recordId) 
+                    loadRecord();
+            }
+        ]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/circ/checkin/app.js b/Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
new file mode 100644
index 0000000..79b1fd7
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
@@ -0,0 +1,316 @@
+angular.module('egCheckinApp', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/checkin/checkin', {
+        templateUrl: './circ/checkin/t_checkin',
+        controller: 'CheckinCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/checkin/capture', {
+        templateUrl: './circ/checkin/t_checkin',
+        controller: 'CheckinCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/checkin/checkin'});
+})
+
+.factory('checkinSvc', [function() {
+    var service = {};
+    service.checkins = [];
+    return service;
+}])
+
+
+/**
+ * Manages checkin
+ */
+.controller('CheckinCtrl',
+       ['$scope','$q','$window','$location','egCore','checkinSvc','egGridDataProvider','egCirc',
+function($scope , $q , $window , $location , egCore , checkinSvc , egGridDataProvider , egCirc)  {
+
+    $scope.focusMe = true;
+    $scope.checkins = checkinSvc.checkins;
+    var today = new Date();
+    $scope.checkinArgs = {backdate : today}
+    $scope.using_hatch = egCore.hatch.usingHatch();
+    $scope.modifiers = {};
+    $scope.fine_total = 0;
+    $scope.is_capture = $location.path().match(/capture$/);
+    var suppress_popups = false;
+    $scope.grid_persist_key = $scope.is_capture ? 
+        'circ.checkin.capture' : 'circ.checkin.checkin';
+
+    egCore.org.settings([
+        'ui.circ.suppress_checkin_popups' // add other settings as needed
+    ]).then(function(set) {
+        suppress_popups = set['ui.circ.suppress_checkin_popups'];
+    });
+
+    // checkin & hold capture modifiers
+    var modifiers = [
+        'void_overdues', 
+        'clear_expired',
+        'hold_as_transit',
+        'manual_float',
+        'no_precat_alert',
+        'retarget_holds',
+        'retarget_holds_all'
+    ];
+
+    if ($scope.is_capture) {
+        // in hold capture mode, some values are forced, regardless
+        // of stored preferences.
+        $scope.modifiers.noop = false;
+        $scope.modifiers.auto_print_holds_transits = true;
+    } else {
+        modifiers.push('noop'); // AKA suppress holds and transits
+        modifiers.push('auto_print_holds_transits');
+    }
+
+    // set modifiers from stored preferences
+    angular.forEach(modifiers, function(mod) {
+        egCore.hatch.getItem('eg.circ.checkin.' + mod)
+        .then(function(val) { if (val) $scope.modifiers[mod] = true });
+    });
+
+    // set / unset a checkin modifier
+    // when set, store the preference
+    $scope.toggle_mod = function(mod) {
+        if ($scope.modifiers[mod]) {
+            $scope.modifiers[mod] = false;
+            egCore.hatch.removeItem('eg.circ.checkin.' + mod);
+        } else {
+            $scope.modifiers[mod] = true;
+            egCore.hatch.setItem('eg.circ.checkin.' + mod, true);
+        }
+    }
+
+
+    // ensure the backdate is not in the future
+    // note: input type=date max=foo not yet supported anywhere
+    $scope.$watch('checkinArgs.backdate', function(newval) {
+        if (newval && newval > today) 
+            $scope.checkinArgs.backdate = today;
+    });
+
+    $scope.is_backdate = function() {
+        return $scope.checkinArgs.backdate < today;
+    }
+
+    var checkinGrid = $scope.gridControls = {};
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.checkins, offset, count);
+        }
+    });
+
+    // turns the various inputs (form args, modifiers, etc.) into
+    // checkin params and options.
+    function compile_checkin_args(args) {
+        var params = angular.copy(args);
+
+        if (params.backdate) {
+            params.backdate = 
+                params.backdate.toISOString().replace(/T.*/,'');
+
+            // a backdate of 'today' is not really a backdate
+            if (params.backdate == $scope.max_backdate)
+                delete params.backdate;
+        }
+
+        angular.forEach(['noop','void_overdues',
+                'clear_expired','hold_as_transit','manual_float'],
+            function(opt) {
+                if ($scope.modifiers[opt]) params[opt] = true;
+            }
+        );
+
+        if ($scope.modifiers.retarget_holds) {
+            if ($scope.modifiers.retarget_holds_all) {
+                params.retarget_mode = 'retarget.all';
+            } else {
+                params.retarget_mode = 'retarget';
+            }
+        }
+
+        var options = {
+            check_barcode : $scope.strict_barcode,
+            no_precat_alert : $scope.modifiers.no_precat_alert,
+            auto_print_holds_transits : 
+                $scope.modifiers.auto_print_holds_transits,
+            suppress_popups : suppress_popups
+        };
+
+        return {params : params, options: options};
+    }
+
+    $scope.checkin = function(args) {
+
+        var compiled = compile_checkin_args(args);
+        args.copy_barcode = ''; // reset UI for next scan
+        $scope.focusMe = true;
+        delete $scope.alert;
+        delete $scope.billable_amount;
+        delete $scope.billable_barcode;
+
+        var params = compiled.params;
+        var options = compiled.options;
+
+        if (!params.copy_barcode) return;
+        delete $scope.alert;
+
+        var row_item = {
+            index : checkinSvc.checkins.length,
+            copy_barcode : params.copy_barcode
+        };
+
+        // track the item in the grid before sending the request
+        checkinSvc.checkins.unshift(row_item);
+        checkinGrid.refresh();
+
+        egCirc.checkin(params, options).then(
+        function(final_resp) {
+
+            row_item.evt = final_resp.evt;
+            angular.forEach(final_resp.data, function(val, key) {
+                row_item[key] = val;
+            });
+
+            if (row_item.mbts) {
+                var amt = Number(row_item.mbts.balance_owed());
+                if (amt != 0) {
+                    $scope.billable_barcode = row_item.copy_barcode;
+                    $scope.billable_amount = amt;
+                    $scope.fine_total = 
+                        ($scope.fine_total * 100 + amt * 100) / 100;
+                }
+            }
+
+            if (final_resp.evt.textcode == 'NO_CHANGE') {
+                $scope.alert = 
+                    {already_checked_in : final_resp.evt.copy_barcode};
+            }
+
+            if ($scope.trim_list && checkinSvc.checkins.length > 20)
+                checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+        },
+        function() {
+            // Checkin was rejected somewhere along the way.
+            // Remove the copy from the grid since there was no action.
+            // note: since checkins are unshifted onto the array, the
+            // index value does not (generally) match the array position.
+            var pos = -1;
+            angular.forEach(checkinSvc.checkins, function(ci, idx) {
+                if (ci.index == row_item.index) pos = idx;
+            });
+            checkinSvc.checkin.splice(pos, 1);
+
+        })['finally'](function() {
+
+            // when all is said and done, refresh the grid and refocus
+            checkinGrid.refresh();
+            $scope.focusMe = true;
+        });
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {checkins : []}
+
+        if (checkinSvc.checkins.length == 0) return $q.when();
+
+        angular.forEach(checkinSvc.checkins, function(checkin) {
+
+            var checkin = {
+                copy : egCore.idl.toHash(checkin.acp) || {},
+                call_number : egCore.idl.toHash(checkin.acn) || {},
+                copy_barcode : checkin.copy_barcode,
+                title : checkin.title,
+                author : checkin.author
+            }
+
+            print_data.checkins.push(checkin);
+        });
+
+        return egCore.print.print({
+            template : 'checkin', 
+            scope : print_data,
+            show_dialog : $scope.show_print_dialog
+        });
+    }
+
+
+    // --- context menu actions
+    //
+    $scope.fetchLastCircPatron = function(items) {
+        var checkin = items[0];
+        if (!checkin || !checkin.acp) return;
+
+        egCirc.last_copy_circ(checkin.acp.id())
+        .then(function(circ) {
+
+            if (circ) {
+                // jump to the patron UI (separate app)
+                $window.location.href = $location
+                    .path('/circ/patron/' + circ.usr() + '/checkout')
+                    .absUrl();
+                return;
+            }
+
+            $scope.alert = {item_never_circed : checkin.acp.barcode()};
+        });
+    }
+
+    $scope.showBackdateDialog = function(items) {
+        var circ_ids = [];
+
+        angular.forEach(items, function(item) {
+            if (item.circ) circ_ids.push(item.circ.id());
+        });
+
+        if (circ_ids.length) {
+            egCirc.backdate_dialog(circ_ids).then(function(result) {
+                angular.forEach(items, function(item) {
+                    item.circ.checkin_time(result.backdate);
+                })
+            });
+            // TODO: support grid row styling
+            checkinGrid.refresh();
+        }
+    }
+
+    $scope.showMarkDamaged = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+
+        if (copy_ids.length) {
+            egCirc.mark_damaged(copy_ids).then(function() {
+                // update grid items?
+            });
+        }
+    }
+
+    $scope.abortTransit = function(items) {
+        var transit_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.transit) transit_ids.push(item.transit.id());
+        });
+
+        egCirc.abort_transits(transit_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/holds/app.js b/Open-ILS/web/js/ui/default/staff/circ/holds/app.js
new file mode 100644
index 0000000..21e9ffc
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/holds/app.js
@@ -0,0 +1,315 @@
+angular.module('egHoldsApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/holds/shelf', {
+        templateUrl: './circ/holds/t_shelf',
+        controller: 'HoldsShelfCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/shelf/:hold_id', {
+        templateUrl: './circ/holds/t_shelf',
+        controller: 'HoldsShelfCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/pull', {
+        templateUrl: './circ/holds/t_pull',
+        controller: 'HoldsPullListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/pull/:hold_id', {
+        templateUrl: './circ/holds/t_pull',
+        controller: 'HoldsPullListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/holds/shelf'});
+})
+
+.factory('holdUiSvc', function() {
+    return {
+        holds : [] // cache
+    }
+})
+
+.controller('HoldsShelfCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egHoldGridActions','egCirc','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egHoldGridActions , egCirc , egGridDataProvider)  {
+    $scope.detail_hold_id = $routeParams.hold_id;
+
+    var hold_ids = [];
+    var holds = [];
+    var clear_mode = false;
+    $scope.gridControls = {};
+    $scope.grid_actions = egHoldGridActions;
+
+    function fetch_holds(offset, count) {
+        var ids = hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                holds.push(hold_data);
+                return hold_data; // to the grid
+            }
+        );
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function refresh_page() {
+        holds = [];
+        hold_ids = [];
+        provider.refresh();
+    }
+    // called after any egHoldGridActions action occurs
+    $scope.grid_actions.refresh = refresh_page;
+
+    provider.get = function(offset, count) {
+
+        // see if we have the requested range cached
+        if (holds[offset]) {
+            return provider.arrayNotifier(patronSvc.holds, offset, count);
+        }
+
+        // see if we have the holds IDs for this range already loaded
+        if (hold_ids[offset]) {
+            return fetch_holds(offset, count);
+        }
+
+        var deferred = $q.defer();
+        hold_ids = [];
+        holds = [];
+
+        var method = 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve.authoritative.atomic';
+        if (clear_mode) 
+            method = 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve.atomic';
+
+        egCore.net.request(
+            'open-ils.circ', method,
+            egCore.auth.token(), $scope.pickup_ou.id()
+
+        ).then(function(ids) {
+            if (!ids.length) { 
+                deferred.resolve(); 
+                return; 
+            }
+
+            hold_ids = ids;
+            fetch_holds(offset, count)
+            .then(deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+    // re-draw the grid when user changes the org selector
+    $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+    $scope.$watch('pickup_ou', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) 
+            refresh_page();
+    });
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/holds/shelf/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/holds/shelf');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+    // manage active vs. clearable holds display
+    var clearing = false; // true if actively clearing holds (below)
+    $scope.is_clearing = function() { return clearing };
+    $scope.active_mode = function() {return !clear_mode}
+    $scope.clear_mode = function() {return clear_mode}
+    $scope.show_clearable = function() { clear_mode = true; refresh_page() }
+    $scope.show_active = function() { clear_mode = false; refresh_page() }
+    $scope.disable_clear = function() { return clearing || !clear_mode }
+
+    // udpate the in-grid hold with the clear-shelf cached response info.
+    function handle_clear_cache_resp(resp) {
+        if (!angular.isArray(resp)) resp = [resp];
+        angular.forEach(resp, function(info) {
+            if (info.action) {
+                var grid_item = holds.filter(function(item) {
+                    return item.hold.id() == info.hold_details.id
+                })[0];
+
+                // there will be no grid item if the hold is off-page
+                if (grid_item) {
+                    grid_item.post_clear = 
+                        egCore.strings['CLEAR_SHELF_ACTION_' + info.action];
+                }
+            }
+        });
+    }
+
+    $scope.clear_holds = function() {
+        clearing = true;
+        $scope.clear_progress = {max : 0, value : 0};
+
+        // we want to see all processed holds, so (effectively) remove
+        // the grid limit.
+        $scope.gridControls.setLimit(1000); 
+
+        // initiate clear shelf and grab cache key
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.clear_shelf.process',
+            egCore.auth.token(), $scope.pickup_ou.id()
+
+        // request responses from the clear shelf cache
+        ).then(
+            
+            // clear shelf done; fetch the cached results.
+            function(resp) {
+                clearing = false;
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.hold.clear_shelf.get_cache',
+                    egCore.auth.token(), resp.cache_key
+                ).then(null, null, handle_clear_cache_resp);
+            }, 
+
+            null,
+
+            // handle streamed clear_shelf progress updates
+            function(resp) {
+                if (resp.maximum) 
+                    $scope.clear_progress.max = resp.maximum;
+                if (resp.progress)
+                    $scope.clear_progress.value = resp.progress;
+            }
+
+        );
+    }
+
+    refresh_page();
+
+}])
+
+.controller('HoldsPullListCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egCirc','egGridDataProvider','egHoldGridActions','holdUiSvc',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egCirc , egGridDataProvider , egHoldGridActions , holdUiSvc)  {
+    $scope.detail_hold_id = $routeParams.hold_id;
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    $scope.grid_actions = egHoldGridActions;
+    $scope.grid_actions.refresh = function() {
+        holdUiSvc.holds = [];
+        provider.refresh();
+    }
+
+    provider.get = function(offset, count) {
+
+        if (holdUiSvc.holds[offset]) {
+            return provider.arrayNotifier(holdUiSvc.holds, offset, count);
+        }
+
+        var deferred = $q.defer();
+        var recv_index = 0;
+
+        // fetch the IDs
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold_pull_list.fleshed.stream',
+            egCore.auth.token(), count, offset
+        ).then(
+            deferred.resolve, null, 
+            function(hold_data) {
+                egHolds.local_flesh(hold_data);
+                holdUiSvc.holds[offset + recv_index++] = hold_data;
+                deferred.notify(hold_data);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/holds/pull/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/holds/pull');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+    // By default, this action is hidded from the UI, but leaving it
+    // here in case it's needed in the future
+    $scope.print_list_alt = function() {
+        var url = '/opac/extras/circ/alt_holds_print.html';
+        var win = $window.open(url, '_blank');
+        win.ses = function() {return egCore.auth.token()};
+        win.open();
+        win.focus();
+    }
+
+    $scope.print_list_progress = null;
+    $scope.print_full_list = function() {
+        var print_holds = [];
+        $scope.print_list_loading = true;
+        $scope.print_list_progress = 0;
+
+        // collect the full list of holds
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold_pull_list.fleshed.stream',
+            egCore.auth.token(), 10000, 0
+        ).then(
+            function() {
+                console.debug('printing ' + print_holds.length + ' holds');
+
+                // holds fetched, send to print
+                egCore.print.print({
+                    context : 'default', 
+                    template : 'hold_pull_list', 
+                    scope : {holds : print_holds}
+                });
+            },
+            null, 
+            function(hold_data) {
+                $scope.print_list_progress++;
+                egHolds.local_flesh(hold_data);
+                print_holds.push(hold_data);
+                hold_data.title = hold_data.mvr.title();
+                hold_data.author = hold_data.mvr.author();
+                hold_data.hold = egCore.idl.toHash(hold_data.hold);
+                hold_data.copy = egCore.idl.toHash(hold_data.copy);
+                hold_data.volume = egCore.idl.toHash(hold_data.volume);
+                hold_data.part = egCore.idl.toHash(hold_data.part);
+            }
+        ).finally(function() {
+            $scope.print_list_loading = false;
+            $scope.print_list_progress = null;
+        });
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js b/Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js
new file mode 100644
index 0000000..06a1889
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js
@@ -0,0 +1,123 @@
+angular.module('egInHouseUseApp', 
+    ['ngRoute', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+
+})
+
+.controller('InHouseUseCtrl',
+       ['$scope','egCore','egGridDataProvider','egConfirmDialog',
+function($scope,  egCore,  egGridDataProvider , egConfirmDialog) {
+
+    var countCap;
+    var countMax;
+
+    egCore.startup.go().then(function() {
+
+        // grab our non-cat types after startup
+        egCore.pcrud.search('cnct', 
+            {owning_lib : 
+                egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+            null, {atomic : true}
+        ).then(function(list) { 
+            egCore.env.absorbList(list, 'cnct');
+            $scope.nonCatTypes = list 
+        });
+
+        // org settings for max and warning in-house-use counts
+        
+        egCore.org.settings([
+            'ui.circ.in_house_use.entry_cap',
+            'ui.circ.in_house_use.entry_warn'
+        ]).then(function(set) {
+            countWarn = set['ui.circ.in_house_use.entry_warn'] || 20;
+            $scope.countMax = countMax = 
+                set['ui.circ.in_house_use.entry_cap'] || 99;
+        });
+    });
+
+    $scope.useFocus = true;
+    $scope.args = {noncat_type : 'barcode', num_uses : 1};
+    var checkouts = [];
+
+    var provider = egGridDataProvider.instance({});
+    provider.get = function(offset, count) {
+        return provider.arrayNotifier(checkouts, offset, count);
+    }
+    $scope.gridDataProvider = provider;
+
+    // currently selected non-cat type
+    $scope.selectedNcType = function() {
+        if (!egCore.env.cnct) return null; // too soon
+        var type = egCore.env.cnct.map[$scope.args.noncat_type];
+        return type ? type.name() : null;
+    }
+
+    $scope.checkout = function(args) {
+        $scope.copyNotFound = false;
+
+        var coArgs = {
+            count : args.num_uses,
+            'location' : egCore.auth.user().ws_ou()
+        };
+
+        if (args.noncat_type == 'barcode') {
+
+            egCore.pcrud.search('acp',
+                {barcode : args.barcode, deleted : 'f'},
+                {   flesh : 3, 
+                    flesh_fields : {
+                        acp : ['call_number','location'],
+                        acn : ['record'],
+                        bre : ['simple_record']
+                    },
+                    select : { bre : ['id'] } // avoid fleshing MARC
+                }
+            ).then(function(copy) {
+
+                if (!copy) {
+                    $scope.copyNotFound = true;
+                    return;
+                }
+
+                coArgs.copyid = copy.id();
+
+                performCheckout(
+                    'open-ils.circ.in_house_use.create',
+                    coArgs, {copy:copy}
+                );
+            });
+
+        } else {
+            coArgs.non_cat_type = args.noncat_type;
+            performCheckout(
+                'open-ils.circ.non_cat_in_house_use.create',
+                coArgs, {title : $scope.selectedNcType()}
+            );
+        }
+    }
+
+    function performCheckout(method, args, data) {
+
+        // FIXME: make this API stream
+        egCore.net.request(
+            'open-ils.circ', method, egCore.auth.token(), args
+
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+
+            var item = {num_uses : resp.length};
+            item.copy = data.copy;
+            item.title = data.title || 
+                data.copy.call_number().record().simple_record().title();
+            item.index = checkouts.length;
+
+            checkouts.unshift(item);
+            provider.refresh();
+        });
+    }
+
+}])
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
new file mode 100644
index 0000000..c607e29
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js
@@ -0,0 +1,1494 @@
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    // data loaded at startup which only requires an authtoken goes
+    // here. this allows the requests to be run in parallel instead of
+    // waiting until startup has completed.
+    var resolver = {delay : ['egCore','egUser', function(egCore , egUser) {
+
+        // fetch the org settings we care about during egStartup
+        // and toss them into egCore.env as egCore.env.aous[name] = value.
+        // note: only load settings here needed by all tabs; load tab-
+        // specific settings from within their respective controllers
+        egCore.env.classLoaders.aous = function() {
+            return egCore.org.settings([
+                'circ.obscure_dob',
+                'ui.circ.show_billing_tab_on_bills',
+                'circ.patron_expires_soon_warning',
+                'ui.circ.items_out.lost',
+                'ui.circ.items_out.longoverdue',
+                'ui.circ.items_out.claimsreturned'
+            ]).then(function(settings) { 
+                // local settings are cached within egOrg.  Caching them
+                // again in egEnv just simplifies the syntax for access.
+                egCore.env.aous = settings;
+            });
+        }
+
+        // local stat cats are displayed in the summary bar on each page.
+        egCore.env.classLoaders.actsc = function() {
+            return egCore.pcrud.search('actsc', 
+                {owner : egCore.org.ancestors(
+                    egCore.auth.user().ws_ou(), true)},
+                {}, {atomic : true}
+            ).then(function(cats) {
+                egCore.env.absorbList(cats, 'actsc');
+            });
+        }
+
+        egCore.env.loadClasses.push('aous');
+        egCore.env.loadClasses.push('actsc');
+
+        // app-globally modify the default flesh fields for 
+        // fleshed user retrieval.
+        if (egUser.defaultFleshFields.indexOf('profile') == -1) {
+            egUser.defaultFleshFields = egUser.defaultFleshFields.concat([
+                'profile',
+                'net_access_level',
+                'ident_type',
+                'ident_type2',
+                'cards'
+            ]);
+        }
+
+        return egCore.startup.go()
+    }]};
+
+    $routeProvider.when('/circ/patron/search', {
+        templateUrl: './circ/patron/t_search',
+        controller: 'PatronSearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/bcsearch', {
+        templateUrl: './circ/patron/t_bcsearch',
+        controller: 'PatronBarcodeSearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/credentials', {
+        templateUrl: './circ/patron/t_credentials',
+        controller: 'PatronVerifyCredentialsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/last', {
+        templateUrl: './circ/patron/t_last_patron',
+        controller: 'PatronFetchLastCtrl',
+        resolve : resolver
+    });
+
+    // the following require a patron ID
+
+    $routeProvider.when('/circ/patron/:id/alerts', {
+        templateUrl: './circ/patron/t_alerts',
+        controller: 'PatronAlertsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/checkout', {
+        templateUrl: './circ/patron/t_checkout',
+        controller: 'PatronCheckoutCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/items_out', {
+        templateUrl: './circ/patron/t_items_out',
+        controller: 'PatronItemsOutCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds', {
+        templateUrl: './circ/patron/t_holds',
+        controller: 'PatronHoldsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds/create', {
+        templateUrl: './circ/patron/t_holds_create',
+        controller: 'PatronHoldsCreateCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds/:hold_id', {
+        templateUrl: './circ/patron/t_holds',
+        controller: 'PatronHoldsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/hold/:hold_id', {
+        templateUrl: './circ/patron/t_hold_details',
+        controller: 'PatronHoldDetailsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bills', {
+        templateUrl: './circ/patron/t_bills',
+        controller: 'PatronBillsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bill/:xact_id', {
+        templateUrl: './circ/patron/t_xact_details',
+        controller: 'XactDetailsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bill_history/:history_tab', {
+        templateUrl: './circ/patron/t_bill_history',
+        controller: 'BillHistoryCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/messages', {
+        templateUrl: './circ/patron/t_messages',
+        controller: 'PatronMessagesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/edit', {
+        templateUrl: './circ/patron/t_edit',
+        controller: 'PatronEditCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/credentials', {
+        templateUrl: './circ/patron/t_credentials',
+        controller: 'PatronVerifyCredentialsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/notes', {
+        templateUrl: './circ/patron/t_notes',
+        controller: 'PatronNotesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/triggered_events', {
+        templateUrl: './circ/patron/t_triggered_events',
+        controller: 'PatronTriggeredEventsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/edit_perms', {
+        templateUrl: './circ/patron/t_edit_perms',
+        controller: 'PatronPermsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/group', {
+        templateUrl: './circ/patron/t_group',
+        controller: 'PatronGroupCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/stat_cats', {
+        templateUrl: './circ/patron/t_stat_cats',
+        controller: 'PatronStatCatsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
+})
+
+/**
+ * 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 : {},
+    };
+
+    // 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.hasAlerts = false;
+        service.alertsShown = false;
+        service.patronExpired = false;
+        service.patronExpiresSoon = false;
+        service.retrievedWithInactive = false;
+    }
+    service.resetPatronLists();  // initialize
+
+    // shortcut to force-reload the current primary
+    service.refreshPrimary = function() {
+        if (!service.current) return $q.when();
+        return service.setPrimary(service.current.id(), null, true);
+    }
+
+    // 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.setLocalItem('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() {
+            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 {
+
+            // reset with no patron
+            service.resetPatronLists();
+            service.current = null;
+            service.patron_stats = null;
+            return $q.when();
+        }
+    }
+
+    // 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 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 (bc = $location.search().card) {
+            var card = p.cards().filter(
+                function(c) { return c.barcode() == bc })[0];
+
+            if (card && card.active() == 'f') {
+                service.hasAlerts = true;
+                service.retrievedWithInactive = 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);
+        });
+
+        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;
+                return stats;
+            }
+        );
+    }
+
+
+    // 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);
+                    }
+                }
+            );
+
+            return service.fetchGroupFines();
+        });
+    }
+
+    // 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.
+ *
+ * */
+.controller('PatronCtrl',
+       ['$scope','$q','$location','$filter','egCore','egUser','patronSvc',
+function($scope,  $q,  $location , $filter,  egCore,  egUser,  patronSvc) {
+
+    // returns true if a redirect occurs
+    function redirectToAlertPanel() {
+
+        $scope.alert_penalties = 
+            function() {return patronSvc.alert_penalties}
+
+        if (patronSvc.alertsShown) return false;
+        patronSvc.alertsShown = true;
+
+        // if the patron has any unshown alerts, show them now
+        if (patronSvc.hasAlerts && 
+            !$location.path().match(/alerts$/)) {
+
+            $location
+                .path('/circ/patron/' + patronSvc.current.id() + '/alerts')
+                .search('card', null);
+            return true;
+        }
+
+        // no alert required.  If the patron has fines and the show-bills
+        // OUS is applied, direct to the bills page.
+        if ($scope.patron_stats().fines.balance_owed > 0 // TODO: != 0 ?
+            && egCore.env.aous['ui.circ.show_billing_tab_on_bills']
+            && !$location.path().match(/bills$/)) {
+
+            $location
+                .path('/circ/patron/' + patronSvc.current.id() + '/bills')
+                .search('card', null);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    // called after each route-specified controller is instantiated.
+    // this doubles as a way to inform the top-level controller that
+    // egStartup.go() has completed, which means we are clear to 
+    // fetch the patron, etc.
+    $scope.initTab = function(tab, patron_id) {
+        console.log('init tab ' + tab);
+        $scope.tab = tab;
+        $scope.aous = egCore.env.aous;
+
+        if (patron_id) {
+            $scope.patron_id = patron_id;
+            return patronSvc.setPrimary($scope.patron_id)
+            .then(function() {return patronSvc.checkAlerts()})
+            .then(redirectToAlertPanel);
+        }
+        return $q.when();
+    }
+
+    $scope.patron = function() { return patronSvc.current }
+    $scope.patron_stats = function() { return patronSvc.patron_stats }
+    $scope.summary_stat_cats = function() { return patronSvc.summary_stat_cats }
+
+    $scope.print_address = function(addr) {
+        egCore.print.print({
+            context : 'default', 
+            template : 'patron_address', 
+            scope : {
+                patron : egCore.idl.toHash(patronSvc.current),
+                address : egCore.idl.toHash(addr)
+            }
+        });
+    }
+
+    $scope.toggle_expand_summary = function() {
+        if ($scope.collapsePatronSummary) {
+            $scope.collapsePatronSummary = false;
+            egCore.hatch.removeItem('eg.circ.patron.summary.collapse');
+        } else {
+            $scope.collapsePatronSummary = true;
+            egCore.hatch.setItem('eg.circ.patron.summary.collapse', true);
+        }
+    }
+    
+    // always expand the patron summary in the search UI, regardless
+    // of stored preference.
+    $scope.collapse_summary = function() {
+        return $scope.tab != 'search' && $scope.collapsePatronSummary;
+    }
+
+    egCore.hatch.getItem('eg.circ.patron.summary.collapse')
+    .then(function(val) {$scope.collapsePatronSummary = Boolean(val)});
+}])
+
+.controller('PatronBarcodeSearchCtrl',
+       ['$scope','$location','egCore','egConfirmDialog','egUser','patronSvc',
+function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
+    $scope.selectMe = true; // focus text input
+    patronSvc.setPrimary(); // clear the default user
+
+    // jump to the patron checkout UI
+    function loadPatron(user_id) {
+        $location
+        .path('/circ/patron/' + user_id + '/checkout')
+        .search('card', $scope.args.barcode);
+    }
+
+    // create an opt-in=yes response for the loaded user
+    function createOptIn(user_id) {
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.org_unit_opt_in.create',
+            egCore.auth.token(), user_id).then(function(resp) {
+                if (evt = egCore.evt.parse(resp)) return alert(evt);
+                loadPatron(user_id);
+            }
+        );
+    }
+
+    $scope.submitBarcode = function(args) {
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        // blur so next time it's set to true it will re-apply select()
+        $scope.selectMe = false;
+
+        var user_id;
+
+        // lookup barcode
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'actor', args.barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt); // FIXME
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = args.barcode;
+                $scope.selectMe = true;
+                return;
+            }
+
+            // see if an opt-in request is needed
+            user_id = resp[0].id;
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.user.org_unit_opt_in.check',
+                egCore.auth.token(), user_id);
+
+        }).then(function(optInResp) { // opt_in_check
+
+            if (evt = egCore.evt.parse(optInResp)) {
+                alert(evt); // FIXME
+                return;
+            }
+           
+            if (optInResp == 1) {
+                // opt-in handled or not needed
+                return loadPatron(user_id);
+            }
+
+            // opt-in needed, show the opt-in dialog
+            egUser.get(user_id, {useFields : []})
+
+            .then(function(user) { // retrieve user
+                egConfirmDialog.open(
+                    egCore.strings.OPT_IN_DIALOG, '',
+                    {   org : egCore.org.get(user.home_ou()),
+                        user : user,
+                        ok : function() { createOptIn(user.id()) },
+                        cancel : function() {}
+                    }
+                );
+            })
+        });
+    }
+}])
+
+
+/**
+ * Manages patron search
+ */
+.controller('PatronSearchCtrl',
+       ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
+       '$filter','egUser', 'patronSvc','egGridDataProvider',
+function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
+        $filter,  egUser,  patronSvc , egGridDataProvider) {
+
+    $scope.initTab('search');
+    $scope.focusMe = true;
+    $scope.searchArgs = {
+        // default to searching globally
+        home_ou : egCore.org.tree()
+    };
+
+    $scope.gridControls = {
+        activateItem : function(item) {
+            $location.path('/circ/patron/' + item.id() + '/checkout');
+        },
+        selectedItems : function() {return []}
+    }
+
+    // Handle URL-encoded searches
+    if ($location.search().search) {
+        patronSvc.urlSearch = {search : JSON2js($location.search().search)};
+
+        // why the double-JSON encoded sort?
+        patronSvc.urlSearch.sort = 
+            JSON2js(patronSvc.urlSearch.search.search_sort);
+        delete patronSvc.urlSearch.search.search_sort;
+    }
+
+    var propagate;
+    if (patronSvc.lastSearch) {
+        propagate = patronSvc.lastSearch.search;
+    } else if (patronSvc.urlSearch) {
+        propagate = patronSvc.urlSearch.search;
+    }
+
+    if (propagate) {
+        // populate the search form with our cached / preexisting search info
+        angular.forEach(propagate, function(val, key) {
+            $scope.searchArgs[key] = val.value;
+        });
+    }
+
+    var provider = egGridDataProvider.instance({});
+
+    $scope.$watch(
+        function() {return $scope.gridControls.selectedItems()},
+        function(list) {
+            if (list[0]) 
+                patronSvc.setPrimary(null, list[0]);
+        },
+        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 {
+
+            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;
+        }
+
+        patronSvc.patrons = [];
+        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() },
+            null, // onerror
+            function(user) {
+                patronSvc.localFlesh(user); // inline
+                patronSvc.patrons.push(user);
+                deferred.notify(user);
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    $scope.patronSearchGridProvider = provider;
+
+    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;
+            }
+        );
+    }
+
+    // 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.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');
+        }
+        $event.preventDefault();
+    }
+
+    egCore.hatch.getItem('eg.prefs.circ.patron.search.showExtras')
+    .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;
+    }
+
+    // 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();
+    }
+
+    // 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();
+    }
+   
+}])
+
+/**
+ * Manages messages
+ */
+.controller('PatronMessagesCtrl',
+       ['$scope','$q','$routeParams','egCore','$modal','patronSvc','egCirc',
+function($scope , $q , $routeParams,  egCore , $modal , patronSvc , egCirc) {
+    $scope.initTab('messages', $routeParams.id);
+    var usr_id = $routeParams.id;
+
+    // setup date filters
+    var start = new Date(); // now - 1 year
+    start.setFullYear(start.getFullYear() - 1),
+    $scope.dates = {
+        start_date : start,
+        end_date : new Date()
+    }
+
+    function date_range() {
+        var start = $scope.dates.start_date.toISOString().replace(/T.*/,'');
+        var end = $scope.dates.end_date.toISOString().replace(/T.*/,'');
+        var today = new Date().toISOString().replace(/T.*/,'');
+        if (end == today) end = 'now';
+        return [start, end];
+    }
+
+    // grid queries
+   
+    var activeGrid = $scope.activeGridControls = {
+        setSort : function() {
+            return ['set_date'];
+        },
+        setQuery : function() {
+            return {
+                usr : usr_id,
+                '-or' : [
+                    {stop_date : null},
+                    {stop_date : {'>' : 'now'}}
+                ]
+            }
+        }
+    }
+
+    var archiveGrid = $scope.archiveGridControls = {
+        setSort : function() {
+            return ['set_date'];
+        },
+        setQuery : function() {
+            return {
+                usr : usr_id, 
+                stop_date : {'<=' : 'now'},
+                set_date : {between : date_range()}
+            };
+        }
+    };
+
+    $scope.removePenalty = function(selected) {
+        // the grid stores flattened penalties.  Fetch penalty objects first
+
+        var ids = selected.map(function(s){ return s.id });
+        egCore.pcrud.search('ausp', 
+            {id : ids}, {}, 
+            {atomic : true, authoritative : true}
+
+        // then delete them
+        ).then(function(penalties) {
+            return egCore.pcrud.remove(penalties);
+
+        // then refresh the grid
+        }).then(function() {
+            activeGrid.refresh();
+        });
+    }
+
+    $scope.archivePenalty = function(selected) {
+        // the grid stores flattened penalties.  Fetch penalty objects first
+
+        var ids = selected.map(function(s){ return s.id });
+        egCore.pcrud.search('ausp', 
+            {id : ids}, {}, 
+            {atomic : true, authoritative : true}
+
+        // then delete them
+        ).then(function(penalties) {
+            angular.forEach(penalties, function(p){ p.stop_date('now') });
+            return egCore.pcrud.update(penalties);
+
+        // then refresh the grid
+        }).then(function() {
+            activeGrid.refresh();
+            archiveGrid.refresh();
+        });
+    }
+
+    // leverage egEnv for caching
+    function fetchPenaltyTypes() {
+        if (egCore.env.csp) 
+            return $q.when(egCore.env.csp.list);
+        return egCore.pcrud.search(
+            // id <= 100 are reserved for system use
+            'csp', {id : {'>': 100}}, {}, {atomic : true})
+        .then(function(penalties) {
+            egCore.env.absorbList(penalties, 'csp');
+            return penalties;
+        });
+    }
+
+    $scope.createPenalty = function() {
+        egCirc.create_penalty(usr_id).then(function() {
+            activeGrid.refresh();
+            // force a refresh of the user, since they may now
+            // have blocking penalties, etc.
+            patronSvc.setPrimary(patronSvc.current.id(), null, true);
+        });
+    }
+
+    $scope.editPenalty = function(selected) {
+        if (selected.length == 0) return;
+
+        // grab the penalty from the user object
+        var penalty = patronSvc.current.standing_penalties().filter(
+            function(p) {return p.id() == selected[0].id})[0];
+
+        egCirc.edit_penalty(penalty).then(function() {
+            activeGrid.refresh();
+            // force a refresh of the user, since they may now
+            // have blocking penalties, etc.
+            patronSvc.setPrimary(patronSvc.current.id(), null, true);
+        });
+    }
+}])
+
+
+/**
+ * Link to patron edit UI
+ */
+.controller('PatronEditCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams,  $location , egCore , patronSvc) {
+    $scope.initTab('edit', $routeParams.id);
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+    url += '?usr=' + encodeURIComponent($routeParams.id);
+
+    $scope.funcs = {
+        on_save : function() {
+            patronSvc.refreshPrimary();
+        }
+    }
+
+    $scope.patron_edit_url = url;
+}])
+
+/**
+ * Credentials tester
+ */
+.controller('PatronVerifyCredentialsCtrl',
+       ['$scope','$routeParams','$location','egCore',
+function($scope,  $routeParams , $location , egCore) {
+    $scope.verified = null;
+    $scope.focusMe = true;
+
+    // called with a patron, pre-populate the form args
+    $scope.initTab('other', $routeParams.id).then(
+        function() {
+            if ($scope.patron()) {
+                $scope.prepop = true;
+                $scope.username = $scope.patron().usrname();
+                $scope.barcode = $scope.patron().card().barcode();
+            }
+        }
+    );
+
+    // verify login credentials
+    $scope.verify = function() {
+        $scope.verified = null;
+        $scope.notFound = false;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.verify_user_password',
+            egCore.auth.token(), $scope.barcode,
+            $scope.username, hex_md5($scope.password || '')
+
+        ).then(function(resp) {
+            $scope.focusMe = true;
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt);
+            } else if (resp == 1) {
+                $scope.verified = true;
+            } else {
+                $scope.verified = false;
+            }
+        });
+    }
+
+    // load the main patron UI for the provided username or barcode
+    $scope.load = function($event) {
+        $scope.notFound = false;
+        $scope.verified = null;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+            egCore.auth.token(), $scope.barcode, $scope.username
+
+        ).then(function(resp) {
+
+            if (Number(resp)) {
+                $location.path('/circ/patron/' + resp + '/checkout');
+                return;
+            }
+
+            // something went wrong...
+            $scope.focusMe = true;
+            if (evt = egCore.evt.parse(resp)) {
+                if (evt.textcode == 'ACTOR_USR_NOT_FOUND') {
+                    $scope.notFound = true;
+                    return;
+                }
+                return alert(evt);
+            } else {
+                alert(resp);
+            }
+        });
+
+        // load() button sits within the verify form.  
+        // avoid submitting the verify() form action on load()
+        $event.preventDefault();
+    }
+}])
+
+.controller('PatronAlertsCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams , $location , egCore , patronSvc) {
+
+    $scope.initTab('other', $routeParams.id)
+    .then(function() {
+        $scope.patronExpired = patronSvc.patronExpired;
+        $scope.patronExpiresSoon = patronSvc.patronExpiresSoon;
+        $scope.retrievedWithInactive = patronSvc.retrievedWithInactive;
+    });
+
+}])
+
+.controller('PatronNotesCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc','$modal',
+function($scope,  $routeParams , $location , egCore , patronSvc , $modal) {
+    $scope.initTab('other', $routeParams.id);
+    var usr_id = $routeParams.id;
+
+    // fetch the notes
+    function refreshPage() {
+        $scope.notes = [];
+        egCore.pcrud.search('aun', 
+            {usr : usr_id}, 
+            {flesh : 1, flesh_fields : {aun : ['creator']}}, 
+            {authoritative : true})
+        .then(null, null, function(note) {
+            $scope.notes.push(note);
+        });
+    }
+
+    // open the new-note dialog and create the note
+    $scope.newNote = function() {
+        $modal.open({
+            templateUrl: './circ/patron/t_new_note_dialog',
+            controller: 
+                ['$scope', '$modalInstance',
+            function($scope, $modalInstance) {
+                $scope.focusNote = true;
+                $scope.args = {};
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }],
+        }).result.then(
+            function(args) {
+                if (!args.value) return;
+                var note = new egCore.idl.aun();
+                note.usr(usr_id);
+                note.title(args.title);
+                note.value(args.value);
+                note.pub(args.pub ? 't' : 'f');
+                note.creator(egCore.auth.user().id());
+                egCore.pcrud.create(note).then(function() {refreshPage()});
+            }
+        );
+    }
+
+    // delete the selected note
+    $scope.deleteNote = function(note) {
+        egCore.pcrud.remove(note).then(function() {refreshPage()});
+    }
+
+    // print the selected note
+    $scope.printNote = function(note) {
+        var hash = egCore.idl.toHash(note);
+        hash.usr = egCore.idl.toHash($scope.patron());
+        egCore.print.print({
+            context : 'default', 
+            template : 'patron_note', 
+            scope : {note : hash}
+        });
+    }
+
+    // perform the initial note fetch
+    refreshPage();
+}])
+
+.controller('PatronGroupCtrl',
+       ['$scope','$routeParams','$q','$window','$location','egCore',
+        'patronSvc','$modal','egPromptDialog','egConfirmDialog',
+function($scope,  $routeParams , $q , $window , $location , egCore ,
+         patronSvc , $modal , egPromptDialog , egConfirmDialog) {
+
+    var usr_id = $routeParams.id;
+
+    $scope.totals = {owed : 0, total_out : 0, overdue : 0}
+
+    var grid = $scope.gridControls = {
+        activateItem : function(item) {
+            $location.path('/circ/patron/' + item.id + '/checkout');
+        },
+        itemRetrieved : function(item) {
+
+            if (item.id == patronSvc.current.id()) {
+                item.stats = patronSvc.patron_stats;
+
+            } else {
+                // flesh stats for other group members
+                patronSvc.getUserStats(item.id).then(function(stats) {
+                    item.stats = stats;
+                    $scope.totals.total_out += stats.checkouts.total_out; 
+                    $scope.totals.overdue += stats.checkouts.overdue; 
+                });
+            }
+        },
+        setSort : function() {
+            return ['create_date'];
+        }
+    }
+
+    $scope.initTab('other', $routeParams.id)
+    .then(function(redirect) {
+        // if we are redirecting to the alerts page, avoid updating the
+        // grid query.
+        if (redirect) return;
+        // let initTab() fetch the user first so we can know the usrgroup
+
+        grid.setQuery({
+            usrgroup : patronSvc.current.usrgroup(),
+            deleted : 'f'
+        });
+        $scope.totals.owed = patronSvc.patron_stats.fines.group_balance_owed;
+    });
+
+    $scope.removeFromGroup = function(selected) {
+        var promises = [];
+        angular.forEach(selected, function(user) {
+            console.debug('removing user ' + user.id + ' from group');
+
+            promises.push(
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.usergroup.new',
+                    egCore.auth.token(), user.id, true
+                )
+            );
+        });
+
+        $q.all(promises).then(function() {grid.refresh()});
+    }
+
+    function addUserToGroup(user) {
+        user.usrgroup(patronSvc.current.usrgroup());
+        user.ischanged(true);
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.update',
+            egCore.auth.token(), user
+
+        ).then(function() {grid.refresh()});
+    }
+
+    // fetch each user ("selected" has flattened users)
+    // update the usrgroup, then update the user object
+    // After all updates are complete, refresh the grid.
+    function moveUsersToGroup(target_user, selected) {
+        var promises = [];
+
+        angular.forEach(selected, function(user) {
+            promises.push(
+                egCore.pcrud.retrieve('au', user.id)
+                .then(function(u) {
+                    u.usrgroup(target_user.usrgroup());
+                    u.ischanged(true);
+                    return egCore.net.request(
+                        'open-ils.actor',
+                        'open-ils.actor.patron.update',
+                        egCore.auth.token(), u
+                    );
+                })
+            );
+        });
+
+        $q.all(promises).then(function() {grid.refresh()});
+    }
+
+    function showMoveToGroupConfirm(barcode, selected) {
+
+        // find the user
+        egCore.pcrud.search('ac', {barcode : barcode})
+
+        // fetch the fleshed user
+        .then(function(card) {
+
+            if (!card) return; // TODO: warn user
+
+            egCore.pcrud.retrieve('au', card.usr())
+            .then(function(user) {
+                user.card(card);
+                $modal.open({
+                    templateUrl: './circ/patron/t_move_to_group_dialog',
+                    controller: [
+                                '$scope','$modalInstance',
+                        function($scope , $modalInstance) {
+                            $scope.user = user;
+                            $scope.outbound = Boolean(selected);
+                            $scope.ok = 
+                                function(count) { $modalInstance.close() }
+                            $scope.cancel = 
+                                function () { $modalInstance.dismiss() }
+                        }
+                    ]
+                }).result.then(function() {
+                    if (selected) {
+                        moveUsersToGroup(user, selected);
+                    } else {
+                        addUserToGroup(user);
+                    }
+                });
+            });
+        });
+    }
+
+    // selected == move selected patrons to another patron's group
+    // !selected == patron from a different group moves into our group
+    function moveToGroup(selected) {
+        egPromptDialog.open(
+            egCore.strings.GROUP_ADD_USER, '',
+            {ok : function(value) {
+                if (value) 
+                    showMoveToGroupConfirm(value, selected);
+            }}
+        );
+    }
+
+    $scope.moveToGroup = function() { moveToGroup() };
+    $scope.moveToAnotherGroup = function(selected) { moveToGroup(selected) };
+
+    $scope.cloneUser = function(selected) {
+        if (!selected.length) return;
+        var url = $location.absUrl().replace(
+            /\/patron\/.*/, 
+            '/patron/register/clone/' + selected[0].id);
+        $window.open(url, '_blank').focus();
+    }
+
+    $scope.retrieveSelected = function(selected) {
+        if (!selected.length) return;
+        var url = $location.absUrl().replace(
+            /\/patron\/.*/, 
+            '/patron/' + selected[0].id + '/checkout');
+        $window.open(url, '_blank').focus();
+    }
+
+}])
+
+.controller('PatronStatCatsCtrl',
+       ['$scope','$routeParams','$q','egCore','patronSvc',
+function($scope,  $routeParams , $q , egCore , patronSvc) {
+    $scope.initTab('other', $routeParams.id)
+    .then(function(redirect) {
+        // Entries for org-visible stat cats are fleshed.  Any others
+        // have to be fleshed within.
+
+        var to_flesh = {};
+        angular.forEach(patronSvc.current.stat_cat_entries(), 
+            function(entry) {
+                if (!angular.isObject(entry.stat_cat())) {
+                    to_flesh[entry.stat_cat()] = entry;
+                }
+            }
+        );
+
+        if (!Object.keys(to_flesh).length) return;
+
+        egCore.pcrud.search('actsc', {id : Object.keys(to_flesh)})
+        .then(null, null, function(cat) { // stream
+            cat.owner(egCore.org.get(cat.owner())); // owner flesh
+            to_flesh[cat.id()].stat_cat(cat);
+        });
+    });
+}])
+
+.controller('PatronFetchLastCtrl',
+       ['$scope','$location','egCore',
+function($scope , $location , egCore) {
+
+    var id = egCore.hatch.getLocalItem('eg.circ.last_patron');
+    if (id) return $location.path('/circ/patron/' + id + '/checkout');
+
+    $scope.no_last = true;
+}])
+
+.controller('PatronTriggeredEventsCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams,  $location , egCore , patronSvc) {
+    $scope.initTab('other', $routeParams.id);
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+    url += '?patron_id=' + encodeURIComponent($routeParams.id);
+
+    $scope.triggered_events_url = url;
+    $scope.funcs = {};
+}])
+
+.controller('PatronPermsCtrl',
+       ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+    $scope.initTab('other', $routeParams.id);
+
+    var url = $location.absUrl().replace(
+        /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+    url += '?usr=' + encodeURIComponent($routeParams.id);
+
+    // user_edit does not load the session via cookie.  It uses URL 
+    // params or xulG instead.  Pass via xulG.
+    $scope.funcs = {
+        ses : egCore.auth.token(),
+        on_patron_save : function() {
+            $scope.funcs.reload();
+        }
+    }
+
+    $scope.user_perms_url = url;
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js b/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
new file mode 100644
index 0000000..5b83989
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
@@ -0,0 +1,731 @@
+
+/* Billing Service */
+
+angular.module('egPatronApp')
+
+.factory('billSvc', 
+       ['$q','egCore','patronSvc',
+function($q , egCore , patronSvc) {
+
+    var service = {};
+
+    // fetch org unit settings specific to the bills display
+    service.fetchBillSettings = function() {
+        if (service.settings) return $q.when(service.settings);
+        return egCore.org.settings(
+            ['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']
+        ).then(function(s) {return service.settings = s});
+    }
+
+    // user billing summary
+    service.fetchSummary = function() {
+        return egCore.pcrud.retrieve(
+            'mous', service.userId, {}, {authoritative : true})
+        .then(function(summary) {return service.summary = summary})
+    }
+
+    service.applyPayment = function(type, payments, note) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.payment',
+            egCore.auth.token(), {
+                userid : service.userId,
+                note : note || '', 
+                payment_type : type,
+                payments : payments,
+                patron_credit : 0
+            },
+            patronSvc.current.last_xact_id()
+        ).then(function(resp) {
+            console.debug('payments: ' + js2JSON(resp));
+            if (evt = egCore.evt.parse(resp)) 
+                return alert(evt);
+
+            // payment API returns the update xact id so we can track it
+            // for future payments without having to refresh the user.
+            patronSvc.current.last_xact_id(resp.last_xact_id);
+            return resp.payments;
+        });
+    }
+
+    service.fetchBills = function(xact_id) {
+        var bills = [];
+        return egCore.pcrud.search('mb',
+            {xact : xact_id}, null,
+            {authoritative : true}
+        ).then(
+            function() {return bills},
+            null,
+            function(bill) {bills.push(bill); return bill}
+        );
+    }
+
+    // TODO: no longer needed?
+    service.fetchPayments = function(xact_id) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.payment.retrieve.all.authoritative',
+            egCore.auth.token(), xact_id
+        );
+    }
+
+    service.voidBills = function(bill_ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.billing.void',
+            [egCore.auth.token()].concat(bill_ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    service.updateBillNotes = function(note, ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.billing.note.edit',
+            [egCore.auth.token(), note].concat(ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    service.updatePaymentNotes = function(note, ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.payment.note.edit',
+            [egCore.auth.token(), note].concat(ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    return service;
+}])
+
+
+/**
+ * Manages Bills
+ */
+.controller('PatronBillsCtrl',
+       ['$scope','$q','$routeParams','egCore','egConfirmDialog','$location',
+        'egGridDataProvider','billSvc','patronSvc','egPromptDialog','$modal',
+        'egBilling',
+function($scope , $q , $routeParams , egCore , egConfirmDialog , $location,
+         egGridDataProvider , billSvc , patronSvc , egPromptDialog , $modal,
+         egBilling) {
+
+    $scope.initTab('bills', $routeParams.id);
+    billSvc.userId = $routeParams.id;
+
+    // set up some defaults
+    $scope.payment_amount = 0;
+    $scope.session_voided = 0;
+    $scope.payment_type = 'cash_payment';
+    $scope.focus_payment = true;
+    $scope.annotate_payment = false;
+    $scope.receipt_count = 1;
+    $scope.receipt_on_pay = false;
+
+    // pre-define list-returning funcs in case we access them
+    // before the grid instantiates
+    $scope.gridControls = {
+        focusRowSelector : false,
+        selectedItems : function(){return []},
+        allItems : function(){return []},
+        itemRetrieved : function(item) {
+            item.payment_pending = 0;
+        },
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setQuery : function() {    
+            return {
+                usr : billSvc.userId, 
+                xact_finish : null,
+                'summary.balance_owed' : {'<>' : 0}
+            }
+        }, 
+        setSort : function() {
+            return ['xact_start']; 
+        }
+    }
+
+    billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+
+    // given a payment amount, determines how much of that is applied
+    // to selected transactions and how much is left over (change).
+    function pending_payment_info() {
+        var amt = $scope.payment_amount || 0;
+        if (amt >= $scope.owed_selected()) {
+            return {
+                payment : $scope.owed_selected(),
+                change : amt - $scope.owed_selected()
+            }
+        } 
+        return {payment : amt, change : 0};
+    }
+
+    // calculates amount owed, billed, and paid for selected items
+    // TODO: move me to service
+    function selected_payment_info() {
+        var info = {owed : 0, billed : 0, paid : 0};
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            info.owed   += Number(item['summary.balance_owed']) * 100;
+            info.billed += Number(item['summary.total_owed']) * 100;
+            info.paid   += Number(item['summary.total_paid']) * 100;
+        });
+        info.owed /= 100;
+        info.billed /= 100;
+        info.paid /= 100;
+        return info;
+    }
+
+    $scope.pending_payment = function() {
+        return pending_payment_info().payment;
+    }
+    $scope.pending_change = function() {
+        return pending_payment_info().change;
+    }
+    $scope.owed_selected = function() {
+        return selected_payment_info().owed; 
+    }
+    $scope.billed_selected = function() {
+        return selected_payment_info().billed;
+    }
+    $scope.paid_selected = function() {
+        return selected_payment_info().paid;
+    }
+    $scope.refunds_available = function() {
+        var amount = 0;
+        angular.forEach($scope.gridControls.allItems(), function(item) {
+            if (item['summary.balance_owed'] < 0) 
+                amount += item['summary.balance_owed'] * 100;
+        });
+        return -(amount / 100);
+    }
+
+    // update the item.payment_pending value each time the user
+    // selects different transactions to pay against.
+    $scope.$watch(
+        function() {return $scope.gridControls.selectedItems()},
+        function() {updatePendingColumn()},
+        true
+    );
+
+    // update the item.payment_pending for each (selected) 
+    // transaction any time the user-entered payment amount is modified
+    $scope.$watch('payment_amount', updatePendingColumn);
+
+    // updates the value of the payment_pending column in the grid.
+    // This has to be managed manually since the display value in the grid
+    // is derived from the value on the stored item and not the contents
+    // of our local scope variables.
+    function updatePendingColumn() {
+        // reset all to zero..
+        angular.forEach($scope.gridControls.allItems(), 
+            function(item) {item.payment_pending = 0});
+
+        var payment_amount = $scope.pending_payment();
+
+        var selected = $scope.gridControls.selectedItems();
+        for (var i = 0; i < selected.length; i++) { // for/break
+            var item = selected[i];
+            var owed = Number(item['summary.balance_owed']);
+
+            if (payment_amount > owed) {
+                // pending payment exceeds balance of current item.
+                // pay the entire item.
+                item.payment_pending = owed;
+                payment_amount -= owed;
+
+            } else {
+                // balance owed on the current item matches or exceeds
+                // the pending payment.  Apply the full remainder of
+                // the payment to this item.. and we're done.
+                item.payment_pending = payment_amount;
+                break;
+            }
+        }
+    }
+
+    // builds payment arrays ([xact_id, ammount]) for all transactions
+    // which have a pending payment amount.
+    function generatePayments() {
+        var payments = [];
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            if (item.payment_pending == 0) return;
+            payments.push([item.id, item.payment_pending]);
+        });
+        return payments;
+    }
+
+    function refreshDisplay() {
+        patronSvc.fetchUserStats();
+        billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+        $scope.payment_amount = 0;
+        $scope.gridControls.refresh();
+    }
+
+    // generates payments, collects user note if needed, and sends payment
+    // to server.
+    function sendPayment(note) {
+        var make_payments = generatePayments();
+        billSvc.applyPayment(
+            $scope.payment_type, make_payments, note)
+        .then(function(payment_ids) {
+
+            if ($scope.receipt_on_pay) {
+                printReceipt(
+                    $scope.payment_type, payment_ids, make_payments, note);
+            }
+
+            refreshDisplay();
+        })
+    }
+
+    function printReceipt(type, payment_ids, payments_made, note) {
+        var payment_blobs = [];
+        angular.forEach(payments_made, function(payment) {
+            var xact_id = payment[0];
+
+            // find the original transaction in the grid..
+            var xact = $scope.gridControls.allItems().filter(
+                function(item) {return item.id == xact_id})[0];
+
+            payment_blobs.push({
+                xact : egCore.idl.flatToNestedHash(xact),
+                amount : payment[1]
+            });
+        });
+
+        console.log(js2JSON(payment_blobs[0]));
+
+        // page data not yet refreshed, capture data from current scope
+        var print_data = {
+            payment_note : note,
+            previous_balance : Number($scope.summary.balance_owed()),
+            payment_total : Number($scope.payment_amount),
+            payment_applied : $scope.pending_payment(),
+            amount_voided : Number($scope.session_voided),
+            change_given : $scope.pending_change(),
+            payments : payment_blobs,
+            current_location : egCore.idl.toHash(
+                egCore.org.get(egCore.auth.user().ws_ou()))
+        }
+
+        print_data.new_balance = (
+            print_data.previous_balance * 100 - 
+            print_data.payment_applied * 100) / 100;
+
+        for (var i = 0; i < $scope.receipt_count; i++) {
+            egCore.print.print({
+                context : 'receipt', 
+                template : 'bill_payment', 
+                scope : print_data
+            });
+        }
+    }
+
+    $scope.showHistory = function() {
+        $location.path('/circ/patron/' + 
+            patronSvc.current.id() + '/bill_history/transactions');
+    }
+    
+    // For now, only adds billing to first selected item.
+    // Could do batches later if needed
+    $scope.addBilling = function(all) {
+        if (all[0]) {
+            egBilling.showBillDialog({
+                xact : egCore.idl.flatToNestedHash(all[0]),
+                patron : $scope.patron()
+            }).then(refreshDisplay);
+        }
+    }
+
+    $scope.showBillDialog = function($event) {
+        egBilling.showBillDialog({
+            patron : $scope.patron()
+        }).then(refreshDisplay);
+    }
+
+    // Select refunds adds all refunds to the existing selection.
+    // It does not /only/ select refunds
+    $scope.selectRefunds = function() {
+        var ids = $scope.gridControls.selectedItems().map(
+            function(i) { return i.id });
+        angular.forEach($scope.gridControls.allItems(), function(item) {
+            if (Number(item['summary.balance_owed']) < 0)
+                ids.push(item.id);
+        });
+        $scope.gridControls.selectItems(ids);
+    }
+
+    // -------------
+    // determine on initial page load when all of the grid rows should
+    // be selected.
+    var selectOnLoad = true;
+    billSvc.fetchBillSettings().then(function(s) {
+        if (s['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']) {
+            $scope.focus_payment = false; // de-focus the payment box
+            $scope.gridControls.focusRowSelector = true;
+            selectOnLoad = false;
+            // if somehow the grid finishes rendering before our settings 
+            // arrive, manually de-select everything.
+            $scope.gridControls.selectItems([]);
+        }
+    });
+
+    $scope.gridControls.allItemsRetrieved = function() {
+        if (selectOnLoad) {
+            selectOnLoad = false; // only for initial controller load.
+            // select all non-refund items
+            $scope.gridControls.selectItems( 
+                $scope.gridControls.allItems()
+                .filter(function(i) {return i['summary.balance_owed'] > 0})
+                .map(function(i){return i.id})
+            );
+        }
+    }
+    // -------------
+
+
+    $scope.printBills = function(selected) {
+        if (!selected.length) return;
+        // bills print receipt assumes nested hashes, but our grid
+        // stores flattener data.  Fetch the selected xacts as
+        // fleshed pcrud objects and hashify.  
+        // (Consider an alternate approach..)
+        var ids = selected.map(function(t){ return t.id });
+        var xacts = [];
+        egCore.pcrud.search('mbt', 
+            {id : ids},
+            {flesh : 1, flesh_fields : {'mbt' : ['summary']}},
+            {authoritative : true}
+        ).then(
+            function() {
+                egCore.print.print({
+                    context : 'receipt', 
+                    template : 'bills_current', 
+                    scope : {   
+                        transactions : xacts,
+                        current_location : egCore.idl.toHash(
+                            egCore.org.get(egCore.auth.user().ws_ou()))
+                    }
+                });
+            }, 
+            null, 
+            function(xact) {
+                xacts.push(egCore.idl.toHash(xact));
+            }
+        );
+    }
+
+    $scope.applyPayment = function() {
+        if ($scope.annotate_payment) {
+            egPromptDialog.open(
+                egCore.strings.ANNOTATE_PAYMENT_MSG, '',
+                {ok : function(value) {sendPayment(value)}}
+            );
+        } else {
+            sendPayment();
+        }
+    }
+
+    $scope.voidAllBillings = function(items) {
+        angular.forEach(items, function(item) {
+
+            billSvc.fetchBills(item.id).then(function(bills) {
+                var bill_ids = [];
+                var cents = 0;
+                angular.forEach(bills, function(b) {
+                    if (b.voided() != 't') {
+                        cents += b.amount() * 100;
+                        bill_ids.push(b.id())
+                    }
+                });
+
+                $scope.session_voided = 
+                    ($scope.session_voided * 100 + cents) / 100;
+
+                if (bill_ids.length == 0) {
+                    // TODO: warn
+                    return;
+                }
+
+                // TODO: alert of pending voiding
+
+                billSvc.voidBills(bill_ids).then(function() {
+                    refreshDisplay();
+                });
+            });
+        });
+    }
+
+    // note this is functionally equivalent to selecting a neg. transaction
+    // then clicking Apply Payment -- this just adds a speed bump (ditto
+    // the XUL client).
+    $scope.refundXact = function(all) {
+        var items = all.filter(function(item) {
+            return item['summary.balance_owed'] < 0
+        });
+
+        if (items.length == 0) return;
+
+        var ids = items.map(function(item) {return item.id});
+            
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_REFUND_PAYMENT, '', 
+            {   xactIds : ''+ids,
+                ok : function() {
+                    // reset the received payment amount.  this ensures
+                    // we're not mingling payments with refunds.
+                    $scope.payment_amount = 0;
+                }
+            }
+        );
+    }
+
+    // direct the user to the transaction details page
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0].id);
+    }
+
+    $scope.activateBill = function(xact) {
+        $scope.showFullDetails([xact]);
+    }
+
+}])
+
+/**
+ * Displays details of a single transaction
+ */
+.controller('XactDetailsCtrl',
+       ['$scope','$q','$routeParams','egCore','egGridDataProvider','patronSvc','billSvc','egPromptDialog','egBilling',
+function($scope,  $q , $routeParams , egCore , egGridDataProvider , patronSvc , billSvc , egPromptDialog , egBilling) {
+
+    $scope.initTab('bills', $routeParams.id);
+    var xact_id = $routeParams.xact_id;
+
+    var xactGrid = $scope.xactGridControls = {
+        setQuery : function() { return {xact : xact_id} },
+        setSort : function() { return ['billing_ts'] }
+    }
+
+    var paymentGrid = $scope.paymentGridControls = {
+        setQuery : function() { return {xact : xact_id} },
+        setSort : function() { return ['payment_ts'] }
+    }
+
+    // -- actions
+    $scope.voidBillings = function(bill_list) {
+        var bill_ids = [];
+        angular.forEach(bill_list, function(b) {
+            if (b.voided != 't') bill_ids.push(b.id);
+        });
+
+        if (bill_ids.length == 0) {
+            // TODO: warn
+            return;
+        }
+
+        billSvc.voidBills(bill_ids).then(function() {
+
+            // refresh bills and summary data
+            // note: no need to update payments
+            patronSvc.fetchUserStats();
+
+            egBilling.fetchXact(xact_id).then(function(xact) {
+                $scope.xact = xact
+            });
+
+            xactGrid.refresh();
+        });
+    }
+
+    // batch-edit billing and payment notes, depending on 'type'
+    function editNotes(selected, type) {
+        var notes = selected.map(function(b){ return b.note }).join(',');
+        var ids = selected.map(function(b){ return b.id });
+
+        // show the note edit prompt
+        egPromptDialog.open(
+            egCore.strings.EDIT_BILL_PAY_NOTE, notes, {
+                ids : ''+ids,
+                ok : function(value) {
+
+                    var func = 'updateBillNotes';
+                    if (type == 'payment') func = 'updatePaymentNotes';
+
+                    billSvc[func](value, ids).then(function() {
+                        if (type == 'payment') {
+                            paymentGrid.refresh();
+                        } else {
+                            xactGrid.refresh();
+                        }
+                    });
+                }
+            }
+        );
+    }
+
+    $scope.editBillNotes = function(selected) {
+        editNotes(selected, 'bill');
+    }
+
+    $scope.editPaymentNotes = function(selected) {
+        editNotes(selected, 'payment');
+    }
+
+    // -- retrieve our data
+    egBilling.fetchXact(xact_id).then(function(xact) {
+        $scope.xact = xact;
+
+        // set the title.  only needs to be done on initial page load
+        if (xact.circulation()) {
+            if (xact.circulation().target_copy().call_number().id() == -1) {
+                $scope.title = xact.circulation().target_copy().dummy_title();
+            } else  {
+                // TODO: shared bib service?
+                $scope.title = xact.circulation().target_copy()
+                    .call_number().record().simple_record().title();
+                $scope.title_id = xact.circulation().target_copy()
+                    .call_number().record().id();
+            }
+        }
+    });
+}])
+
+
+.controller('BillHistoryCtrl',
+       ['$scope','$q','$routeParams','egCore','patronSvc','billSvc','egPromptDialog','$location',
+function($scope,  $q , $routeParams , egCore , patronSvc , billSvc , egPromptDialog , $location) {
+
+    $scope.initTab('bills', $routeParams.id);
+    billSvc.userId = $routeParams.id;
+    $scope.bill_tab = $routeParams.history_tab;
+    $scope.totals = {};
+
+    var start = new Date(); // now - 1 year
+    start.setFullYear(start.getFullYear() - 1),
+    $scope.dates = {
+        xact_start : start,
+        xact_finish : new Date()
+    }
+
+    $scope.date_range = function() {
+        var start = $scope.dates.xact_start.toISOString().replace(/T.*/,'');
+        var end = $scope.dates.xact_finish.toISOString().replace(/T.*/,'');
+        var today = new Date().toISOString().replace(/T.*/,'');
+        if (end == today) end = 'now';
+        return [start, end];
+    }
+}])
+
+
+.controller('BillXactHistoryCtrl',
+       ['$scope','$q','egCore','patronSvc','billSvc','egPromptDialog','$location','egBilling',
+function($scope,  $q , egCore , patronSvc , billSvc , egPromptDialog , $location , egBilling) {
+
+    $scope.gridControls = {
+        selectedItems : function(){return []},
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setQuery : function() {
+            // open-ils.actor.user.transactions.history.have_bill_or_payment
+            return {
+                '-or' : [
+                    {'summary.balance_owed' : {'<>' : 0}},
+                    {'summary.last_payment_ts' : {'<>' : null}}
+                ],
+                xact_start : {between : $scope.date_range()},
+                usr : billSvc.userId
+            }
+        }
+    }
+
+
+    // TODO; move me to service
+    function selected_payment_info() {
+        var info = {owed : 0, billed : 0, paid : 0};
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            info.owed   += Number(item['summary.balance_owed']) * 100;
+            info.billed += Number(item['summary.total_owed']) * 100;
+            info.paid   += Number(item['summary.total_paid']) * 100;
+        });
+        info.owed /= 100;
+        info.billed /= 100;
+        info.paid /= 100;
+        return info;
+    }
+
+    $scope.totals.selected_billed = function() {
+        return selected_payment_info().billed;
+    }
+    $scope.totals.selected_paid = function() {
+        return selected_payment_info().paid;
+    }
+
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0].id);
+    }
+
+    // For now, only adds billing to first selected item.
+    // Could do batches later if needed
+    $scope.addBilling = function(all) {
+        if (all[0]) {
+            egBilling.showBillDialog({
+                xact : egCore.idl.flatToNestedHash(all[0]),
+                patron : $scope.patron()
+            }).then(function() { 
+                $scope.gridControls.refresh();
+                patronSvc.fetchUserStats();
+            })
+        }
+    }
+}])
+
+.controller('BillPaymentHistoryCtrl',
+       ['$scope','$q','egCore','patronSvc','billSvc','$location',
+function($scope,  $q , egCore , patronSvc , billSvc , $location) {
+
+    $scope.gridControls = {
+        selectedItems : function(){return []},
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setSort : function() {
+            return [{'payment_ts' : 'DESC'}, 'id'];
+        },
+        setQuery : function() {
+            return {
+                'payment_ts' : {between : $scope.date_range()},
+                'xact.usr' : billSvc.userId
+            }
+        }
+    }
+
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0]['xact.id']);
+    }
+
+    $scope.totals.selected_paid = function() {
+        var paid = 0;
+        angular.forEach($scope.gridControls.selectedItems(), function(payment) {
+            paid += Number(payment.amount) * 100;
+        });
+        return paid / 100;
+    }
+}])
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js b/Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js
new file mode 100644
index 0000000..5ee1bc5
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js
@@ -0,0 +1,201 @@
+/**
+ * Checkout items to patrons
+ */
+
+angular.module('egPatronApp').controller('PatronCheckoutCtrl',
+
+       ['$scope','$q','$modal','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','$location','$timeout','egCirc',
+
+function($scope , $q , $modal , $routeParams , egCore , egUser , patronSvc , 
+         egGridDataProvider , $location , $timeout , egCirc) {
+
+    $scope.initTab('checkout', $routeParams.id);
+    $scope.focusMe = true;
+    $scope.checkouts = patronSvc.checkouts;
+    $scope.checkoutArgs = {
+        noncat_type : 'barcode',
+        due_date : new Date()
+    };
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.checkouts, offset, count);
+        }
+    });
+
+    $scope.disable_checkout = function() {
+        return (
+            !patronSvc.current ||
+            patronSvc.current.active() == 'f' ||
+            patronSvc.current.deleted() == 't' ||
+            patronSvc.current.card().active() == 'f'
+        );
+    }
+
+    $scope.using_hatch = egCore.hatch.usingHatch();
+
+    // avoid multiple, in-flight attempts on the same barcode
+    var pending_barcodes = {};
+
+    var printOnComplete = true;
+    egCore.org.settings([
+        'circ.staff_client.do_not_auto_attempt_print'
+    ]).then(function(settings) { 
+        printOnComplete = !Boolean(
+            settings['circ.staff_client.do_not_auto_attempt_print']);
+    });
+
+    egCirc.get_noncat_types().then(function(list) {
+        $scope.nonCatTypes = list;
+    });
+
+    $scope.selectedNcType = function() {
+        if (!egCore.env.cnct) return null; // too soon
+        var type = egCore.env.cnct.map[$scope.checkoutArgs.noncat_type];
+        return type ? type.name() : null;
+    }
+
+    $scope.checkout = function(args) {
+        var params = angular.copy(args);
+        params.patron_id = patronSvc.current.id();
+
+        if (args.sticky_date) {
+            params.due_date = args.due_date.toISOString();
+        } else {
+            delete params.due_date;
+        }
+        delete params.sticky_date;
+
+        if (params.noncat_type == 'barcode') {
+            if (!args.copy_barcode) return;
+
+            args.copy_barcode = ''; // reset UI input
+            params.noncat_type = ''; // "barcode"
+
+            if (pending_barcodes[params.copy_barcode]) {
+                console.log(
+                    "Skipping checkout of redundant barcode " 
+                    + params.copy_barcode
+                );
+                return;
+            }
+
+            pending_barcodes[params.copy_barcode] = true;
+            send_checkout(params);
+
+        } else {
+            egCirc.noncat_dialog(params).then(function() {
+                send_checkout(params)
+            });
+        }
+
+        $scope.focusMe; // return focus to barcode input
+    }
+
+    function send_checkout(params) {
+
+        params.noncat_type = params.noncat ? params.noncat_type : '';
+
+        // populate the grid row before we send the request so that the
+        // order of actions is maintained and so the user gets an 
+        // immediate reaction to their barcode input action.
+        var row_item = {
+            index : $scope.checkouts.length,
+            copy_barcode : params.copy_barcode,
+            noncat_type : params.noncat_type
+        };
+
+        $scope.checkouts.unshift(row_item);
+        $scope.gridDataProvider.refresh();
+
+        var options = {check_barcode : $scope.strict_barcode};
+
+        egCirc.checkout(params, options).then(
+            function(co_resp) {
+                // update stats locally so we don't have to fetch them w/
+                // each checkout.
+                patronSvc.patron_stats.checkouts.out++;
+                patronSvc.patron_stats.checkouts.total_out++;
+
+                // copy the response event into the original grid row item
+                // note: angular.copy clobbers the destination
+                row_item.evt = co_resp.evt;
+                angular.forEach(co_resp.data, function(val, key) {
+                    row_item[key] = val;
+                });
+                munge_checkout_resp(co_resp, row_item);
+            },
+            function() {
+                // Circ was rejected somewhere along the way.
+                // Remove the copy from the grid since there was no action.
+                // note: since checkouts are unshifted onto the array, the
+                // index value does not (generally) match the array position.
+                var pos = -1;
+                angular.forEach($scope.checkouts, function(co, idx) {
+                    if (co.index == row_item.index) pos = idx;
+                });
+                $scope.checkouts.splice(pos, 1);
+                $scope.gridDataProvider.refresh();
+            }
+
+        )['finally'](function() {
+
+            // regardless of the outcome of the circ, remove the 
+            // barcode from the pending list.
+            if (params.copy_barcode)
+                delete pending_barcodes[params.copy_barcode];
+        });
+    }
+
+    // add some checkout-specific additions for display
+    function munge_checkout_resp(co_resp, row_item) {
+        var params = co_resp.params;
+        if (params.noncat) {
+            row_item.title = egCore.env.cnct.map[params.noncat_type].name();
+            row_item.noncat_count = params.noncat_count;
+            row_item.circ = new egCore.idl.circ();
+            row_item.circ.due_date(co_resp.evt.payload.noncat_circ.duedate());
+        }
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {circulations : []}
+
+        if ($scope.checkouts.length == 0) return $q.when();
+
+        angular.forEach($scope.checkouts, function(co) {
+            if (co.circ) {
+                print_data.circulations.push({
+                    circ : egCore.idl.toHash(co.circ),
+                    copy : egCore.idl.toHash(co.acp),
+                    call_number : egCore.idl.toHash(co.acn),
+                    title : co.title,
+                    author : co.author
+                })
+            };
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'checkout', 
+            scope : print_data,
+            show_dialog : $scope.show_print_dialog
+        });
+    }
+
+    // Redirect the user to the barcode entry page to load a new patron.
+    // If configured to do so, print the receipt first
+    $scope.done = function() {
+        if (printOnComplete) {
+
+            $scope.print_receipt().then(function() {
+                $location.path('/circ/patron/bcsearch');
+            });
+
+        } else {
+            $location.path('/circ/patron/bcsearch');
+        }
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/holds.js b/Open-ILS/web/js/ui/default/staff/circ/patron/holds.js
new file mode 100644
index 0000000..c42a162
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/holds.js
@@ -0,0 +1,155 @@
+/**
+ * List of patron holds
+ */
+
+angular.module('egPatronApp').controller('PatronHoldsCtrl',
+
+       ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','egHolds','$window','$location','egCirc','egHoldGridActions',
+function($scope,  $q,  $routeParams,  egCore,  egUser,  patronSvc,  
+        egGridDataProvider , egHolds , $window , $location , egCirc, egHoldGridActions) {
+
+    $scope.initTab('holds', $routeParams.id);
+    $scope.holds_display = 'main';
+    $scope.detail_hold_id = $routeParams.hold_id;
+    $scope.grid_actions = egHoldGridActions;
+
+    function refresh_all() {
+        patronSvc.refreshPrimary();
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh() 
+    }
+    $scope.grid_actions.refresh = refresh_all;
+
+    $scope.show_main_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.holds_display = 'main';
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh();
+    }
+
+    $scope.show_alt_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.holds_display = 'alt';
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh();
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function fetchHolds(offset, count) {
+        var ids = patronSvc.hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                patronSvc.holds.push(hold_data);
+                return hold_data;
+            }
+        );
+    }
+
+    provider.get = function(offset, count) {
+
+        // see if we have the requested range cached
+        if (patronSvc.holds[offset]) {
+            return provider.arrayNotifier(patronSvc.holds, offset, count);
+        }
+
+        // see if we have the holds IDs for this range already loaded
+        if (patronSvc.hold_ids[offset]) {
+            return fetchHolds(offset, count);
+        }
+
+        var deferred = $q.defer();
+        patronSvc.hold_ids = [];
+
+        var method = 'open-ils.circ.holds.id_list.retrieve.authoritative';
+        if ($scope.holds_display == 'alt')
+            method = 'open-ils.circ.holds.canceled.id_list.retrieve.authoritative';
+
+        egCore.net.request(
+            'open-ils.circ', method,
+            egCore.auth.token(), $scope.patron_id
+
+        ).then(function(hold_ids) {
+            if (!hold_ids.length) { deferred.resolve(); return; }
+
+            patronSvc.hold_ids = hold_ids;
+            fetchHolds(offset, count)
+            .then(deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+    $scope.print = function() {
+        var holds = [];
+        angular.forEach(patronSvc.holds, function(item) {
+            holds.push({
+                hold : egCore.idl.toHash(item.hold),
+                copy : egCore.idl.toHash(item.copy),
+                volume : egCore.idl.toHash(item.volume),
+                title : item.mvr.title(),
+                author : item.mvr.author()
+            });
+        });
+
+        egCore.print.print({
+            context : 'receipt', 
+            template : 'holds_for_patron', 
+            scope : {holds : holds}
+        });
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/patron/' + 
+                $scope.patron_id + '/holds/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/patron/' + $scope.patron_id + '/holds');
+    }
+
+    $scope.place_hold = function() {
+        $location.path($location.path() + '/create');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+}])
+
+
+.controller('PatronHoldsCreateCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope , $routeParams , $location , egCore , patronSvc) {
+
+    $scope.handlers = {
+        opac_hold_placed : function() {
+            // FIXME: this isn't getting called.. not sure why
+            patronSvc.fetchUserStats(); // update hold counts
+        }
+    }
+
+    $scope.initTab('holds', $routeParams.id).then(function(isAlert) {
+        if (isAlert) return;
+        // not guarenteed to have a barcode until init fetches the user
+        $scope.handlers.patron_barcode = patronSvc.current.card().barcode();
+    });
+
+    $scope.catalog_url = 
+        $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+    $scope.handle_page = function(url) {
+    }
+
+}])
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js b/Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js
new file mode 100644
index 0000000..fa68501
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js
@@ -0,0 +1,406 @@
+/**
+ * List of patron items checked out
+ */
+
+angular.module('egPatronApp')
+
+.controller('PatronItemsOutCtrl',
+       ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','$modal','egCirc','egConfirmDialog','egBilling',
+function($scope,  $q,  $routeParams,  egCore , egUser,  patronSvc , 
+         egGridDataProvider , $modal , egCirc , egConfirmDialog , egBilling) {
+    $scope.initTab('items_out', $routeParams.id);
+
+    // cache of circ objects for grid display
+    patronSvc.items_out = [];
+
+    // main list of checked out items
+    $scope.main_list = [];
+
+    // list of alt circs (lost, etc.) and/or check-in with fines circs
+    $scope.alt_list = []; 
+
+    // these are fetched during startup (i.e. .configure())
+    // By default, show lost/lo/cr items in the alt list
+    var display_lost = Number(
+        egCore.env.aous['ui.circ.items_out.lost']) || 2;
+    var display_lo = Number(
+        egCore.env.aous['ui.circ.items_out.longoverdue']) || 2;
+    var display_cr = Number(
+        egCore.env.aous['ui.circ.items_out.claimsreturned']) || 2;
+
+    var fetch_checked_in = true;
+    $scope.show_alt_circs = true;
+    if (display_lost & 4 && display_lo & 4 && display_cr & 4) {
+        // all special types are configured to be hidden once
+        // checked in, so there's no need to fetch checked-in circs.
+        fetch_checked_in = false;
+
+        if (display_lost & 1 && display_lo & 1 && display_cr & 1) {                 
+            // additionally, if all types are configured to display    
+            // in the main list while checked out, nothing will         
+            // ever appear in the alternate list, so we can hide          
+            // the alternate list from the UI.  
+            $scope.show_alt_circs = false;
+        }
+    }
+
+    $scope.items_out_display = 'main';
+    $scope.show_main_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.items_out_display = 'main';
+        patronSvc.items_out = [];
+        provider.refresh();
+    }
+
+    $scope.show_alt_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.items_out_display = 'alt';
+        patronSvc.items_out = [];
+        provider.refresh();
+    }
+
+    // Reload the user to pick up changes in items out, fines, etc.
+    // Reload circs since the contents of the main vs. alt list may
+    // have changed.
+    function reset_page() {
+        patronSvc.refreshPrimary();
+        patronSvc.items_out = []; 
+        $scope.main_list = [];
+        $scope.alt_list = [];
+        provider.refresh() 
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function fetch_circs(id_list, offset, count) {
+        if (!id_list.length) return $q.when();
+
+        // fetch the lot of circs and stream the results back via notify
+        return egCore.pcrud.search('circ', {id : id_list},
+            {   flesh : 4,
+                flesh_fields : {
+                    circ : ['target_copy'],
+                    acp : ['call_number'],
+                    acn : ['record'],
+                    bre : ['simple_record']
+                },
+                // avoid fetching the MARC blob by specifying which 
+                // fields on the bre to select.  More may be needed.
+                // note that fleshed fields are explicitly selected.
+                select : { bre : ['id'] },
+                limit  : count,
+                offset : offset,
+                // we need an order-by to support paging
+                order_by : {circ : ['xact_start']} 
+
+        }).then(null, null, function(circ) {
+            circ.circ_lib(egCore.org.get(circ.circ_lib())); // local fleshing
+
+            if (circ.target_copy().call_number().id() == -1) {
+                // dummy-up a record for precat items
+                circ.target_copy().call_number().record().simple_record({
+                    title : function() {return circ.target_copy().dummy_title()},
+                    author : function() {return circ.target_copy().dummy_author()},
+                    isbn : function() {return circ.target_copy().dummy_isbn()}
+                })
+            }
+
+            patronSvc.items_out.push(circ); // toss it into the cache
+            return circ;
+        });
+    }
+
+    // decide which list each circ belongs to
+    function promote_circs(list, display_code, open) {
+        if (open) {                                                    
+            if (1 & display_code) { // bitflag 1 == top list                   
+                $scope.main_list = $scope.main_list.concat(list);
+            } else {                                                   
+                $scope.alt_list = $scope.alt_list.concat(list);
+            }                                                          
+        } else {                                                       
+            if (4 & display_code) return;  // bitflag 4 == hide on checkin     
+            $scope.alt_list = $scope.alt_list.concat(list);
+        } 
+    }
+
+    // fetch IDs for circs we care about
+    function get_circ_ids() {
+        $scope.main_list = [];
+        $scope.alt_list = [];
+
+        // we can fetch these in parallel
+        var promise1 = egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_out.authoritative',
+            egCore.auth.token(), $scope.patron_id
+        ).then(function(outs) {
+            $scope.main_list = outs.out.concat(outs.overdue);
+            promote_circs(outs.lost, display_lost, true);                            
+            promote_circs(outs.long_overdue, display_lo, true);             
+            promote_circs(outs.claims_returned, display_cr, true);
+        });
+
+        // only fetched checked-in-with-bills circs if configured to display
+        var promise2 = !fetch_checked_in ? $q.when() : egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_in_with_fines.authoritative',
+            egCore.auth.token(), $scope.patron_id
+        ).then(function(outs) {
+            promote_circs(outs.lost, display_lost);
+            promote_circs(outs.long_overdue, display_lo);
+            promote_circs(outs.claims_returned, display_cr);
+        });
+
+        return $q.all([promise1, promise2]);
+    }
+
+    provider.get = function(offset, count) {
+
+        var id_list = $scope[$scope.items_out_display + '_list'];
+
+        // see if we have the requested range cached
+        if (patronSvc.items_out[offset]) {
+            return provider.arrayNotifier(
+                patronSvc.items_out, offset, count);
+        }
+
+        // See if we have the circ IDs for this range already loaded.
+        // this would happen navigating to a subsequent page.
+        if (id_list[offset]) {
+            return fetch_circs(id_list, offset, count);
+        }
+
+        // avoid returning the request directly to the caller so the
+        // notify()'s from egCore.net.request don't leak into the 
+        // final set of notifies (i.e. the real responses);
+
+        var deferred = $q.defer();
+        get_circ_ids().then(function() {
+
+            id_list = $scope[$scope.items_out_display + '_list'];
+
+            // relay the notified circs back to the grid through our promise
+            fetch_circs(id_list, offset, count).then(
+                deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+
+    // true if circ is overdue, false otherwise
+    $scope.circIsOverdue = function(circ) {
+        // circ may not exist yet for rendered row
+        if (!circ) return false;
+
+        var date = new Date();
+        date.setTime(Date.parse(circ.due_date()));
+        return date < new Date();
+    }
+
+    $scope.edit_due_date = function(items) {
+        if (!items.length) return;
+
+        $modal.open({
+            templateUrl : './circ/patron/t_edit_due_date_dialog',
+            controller : [
+                        '$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                    // if there is only one circ, default to the due date
+                    // of that circ.  Otherwise, default to today.
+                    var due_date = items.length == 1 ? 
+                        Date.parse(items[0].due_date()) : new Date();
+
+                    $scope.args = {
+                        num_circs : items.length,
+                        due_date : due_date
+                    }
+
+                    // Fire off the due-date updater for each circ.
+                    // When all is done, close the dialog
+                    $scope.ok = function(args) {
+                        var due = args.due_date.toISOString().replace(/T.*/,'');
+                        console.debug("applying due date of " + due);
+
+                        var promises = [];
+                        angular.forEach(items, function(circ) {
+                            promises.push(
+                                egCore.net.request(
+                                    'open-ils.circ',
+                                    'open-ils.circ.circulation.due_date.update',
+                                    egCore.auth.token(), circ.id(), due
+
+                                ).then(function(new_circ) {
+                                    // update the grid circ with the canonical 
+                                    // date from the modified circulation.
+                                    circ.due_date(new_circ.due_date());
+                                })
+                            );
+                        });
+
+                        $q.all(promises).then(function() {
+                            $modalInstance.close();
+                            provider.refresh();
+                        });
+                    }
+                    $scope.cancel = function($event) {
+                        $modalInstance.dismiss();
+                        $event.preventDefault();
+                    }
+                }
+            ]
+        });
+    }
+
+    $scope.print_receipt = function(items) {
+        if (items.length == 0) return $q.when();
+        var print_data = {circulations : []}
+
+        angular.forEach(patronSvc.items_out, function(circ) {
+            print_data.circulations.push({
+                circ : egCore.idl.toHash(circ),
+                copy : egCore.idl.toHash(circ.target_copy()),
+                call_number : egCore.idl.toHash(circ.target_copy().call_number()),
+                title : circ.target_copy().call_number().record().simple_record().title(),
+                author : circ.target_copy().call_number().record().simple_record().author(),
+            })
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'items_out', 
+            scope : print_data,
+        });
+    }
+
+    function batch_action_with_barcodes(items, action) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+        action(barcodes).then(reset_page);
+    }
+    $scope.mark_lost = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_lost);
+    }
+    $scope.mark_claims_returned = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_claims_returned_dialog);
+    }
+    $scope.mark_claims_never_checked_out = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_claims_never_checked_out);
+    }
+
+    $scope.renew = function(items, msg) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        if (!msg) msg = egCore.strings.RENEW_ITEMS;
+
+        return egConfirmDialog.open(msg, barcodes.join(' '), {}).result
+        .then(function() {
+            function do_one() {
+                var bc = barcodes.pop();
+                if (!bc) { reset_page(); return }
+                // finally -> continue even when one fails
+                egCirc.renew({copy_barcode : bc}).finally(do_one);
+            }
+            do_one();
+        });
+    }
+
+    $scope.renew_all = function() {
+        var circs = patronSvc.items_out.filter(function(circ) {
+            return (
+                // all others will be rejected at the server
+                !circ.stop_fines() ||
+                circ.stop_fines() == 'MAXFINES'
+            );
+        });
+        $scope.renew(circs, egCore.strings.RENEW_ALL_ITEMS);
+    }
+
+    $scope.renew_with_date = function(items) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        return $modal.open({
+            templateUrl : './circ/patron/t_edit_due_date_dialog',
+            templateUrl : './circ/patron/t_renew_with_date_dialog',
+            controller : [
+                        '$scope','$modalInstance',
+                function($scope , $modalInstance) {
+                    $scope.args = {
+                        barcodes : barcodes,
+                        date : new Date()
+                    }
+                    $scope.cancel = function() {$modalInstance.dismiss()}
+
+                    // Fire off the due-date updater for each circ.
+                    // When all is done, close the dialog
+                    $scope.ok = function() {
+                        var due = $scope.args.date.toISOString().replace(/T.*/,'');
+                        console.debug("renewing with due date: " + due);
+
+                        function do_one() {
+                            if (bc = barcodes.pop()) {
+                                egCirc.renew({copy_barcode : bc, due_date : due})
+                                .finally(do_one);
+                            } else {
+                                $modalInstance.close(); 
+                                reset_page();
+                            }
+                        }
+                       do_one(); // kick it off
+                    }
+                }
+            ]
+        }).result;
+    }
+
+    $scope.checkin = function(items) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        return egConfirmDialog.open(
+            egCore.strings.CHECK_IN_CONFIRM, barcodes.join(' '), {
+
+        }).result.then(function() {
+            function do_one() {
+                if (bc = barcodes.pop()) {
+                    egCirc.checkin({copy_barcode : bc})
+                    .finally(do_one);
+                } else {
+                    reset_page();
+                }
+            }
+            do_one(); // kick it off
+        });
+    }
+
+    $scope.add_billing = function(items) {
+        if (!items.length) return;
+        var circs = items.concat(); // don't pop from grid array
+        function do_one() {
+            var circ; // don't clobber window.circ!
+            if (circ = circs.pop()) {
+                egBilling.showBillDialog({
+                    // let the dialog fetch the transaction, since it's
+                    // not sufficiently fleshed here.
+                    xact_id : circ.id(),
+                    patron : patronSvc.current
+                }).finally(do_one);
+            } else {
+                reset_page();
+            }
+        }
+        do_one();
+    }
+
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/pending.js b/Open-ILS/web/js/ui/default/staff/circ/patron/pending.js
new file mode 100644
index 0000000..9c2bc65
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/pending.js
@@ -0,0 +1,84 @@
+angular.module('egPendingPatronsApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/patron/pending/list', {
+        templateUrl: './circ/patron/t_pending_list',
+        controller: 'PendingPatronsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/pending/list'});
+})
+
+.controller('PendingPatronsCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egGridDataProvider) {
+
+    console.log('HERE');
+
+    var pending_patrons = [];
+    var provider = egGridDataProvider.instance({});
+    $scope.grid_data_provider = provider;
+
+    function load_patron(item) {
+        if (angular.isArray(item)) item = item[0];
+        if (!item) return;
+        $window.open(
+            $location.path(
+                '/circ/patron/register/stage/' + item.user.usrname()).absUrl(),
+            '_blank'
+        ).focus();
+    }
+
+    $scope.load_patron = function(action, data, items) {
+        load_patron(items);
+    }
+
+    $scope.grid_controls = {
+        activateItem : load_patron
+    }
+
+    function refresh_page() {
+        pending_patrons = [];
+        provider.refresh();
+    }
+
+    provider.get = function(offset, count) {
+        var deferred = $q.defer();
+        var recv_index = 0;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.stage.retrieve.by_org',
+            egCore.auth.token(), $scope.context_org.id()
+
+        ).then(
+            deferred.resolve, null, 
+            function(user) {
+                user.id = user.user.row_id();
+                user.user.home_ou(egCore.org.get(user.user.home_ou()));
+
+                // only one (mailing) address is captured during patron
+                // self-registration
+                user.mailing_address = user.mailing_addresses[0];
+                pending_patrons[offset + recv_index++] = user;
+                deferred.notify(user);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.context_org = egCore.org.get(egCore.auth.user().ws_ou())
+    $scope.$watch('context_org', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) refresh_page();
+    });
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/register.js b/Open-ILS/web/js/ui/default/staff/circ/patron/register.js
new file mode 100644
index 0000000..13b4a41
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/patron/register.js
@@ -0,0 +1,72 @@
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronRegApp', ['ui.bootstrap','ngRoute','egCoreMod'])
+
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/patron/register', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/stage/:stage_username', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/edit/:edit_id', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/clone/:clone_id', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/register'});
+})
+
+
+/**
+ * */
+.controller('PatronRegCtrl',
+       ['$scope','$routeParams','$location','egCore',
+function($scope , $routeParams , $location , egCore) {
+    
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+
+    // since we don't store auth cookies, pass the cookie via URL
+    url += '?ses=' + egCore.auth.token();
+
+    if ($routeParams.stage_username) {
+        url += '&stage=' + encodeURIComponent($routeParams.stage_username);
+    }
+
+    if ($routeParams.edit_id) {
+        url += '&usr=' + encodeURIComponent($routeParams.edit_id);
+    }
+
+    if ($routeParams.clone_id) {
+        url += '&clone=' + encodeURIComponent($routeParams.clone_id);
+    }
+
+    // pass the reg URL into the scope, thus into the 
+    $scope.reg_url = url;
+}])
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/circ/renew/app.js b/Open-ILS/web/js/ui/default/staff/circ/renew/app.js
new file mode 100644
index 0000000..c64c66c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/renew/app.js
@@ -0,0 +1,213 @@
+/**
+ * Renewal
+ */
+
+angular.module('egRenewApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/circ/renew/renew', {
+        templateUrl: './circ/renew/t_renew',
+        controller: 'RenewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/renew/renew', {
+        templateUrl: './circ/renew/t_renew',
+        controller: 'RenewCtrl',
+        resolve : resolver
+    });
+    
+    $routeProvider.otherwise({redirectTo : '/circ/renew/renew'});
+})
+
+
+
+
+.controller('RenewCtrl',
+       ['$scope','$window','$location','egCore','egGridDataProvider','egCirc',
+function($scope , $window , $location , egCore , egGridDataProvider , egCirc) {
+
+    $scope.focusBarcode = true;
+    $scope.renewals = [];
+
+    var today = new Date();
+    $scope.renewalArgs = {due_date : today};
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.renewals, offset, count);
+        }
+    });
+
+    // avoid multiple, in-flight attempts on the same barcode
+    var pending_barcodes = {};
+
+    $scope.renew = function(args) {
+        var params = angular.copy(args);
+
+        if (args.sticky_date) {
+            params.due_date = args.due_date.toISOString();
+        } else {
+            delete params.due_date;
+        }
+        delete params.sticky_date;
+         if (!args.copy_barcode) return;
+
+        args.copy_barcode = ''; // reset UI input
+
+        if (pending_barcodes[params.copy_barcode]) {
+            console.log(
+                "Skipping renewals of redundant barcode " 
+                + params.copy_barcode
+            );
+            return;
+        }
+
+        pending_barcodes[params.copy_barcode] = true;
+        send_renewal(params);
+
+        $scope.focusBarcode = true; // return focus to barcode input
+    }
+
+    function send_renewal(params) {
+
+        params.noncat_type = params.noncat ? params.noncat_type : '';
+
+        // populate the grid row before we send the request so that the
+        // order of actions is maintained and so the user gets an 
+        // immediate reaction to their barcode input action.
+        var row_item = {
+            index : $scope.renewals.length,
+            copy_barcode : params.copy_barcode,
+            noncat_type : params.noncat_type
+        };
+
+        $scope.renewals.unshift(row_item);
+        $scope.gridDataProvider.refresh();
+
+        var options = {check_barcode : $scope.strict_barcode};
+
+        egCirc.renew(params, options).then(
+            function(final_resp) {
+
+                row_item.evt = final_resp.evt;
+                angular.forEach(final_resp.data, function(val, key) {
+                    row_item[key] = val;
+                });
+
+                if (row_item.mbts) {
+                    var amt = Number(row_item.mbts.balance_owed());
+                    if (amt != 0) {
+                        $scope.billable_barcode = row_item.copy_barcode;
+                        $scope.billable_amount = amt;
+                        $scope.fine_total = 
+                            ($scope.fine_total * 100 + amt * 100) / 100;
+                    }
+                }
+
+                if ($scope.trim_list && checkinSvc.checkins.length > 20)
+                    checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+
+            },
+            function() {
+                // Circ was rejected somewhere along the way.
+                // Remove the copy from the grid since there was no action.
+                // note: since renewals are unshifted onto the array, the
+                // index value does not (generally) match the array position.
+                var pos = -1;
+                angular.forEach($scope.renewals, function(co, idx) {
+                    if (co.index == row_item.index) pos = idx;
+                });
+                $scope.renewals.splice(pos, 1);
+                $scope.gridDataProvider.refresh();
+            }
+
+        )['finally'](function() {
+
+            // regardless of the outcome of the circ, remove the 
+            // barcode from the pending list.
+            if (params.copy_barcode)
+                delete pending_barcodes[params.copy_barcode];
+        });
+    }
+
+    $scope.fetchLastCircPatron = function(items) {
+        var renewal = items[0];
+        if (!renewal || !renewal.acp) return;
+
+        egCirc.last_copy_circ(renewal.acp.id())
+        .then(function(circ) {
+
+            if (circ) {
+                // jump to the patron UI (separate app)
+                $window.location.href = $location
+                    .path('/circ/patron/' + circ.usr() + '/checkout')
+                    .absUrl();
+                return;
+            }
+
+            $scope.alert = {item_never_circed : renewal.acp.barcode()};
+        });
+    }
+
+    $scope.showMarkDamaged = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+
+        if (copy_ids.length) {
+            egCirc.mark_damaged(copy_ids).then(function() {
+                // update grid items?
+            });
+        }
+    }
+
+    $scope.showLastFewCircs = function(items) {
+        if (items.length && (copy = items[0].acp)) {
+            var url = $location.path(
+                '/cat/item/' + copy.id() + '/circ_list').absUrl();
+            $window.open(url, '_blank').focus();
+        }
+    }
+
+    $scope.abortTransit = function(items) {
+        var transit_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.transit) transit_ids.push(item.transit.id());
+        });
+
+        egCirc.abort_transits(transit_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {circulations : []}
+
+        if ($scope.renewals.length == 0) return $q.when();
+
+        angular.forEach($scope.renewals, function(renewal) {
+            if (renewal.circ) {
+                print_data.circulations.push({
+                    circ : egCore.idl.toHash(renewal.circ),
+                    copy : egCore.idl.toHash(renewal.acp),
+                    title : egCore.idl.toHash(renewal.title),
+                    author : egCore.idl.toHash(renewal.author)
+                });
+            }
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'renew', 
+            scope : print_data,
+        });
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/billing.js b/Open-ILS/web/js/ui/default/staff/circ/services/billing.js
new file mode 100644
index 0000000..9a2efb6
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/services/billing.js
@@ -0,0 +1,175 @@
+/**
+ * Shared services for patron billing.
+ * 
+ */
+
+angular.module('egCoreMod')
+
+.factory('egBilling', 
+       ['$modal','$q','egCore',
+function($modal , $q , egCore) {
+
+    var service = {};
+
+    // fetch a fleshed money.billable_xact
+    service.fetchXact = function(xact_id) {
+        return egCore.pcrud.retrieve('mbt', xact_id, {
+            flesh : 5,
+            flesh_fields : {
+                mbt : ['summary','circulation','grocery','reservation'],
+                circ: ['target_copy'],
+                acp : ['call_number','location','status','age_protect'],
+                acn : ['record'],
+                bre : ['simple_record']
+            },
+            select : {bre : ['id']}}, // avoid MARC
+            {authoritative : true}
+        );
+    }
+
+    // apply a patron billing.  If no xact is provided, a grocery xact is
+    // created.
+    service.billPatron = function(args, xact) {
+        // apply a billing to an existing transaction
+        if (xact) return service.createBilling(xact.id, args);
+
+        // create a new grocery xact, then apply a billing
+        return service.createGroceryXact(args)
+        .then(function(xact_id) { 
+            return service.createBilling(xact_id, args);
+        });
+    }
+
+    // create a new grocery xact
+    service.createGroceryXact = function(args) {
+        var groc = new egCore.idl.mg();
+        groc.billing_location(egCore.auth.user().ws_ou());
+        groc.note(args.note);
+        groc.usr(args.patron_id);
+        
+        // create the xact
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.grocery.create',
+            egCore.auth.token(), groc
+
+        // create the billing on the new xact
+        ).then(function(xact_id) {
+            if (evt = egCore.evt.parse(xact_id)) 
+                return alert(evt);
+            return xact_id;
+        });
+    }
+
+    // fetch the org-focused billing types
+    // Cache on egEnv
+    service.fetchBillingTypes = function() {
+        if (egCore.env.cbt) 
+            return $q.when(egCore.env.cbt.list);
+
+        return egCore.pcrud.search('cbt', 
+            {   // first 100 are reserved for system-generated bills
+                id : {'>' : 100}, 
+                owner : egCore.org.ancestors(
+                    egCore.auth.user().ws_ou(), true)
+            }, 
+            {}, {atomic : true}
+        ).then(function(list) {
+            egCore.env.absorbList(list, 'cbt');
+            return list;
+        });
+    }
+
+    // create a patron billing
+    service.createBilling = function(xact_id, args) {
+        var bill = new egCore.idl.mb();
+        bill.xact(xact_id);
+        bill.amount(args.amount);
+        bill.btype(args.billingType);
+        bill.billing_type(egCore.env.cbt.map[args.billingType].name());
+        bill.note(args.note);
+
+        return egCore.net.request(
+            'open-ils.circ', 
+            'open-ils.circ.money.billing.create',
+            egCore.auth.token(), bill
+
+        // check the billing response
+        ).then(function(bill_id) {
+            if (evt = egCore.evt.parse(bill_id)) {
+                alert(evt);
+            } else {
+                return bill_id;
+            }
+        });
+    }
+
+
+    // Show the billing dialog.  
+    // Allows users to select amount, billing type, and note.
+    // args:
+    //   xact OR xact_id : if null, creates a grocery xact
+    //   patron OR patron_id
+    service.showBillDialog = function(args) {
+
+        return $modal.open({
+            templateUrl: './circ/share/t_bill_patron_dialog',
+            controller: 
+                   ['$scope','$modalInstance','$timeout','billingTypes','xact','patron',
+            function($scope , $modalInstance , $timeout , billingTypes , xact , patron) {
+                console.debug('billing patron ' + patron.id());
+                $scope.focus = true;
+                if (xact && xact._isfieldmapper)
+                    xact = egCore.idl.toHash(xact);
+                $scope.xact = xact;
+                $scope.patron = patron;
+                $scope.billingTypes = billingTypes;
+                $scope.location = egCore.org.get(egCore.auth.user().ws_ou()),
+                $scope.billArgs = {
+                    billingType : 101, // default to stock Misc. billing type
+                    xact : xact,
+                    patron_id : patron.id()
+                }
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+                $scope.updateDefaultPrice = function() {
+                    var type = billingTypes.filter(function(t) {
+                        return t.id() == $scope.billArgs.billingType })[0];
+                    if (type.default_price() && !$scope.billArgs.amount) 
+                        $scope.billArgs.amount = Number(type.default_price());
+                }
+            }],
+            resolve : {
+                // if we don't already have them, fetch the billing types
+                billingTypes : function() {
+                    return service.fetchBillingTypes();
+                }, 
+
+                xact : function() {
+                    if (args.xact) return $q.when(args.xact);
+                    if (args.xact_id) return service.fetchXact(args.xact_id);
+                    return $q.when();
+                },
+
+                patron : function() {
+                    if (args.patron) return $q.when(args.patron);
+                    return  egCore.pcrud.retrieve('au', args.patron_id,
+                        {flesh : 1, flesh_fields : {au : ['card']}});
+                }
+
+            }
+        }).result.then(
+            function(args) {
+                // send the billing to the server using the arguments
+                // provided in the billing dialog, then refresh
+                return service.billPatron(args, args.xact);
+            }
+        );
+    }
+
+    return service;
+}]);
+
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/circ.js b/Open-ILS/web/js/ui/default/staff/circ/services/circ.js
new file mode 100644
index 0000000..edf975a
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/services/circ.js
@@ -0,0 +1,1485 @@
+/**
+ * Checkin, checkout, and renew
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCirc',
+
+       ['$modal','$q','egCore','egAlertDialog','egConfirmDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog) {
+
+    var service = {
+        // auto-override these events after the first override
+        auto_override_checkout_events : {},
+    };
+
+    service.reset = function() {
+        service.auto_override_checkout_events = {};
+    }
+
+    // these events can be overridden by staff during checkout
+    service.checkout_overridable_events = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES',
+        'PATRON_BARRED',
+        'CIRC_EXCEEDS_COPY_RANGE',
+        'ITEM_DEPOSIT_REQUIRED',
+        'ITEM_RENTAL_FEE_REQUIRED',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'COPY_CIRC_NOT_ALLOWED',
+        'COPY_NOT_AVAILABLE',
+        'COPY_IS_REFERENCE',
+        'COPY_ALERT_MESSAGE',
+        'ITEM_ON_HOLDS_SHELF'                 
+    ]
+
+    // after the first override of any of these events, 
+    // auto-override them in subsequent calls.
+    service.checkout_auto_override_after_first = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_BARRED',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES'
+    ]
+
+
+    // overridable during renewal
+    service.renew_overridable_events = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES',
+        'CIRC_EXCEEDS_COPY_RANGE',
+        'ITEM_DEPOSIT_REQUIRED',
+        'ITEM_RENTAL_FEE_REQUIRED',
+        'ITEM_DEPOSIT_PAID',
+        'COPY_CIRC_NOT_ALLOWED',
+        'COPY_IS_REFERENCE',
+        'COPY_ALERT_MESSAGE',
+        'COPY_NEEDED_FOR_HOLD',
+        'MAX_RENEWALS_REACHED',
+        'CIRC_CLAIMS_RETURNED'
+    ];
+
+    // these checkin events do not produce alerts when 
+    // options.suppress_alerts is in effect.
+    service.checkin_suppress_overrides = [
+        'COPY_BAD_STATUS',
+        'PATRON_BARRED',
+        'PATRON_INACTIVE',
+        'PATRON_ACCOUNT_EXPIRED',
+        'ITEM_DEPOSIT_PAID',
+        'CIRC_CLAIMS_RETURNED',
+        'COPY_ALERT_MESSAGE',
+        'COPY_STATUS_LOST',
+        'COPY_STATUS_LONG_OVERDUE',
+        'COPY_STATUS_MISSING',
+        'PATRON_EXCEEDS_FINES'
+    ]
+
+    // these events can be overridden by staff during checkin
+    service.checkin_overridable_events = 
+        service.checkin_suppress_overrides.concat([
+        'TRANSIT_CHECKIN_INTERVAL_BLOCK'
+    ])
+
+    // Performs a checkout.
+    // Returns a promise resolved with the original params and options
+    // and the final checkout event (e.g. in the case of override).
+    // Rejected if the checkout cannot be completed.
+    //
+    // params : passed directly as arguments to the server API 
+    // options : non-parameter controls.  e.g. "override", "check_barcode"
+    service.checkout = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.checkout() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.checkout.full';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+
+                return service.flesh_response_data('checkout', evt, params, options)
+                .then(function() {
+                    return service.handle_checkout_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // Performs a renewal.
+    // Returns a promise resolved with the original params and options
+    // and the final checkout event (e.g. in the case of override)
+    // Rejected if the renewal cannot be completed.
+    service.renew = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.renew() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.renew';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+
+                return service.flesh_response_data(
+                    'renew', evt, params, options)
+                .then(function() {
+                    return service.handle_renew_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // Performs a checkin
+    // Returns a promise resolved with the original params and options,
+    // plus the final checkin event (e.g. in the case of override).
+    // Rejected if the checkin cannot be completed.
+    service.checkin = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.checkin() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.checkin';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+                return service.flesh_response_data(
+                    'checkin', evt, params, options)
+                .then(function() {
+                    return service.handle_checkin_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // provide consistent formatting of the final response data
+    service.munge_resp_data = function(final_resp) {
+        var data = final_resp.data = {};
+
+        if (!final_resp.evt) return;
+
+        var payload = final_resp.evt.payload;
+        if (!payload) return;
+
+        data.circ = payload.circ;
+        data.parent_circ = payload.parent_circ;
+        data.hold = payload.hold;
+        data.record = payload.record;
+        data.acp = payload.copy;
+        data.acn = payload.volume ?  payload.volume : payload.copy.call_number();
+        data.au = payload.patron;
+        data.transit = payload.transit;
+        data.status = payload.status;
+        data.message = payload.message;
+        data.title = final_resp.evt.title;
+        data.author = final_resp.evt.author;
+        data.isbn = final_resp.evt.isbn;
+        data.route_to = final_resp.evt.route_to;
+
+        // for checkin, the mbts lives on the main circ
+        if (payload.circ && payload.circ.billable_transaction())
+            data.mbts = payload.circ.billable_transaction().summary();
+
+        // on renewals, the mbts lives on the parent circ
+        if (payload.parent_circ && payload.parent_circ.billable_transaction())
+            data.mbts = payload.parent_circ.billable_transaction().summary();
+
+        if (!data.route_to) {
+            if (data.transit) {
+                data.route_to = data.transit.dest().shortname();
+            } else if (data.acp) {
+                data.route_to = data.acp.location().name();
+            }
+        }
+
+        return final_resp;
+    }
+
+    service.handle_overridable_checkout_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        if (service.auto_override_checkout_events[evt.textcode]) {
+            // user has already opted to override this type
+            // of event.  Re-run the checkout w/ override.
+            options.override = true;
+            return service.checkout(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_NOT_AVAILABLE':
+                return service.copy_not_avail_dialog(evt, params, options);
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'checkout');
+            default: 
+                return service.override_dialog(evt, params, options, 'checkout');
+        }
+    }
+
+    service.handle_overridable_renew_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        // renewal auto-overrides are the same as checkout
+        if (service.auto_override_checkout_events[evt.textcode]) {
+            // user has already opted to override this type
+            // of event.  Re-run the renew w/ override.
+            options.override = true;
+            return service.renew(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'renew');
+            default: 
+                return service.override_dialog(evt, params, options, 'renew');
+        }
+    }
+
+
+    service.handle_overridable_checkin_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        if (options.suppress_checkin_popups
+            && service.checkin_suppress_overrides.indexOf(evt.textcode) > -1) {
+            // Event is suppressed.  Re-run the checkin w/ override.
+            options.override = true;
+            return service.checkin(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'checkin');
+            default: 
+                return service.override_dialog(evt, params, options, 'checkin');
+        }
+    }
+
+
+    service.handle_renew_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        // track the barcode regardless of whether it refers to a copy
+        evt.copy_barcode = params.copy_barcode;
+
+        // Overridable Events
+        if (service.renew_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_renew_event(evt, params, options);
+
+        // Other events
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                return $q.when(final_resp);
+
+            case 'COPY_IN_TRANSIT':
+            case 'PATRON_CARD_INACTIVE':
+            case 'PATRON_INACTIVE':
+            case 'PATRON_ACCOUNT_EXPIRED':
+            case 'CIRC_CLAIMS_RETURNED':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {barcode : params.copy_barcode}
+                );
+
+            case 'PERM_FAILURE':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {permission : evt.ilsperm}
+                );
+
+            default:
+                return service.exit_alert(
+                    egCore.strings.CHECKOUT_FAILED_GENERIC, {
+                        barcode : params.copy_barcode,
+                        textcode : evt.textcode,
+                        desc : evt.desc
+                    }
+                );
+        }
+    }
+
+
+    service.handle_checkout_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        // track the barcode regardless of whether it refers to a copy
+        evt.copy_barcode = params.copy_barcode;
+
+        // Overridable Events
+        if (service.checkout_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_checkout_event(evt, params, options);
+
+        // Other events
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                return $q.when(final_resp);
+
+            case 'ITEM_NOT_CATALOGED':
+                return service.precat_dialog(params, options);
+
+            case 'OPEN_CIRCULATION_EXISTS':
+                return service.circ_exists_dialog(evt, params, options);
+
+            case 'COPY_IN_TRANSIT':
+                return service.copy_in_transit_dialog(evt, params, options);
+
+            case 'PATRON_CARD_INACTIVE':
+            case 'PATRON_INACTIVE':
+            case 'PATRON_ACCOUNT_EXPIRED':
+            case 'CIRC_CLAIMS_RETURNED':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {barcode : params.copy_barcode}
+                );
+
+            case 'PERM_FAILURE':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {permission : evt.ilsperm}
+                );
+
+            default:
+                return service.exit_alert(
+                    egCore.strings.CHECKOUT_FAILED_GENERIC, {
+                        barcode : params.copy_barcode,
+                        textcode : evt.textcode,
+                        desc : evt.desc
+                    }
+                );
+        }
+    }
+
+    // returns a promise resolved with the list of circ mods
+    service.get_circ_mods = function() {
+        if (egCore.env.ccm) 
+            return $q.when(egCore.env.ccm.list);
+
+        return egCore.pcrud.retrieveAll('ccm', null, {atomic : true})
+        .then(function(list) { 
+            egCore.env.absorbList(list, 'ccm');
+            return list;
+        });
+    };
+
+    // returns a promise resolved with the list of noncat types
+    service.get_noncat_types = function() {
+        if (egCore.env.cnct) 
+            return $q.when(egCore.env.cnct.list);
+
+        return egCore.pcrud.search('cnct', 
+            {owning_lib : 
+                egCore.org.fullPath(egCore.auth.user().ws_ou(), true)}, 
+            null, {atomic : true}
+        ).then(function(list) { 
+            egCore.env.absorbList(list, 'cnct');
+            return list;
+        });
+    }
+
+    service.get_staff_penalty_types = function() {
+        if (egCore.env.csp) 
+            return $q.when(egCore.env.csp.list);
+        return egCore.pcrud.search(
+            // id <= 100 are reserved for system use
+            'csp', {id : {'>': 100}}, {}, {atomic : true})
+        .then(function(penalties) {
+            return egCore.env.absorbList(penalties, 'csp').list;
+        });
+    }
+
+    // ideally all of these data should be returned with the response,
+    // but until then, grab what we need.
+    service.flesh_response_data = function(action, evt, params, options) {
+        var promises = [];
+        var payload;
+        if (!evt || !(payload = evt.payload)) return $q.when();
+
+        promises.push(service.flesh_copy_location(payload.copy));
+        if (payload.copy) {
+            promises.push(
+                service.flesh_copy_status(payload.copy)
+
+                .then(function() {
+                    // copy is in transit, but no transit was delivered
+                    // in the payload.  Do this here instead of below to
+                    // ensure consistent copy status fleshiness
+                    if (!payload.transit && payload.copy.status().id() == 6) { // in-transit
+                        return service.find_copy_transit(evt, params, options)
+                        .then(function(trans) {
+                            if (trans) {
+                                trans.source(egCore.org.get(trans.source()));
+                                trans.dest(egCore.org.get(trans.dest()));
+                                payload.transit = trans;
+                            }
+                        })
+                    }
+                })
+            );
+        }
+
+        // local flesh transit
+        if (transit = payload.transit) {
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+        } 
+
+        // TODO: renewal responses should include the patron
+        if (!payload.patron && payload.circ) {
+            promises.push(
+                egCore.pcrud.retrieve('au', payload.circ.usr())
+                .then(function(user) {payload.patron = user})
+            );
+        }
+
+        // extract precat values
+        evt.title = payload.record ? payload.record.title() : 
+            (payload.copy ? payload.copy.dummy_title() : null);
+
+        evt.author = payload.record ? payload.record.author() : 
+            (payload.copy ? payload.copy.dummy_author() : null);
+
+        evt.isbn = payload.record ? payload.record.isbn() : 
+            (payload.copy ? payload.copy.dummy_isbn() : null);
+
+        return $q.all(promises);
+    }
+
+    // fetches the full list of copy statuses
+    service.flesh_copy_status = function(copy) {
+        if (!copy) return $q.when();
+        if (egCore.env.ccs) 
+            return $q.when(copy.status(egCore.env.ccs.map[copy.status()]));
+        return egCore.pcrud.retrieveAll('ccs', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'ccs');
+                copy.status(egCore.env.ccs.map[copy.status()]);
+            }
+        );
+    }
+
+    // there may be *many* copy locations and we may be handling items
+    // for other locations.  Fetch copy locations as-needed and cache.
+    service.flesh_copy_location = function(copy) {
+        if (!copy) return $q.when();
+        if (angular.isObject(copy.location())) return $q.when(copy);
+        if (egCore.env.acpl) {
+            if (egCore.env.acpl.map[copy.location()]) {
+                copy.location(egCore.env.acpl.map[copy.location()]);
+                return $q.when(copy);
+            }
+        } 
+        return egCore.pcrud.retrieve('acpl', copy.location())
+        .then(function(loc) {
+            egCore.env.absorbList([loc], 'acpl'); // append to cache
+            copy.location(loc);
+            return copy;
+        });
+    }
+
+
+    // fetch org unit addresses as needed.
+    service.get_org_addr = function(org_id, addr_type) {
+        var org = egCore.org.get(org_id);
+        var addr_id = org[addr_type]();
+
+        if (egCore.env.aoa && egCore.env.aoa.map[addr_id]) 
+            return $q.when(egCore.env.aoa.map[addr_id]); 
+
+        return egCore.pcrud.retrieve('aoa', addr_id).then(function(addr) {
+            egCore.env.absorbList([addr], 'aoa');
+            return egCore.env.aoa.map[addr_id]; 
+        });
+    }
+
+    service.exit_alert = function(msg, scope) {
+        return egAlertDialog.open(msg, scope).result.then(
+            function() {return $q.reject()});
+    }
+
+    // opens a dialog asking the user if they would like to override
+    // the returned event.
+    service.override_dialog = function(evt, params, options, action) {
+        return $modal.open({
+            templateUrl: './circ/share/t_event_override_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 
+                function($scope, $modalInstance) {
+                $scope.evt = evt;
+                $scope.auto_override = 
+                    service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1;
+                $scope.copy_barcode = params.copy_barcode; // may be null
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function ($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        }).result.then(
+            function() {
+                options.override = true;
+
+                if (action == 'checkin') {
+                    return service.checkin(params, options);
+                }
+
+                // checkout/renew support override-after-first
+                if (service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1)
+                    service.auto_override_checkout_events[evt.textcode] = true;
+
+                return service[action](params, options);
+            }
+        );
+    }
+
+    service.copy_not_avail_dialog = function(evt, params, options) {
+        return $modal.open({
+            templateUrl: './circ/share/t_copy_not_avail_dialog',
+            controller: 
+                       ['$scope','$modalInstance','copyStatus',
+                function($scope , $modalInstance , copyStatus) {
+                $scope.copyStatus = copyStatus;
+                $scope.ok = function() {$modalInstance.close()}
+                $scope.cancel = function() {$modalInstance.dismiss()}
+            }],
+            resolve : {
+                copyStatus : function() {
+                    return egCore.pcrud.retrieve(
+                        'ccs', evt.payload.status());
+                }
+            }
+        }).result.then(
+            function() {
+                options.override = true;
+                return service.checkout(params, options);
+            }
+        );
+    }
+
+    // Opens a dialog allowing the user to fill in the desired non-cat count.
+    // Unlike other dialogs, which kickoff circ actions internally
+    // as a result of events, this dialog does not kick off any circ
+    // actions. It just collects the count and and resolves the promise.
+    //
+    // This assumes the caller has already handled the noncat-type
+    // selection and just needs to collect the count info.
+    service.noncat_dialog = function(params, options) {
+        var noncatMax = 99; // hard-coded max
+        
+        // the caller should presumably have fetched the noncat_types via
+        // our API already, but fetch them again (from cache) to be safe.
+        return service.get_noncat_types().then(function() {
+
+            params.noncat = true;
+            var type = egCore.env.cnct.map[params.noncat_type];
+
+            return $modal.open({
+                templateUrl: './circ/share/t_noncat_dialog',
+                controller: 
+                    ['$scope', '$modalInstance',
+                    function($scope, $modalInstance) {
+                    $scope.focusMe = true;
+                    $scope.type = type;
+                    $scope.count = 1;
+                    $scope.noncatMax = noncatMax;
+                    $scope.ok = function(count) { $modalInstance.close(count) }
+                    $scope.cancel = function ($event) { 
+                        $modalInstance.dismiss() 
+                        $event.preventDefault();
+                    }
+                }],
+            }).result.then(
+                function(count) {
+                    if (count && count > 0 && count <= noncatMax) { 
+                        // NOTE: in Chrome, form validation ensure a valid number
+                        params.noncat_count = count;
+                        return $q.when(params);
+                    } else {
+                        return $q.reject();
+                    }
+                }
+            );
+        });
+    }
+
+    // Opens a dialog allowing the user to fill in pre-cat copy info.
+    service.precat_dialog = function(params, options) {
+
+        return $modal.open({
+            templateUrl: './circ/share/t_precat_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 'circMods',
+                function($scope, $modalInstance, circMods) {
+                $scope.focusMe = true;
+                $scope.precatArgs = {
+                    copy_barcode : params.copy_barcode,
+                    circ_modifier : circMods.length ? circMods[0].code() : null
+                };
+                $scope.circModifiers = circMods;
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }],
+            resolve : {
+                circMods : function() { 
+                    return service.get_circ_mods();
+                }
+            }
+        }).result.then(
+            function(args) {
+                if (!args || !args.dummy_title) return $q.reject();
+                angular.forEach(args, function(val, key) {params[key] = val});
+                params.precat = true;
+                return service.checkout(params, options);
+            }
+        );
+    }
+
+    // find the open transit for the given copy barcode; flesh the org
+    // units locally.
+    service.find_copy_transit = function(evt, params, options) {
+
+        if (evt && evt.payload && evt.payload.transit)
+            return $q.when(evt.payload.transit);
+
+         return egCore.pcrud.search('atc',
+            {   dest_recv_time : null},
+            {   flesh : 1, 
+                flesh_fields : {atc : ['target_copy']},
+                join : {
+                    acp : {
+                        filter : {
+                            barcode : params.copy_barcode,
+                            deleted : 'f'
+                        }
+                    }
+                },
+                limit : 1,
+                order_by : {atc : 'source_send_time desc'}, 
+            }
+        ).then(function(transit) {
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+            return transit;
+        });
+    }
+
+    service.copy_in_transit_dialog = function(evt, params, options) {
+        return $modal.open({
+            templateUrl: './circ/share/t_copy_in_transit_dialog',
+            controller: 
+                       ['$scope','$modalInstance','transit',
+                function($scope , $modalInstance , transit) {
+                $scope.transit = transit;
+                $scope.ok = function() { $modalInstance.close(transit) }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }],
+            resolve : {
+                // fetch the conflicting open transit w/ fleshed copy
+                transit : function() {
+                    return service.find_copy_transit(evt, params, options);
+                }
+            }
+        }).result.then(
+            function(transit) {
+                // user chose to abort the transit then checkout
+                return service.abort_transit(transit.id())
+                .then(function() {
+                    return service.checkout(params, options);
+                });
+            }
+        );
+    }
+
+    service.abort_transit = function(transit_id) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.transit.abort',
+            egCore.auth.token(), {transitid : transit_id}
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt);
+                return $q.reject();
+            }
+            return $q.when();
+        });
+    }
+
+    service.last_copy_circ = function(copy_id) {
+        return egCore.pcrud.search('circ', 
+            {target_copy : copy_id},
+            {order_by : {circ : 'xact_start desc' }, limit : 1}
+        );
+    }
+
+    service.circ_exists_dialog = function(evt, params, options) {
+
+        var openCirc = evt.payload.old_circ;
+        var sameUser = openCirc.usr() == params.patron_id;
+        
+        return $modal.open({
+            templateUrl: './circ/share/t_circ_exists_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+                $scope.circDate = openCirc.xact_start();
+                $scope.sameUser = sameUser;
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault(); // form, avoid calling ok();
+                }
+            }]
+        }).result.then(
+            function() {
+                
+                return service.checkin(
+                    {barcode : params.copy_barcode, noop : true}
+                ).then(function(checkin_resp) {
+                    if (checkin_resp.evt.textcode == 'SUCCESS') {
+                        return service.checkout(params, options);
+                    } else {
+                        alert(egCore.evt.parse(evt));
+                        return $q.reject();
+                    }
+                });
+            }
+        );
+    }
+
+    service.batch_backdate = function(circ_ids, backdate) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.post_checkin_backdate.batch',
+            egCore.auth.token(), circ_ids, backdate);
+    }
+
+    service.backdate_dialog = function(circ_ids) {
+        return $modal.open({
+            templateUrl: './circ/share/t_backdate_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                var today = new Date();
+                $scope.dialog = {
+                    num_circs : circ_ids.length,
+                    num_processed : 0,
+                    backdate : today
+                }
+
+                $scope.$watch('dialog.backdate', function(newval) {
+                    if (newval && newval > today) 
+                        $scope.dialog.backdate = today;
+                });
+
+
+                $scope.cancel = function() { 
+                    $modalInstance.dismiss();
+                }
+
+                $scope.ok = function() { 
+
+                    var bd = $scope.dialog.backdate.toISOString().replace(/T.*/,'');
+                    service.batch_backdate(circ_ids, bd)
+                    .then(
+                        function() { // on complete
+                            $modalInstance.close({backdate : bd});
+                        },
+                        null,
+                        function(resp) { // on response
+                            console.debug('backdate returned ' + resp);
+                            if (resp == '1') {
+                                $scope.num_processed++;
+                            } else {
+                                console.error(egCore.evt.parse(resp));
+                            }
+                        }
+                    );
+                }
+            }]
+        }).result;
+    }
+
+    service.mark_claims_returned = function(barcode, date, override) {
+
+        var method = 'open-ils.circ.circulation.set_claims_returned';
+        if (override) method += '.override';
+
+        console.debug('claims returned ' + method);
+
+        return egCore.net.request(
+            'open-ils.circ', method, egCore.auth.token(),
+            {barcode : barcode, backdate : date})
+
+        .then(function(resp) {
+
+            if (resp == 1) { // success
+                console.debug('claims returned succeeded for ' + barcode);
+                return barcode;
+
+            } else if (evt = egCore.evt.parse(resp)) {
+                console.debug('claims returned failed: ' + evt.toString());
+
+                if (evt.textcode == 'PATRON_EXCEEDS_CLAIMS_RETURN_COUNT') {
+                    // TODO check perms before offering override option?
+
+                    if (override) return;// just to be safe
+
+                    return egConfirmDialog.open(
+                        egCore.strings.TOO_MANY_CLAIMS_RETURNED, '', {}
+                    ).result.then(function() {
+                        return service.mark_claims_returned(barcode, date, true);
+                    });
+                }
+
+                if (evt.textcode == 'PERM_FAILURE') {
+                    console.error('claims returned permission denied')
+                    // TODO: auth override dialog?
+                }
+            }
+        });
+    }
+
+    service.mark_claims_returned_dialog = function(copy_barcodes) {
+        if (!copy_barcodes.length) return;
+
+        return $modal.open({
+            templateUrl: './circ/share/t_mark_claims_returned_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                var today = new Date();
+                $scope.args = {
+                    barcodes : copy_barcodes,
+                    date : today
+                };
+
+                $scope.$watch('args.date', function(newval) {
+                    if (newval && newval > today) 
+                        $scope.args.backdate = today;
+                });
+
+                $scope.cancel = function() {$modalInstance.dismiss()}
+                $scope.ok = function() { 
+
+                    var date = $scope.args.date.toISOString().replace(/T.*/,'');
+
+                    var deferred = $q.defer();
+
+                    // serialize the action on each barcode so that the 
+                    // caller will never see multiple alerts at the same time.
+                    function mark_one() {
+                        var bc = copy_barcodes.pop();
+                        if (!bc) {
+                            deferred.resolve();
+                            $modalInstance.close();
+                            return;
+                        }
+
+                        // finally -> continue even when one fails
+                        service.mark_claims_returned(bc, date)
+                        .finally(function(barcode) {
+                            if (barcode) deferred.notify(barcode);
+                            mark_one();
+                        });
+                    }
+                    mark_one(); // kick it off
+                    return deferred.promise;
+                }
+            }]
+        }).result;
+    }
+
+    // serially checks in each barcode with claims_never_checked_out set
+    // returns promise, notified on each barcode, resolved after all
+    // checkins are complete.
+    service.mark_claims_never_checked_out = function(barcodes) {
+        if (!barcodes.length) return;
+
+        var deferred = $q.defer();
+        egConfirmDialog.open(
+            egCore.strings.MARK_NEVER_CHECKED_OUT, '', {barcodes : barcodes}
+
+        ).result.then(function() {
+            function mark_one() {
+                var bc = barcodes.pop();
+
+                if (!bc) { // all done
+                    deferred.resolve();
+                    return;
+                }
+
+                service.checkin(
+                    {claims_never_checked_out : true, copy_barcode : bc})
+                .finally(function() { 
+                    deferred.notify(bc);
+                    mark_one();
+                })
+            }
+            mark_one();
+        });
+
+        return deferred.promise;
+    }
+
+    service.mark_damaged = function(copy_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.MARK_DAMAGED_CONFIRM, '',
+            {   num_items : copy_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(copy_ids, function(copy_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.mark_item_damaged',
+                        egCore.auth.token(), copy_id
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('mark damaged failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+    service.mark_missing = function(copy_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.MARK_MISSING_CONFIRM, '',
+            {   num_items : copy_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(copy_ids, function(copy_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.mark_item_missing',
+                        egCore.auth.token(), copy_id
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('mark missing failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+
+
+    // Mark circulations as lost via copy barcode.  As each item is 
+    // processed, the returned promise is notified of the barcode.
+    // No confirmation dialog is presented.
+    service.mark_lost = function(copy_barcodes) {
+        var deferred = $q.defer();
+        var promises = [];
+
+        angular.forEach(copy_barcodes, function(barcode) {
+            promises.push(
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.circulation.set_lost',
+                    egCore.auth.token(), {barcode : barcode}
+                ).then(function(resp) {
+                    if (evt = egCore.evt.parse(resp)) {
+                        console.error("Mark lost failed: " + evt.toString());
+                        return;
+                    }
+                    // inform the caller as each item is processed
+                    deferred.notify(barcode);
+                })
+            );
+        });
+
+        $q.all(promises).then(function() {deferred.resolve()});
+        return deferred.promise;
+    }
+
+    service.abort_transits = function(transit_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.ABORT_TRANSIT_CONFIRM, '',
+            {   num_transits : transit_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(transit_ids, function(transit_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.transit.abort',
+                        egCore.auth.token(), {transitid : transit_id}
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('abort transit failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+
+
+    // alert when copy location alert_message is set.
+    // This does not affect processing, it only produces a click-through
+    service.handle_checkin_loc_alert = function(evt, params, options) {
+
+        var copy = evt && evt.payload ? evt.payload.copy : null;
+
+        if (copy && !options.suppress_checkin_popups
+            && copy.location().checkin_alert() == 't') {
+
+            return egAlertDialog.open(
+                egCore.strings.LOCATION_ALERT_MSG, {copy : copy}).result;
+        }
+
+        return $q.when();
+    }
+
+    service.handle_checkin_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        var copy, hold, transit;
+        if (evt.payload) {
+            copy = evt.payload.copy;
+            hold = evt.payload.hold;
+            transit = evt.payload.transit;
+        }
+
+        // track the barcode regardless of whether it's valid
+        evt.copy_barcode = params.copy_barcode;
+
+        console.debug('checkin event ' + evt.textcode);
+
+        if (service.checkin_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_checkin_event(evt, params, options);
+
+        switch (evt.textcode) {
+
+            case 'SUCCESS':
+            case 'NO_CHANGE':
+
+                switch(Number(copy.status().id())) {
+
+                    case 0: /* AVAILABLE */                                        
+                    case 4: /* MISSING */                                          
+                    case 7: /* RESHELVING */ 
+
+                        // see if the copy location requires an alert
+                        return service.handle_checkin_loc_alert(evt, params, options)
+                        .then(function() {return final_resp});
+
+                    case 8: /* ON HOLDS SHELF */
+
+                        
+                        if (hold) {
+
+                            if (hold.pickup_lib() == egCore.auth.user().ws_ou()) {
+                                // inform user if the item is on the local holds shelf
+                            
+                                evt.route_to = egCore.strings.ROUTE_TO_HOLDS_SHELF;
+                                return service.route_dialog(
+                                    './circ/share/t_hold_shelf_dialog', 
+                                    evt, params, options
+                                ).then(function() { return final_resp });
+
+                            } else {
+                                // normally, if the hold was on the shelf at a 
+                                // different location, it would be put into 
+                                // transit, resulting in a ROUTE_ITEM event.
+                                return $q.when(final_resp);
+                            }
+                        } else {
+
+                            console.error('checkin: item on holds shelf, '
+                                + 'but hold info not returned from checkin');
+                            return $q.when(final_resp);
+                        }
+
+                    case 11: /* CATALOGING */
+                        evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+                        return $q.when(final_resp);
+
+                    case 15: /* ON_RESERVATION_SHELF */
+                        // TODO: show booking reservation dialog
+                        return $q.when(final_resp);
+
+                    default:
+                        console.error('Unhandled checkin copy status: ' 
+                            + copy.status().id() + ' : ' + copy.status().name());
+                        return $q.when(final_resp);
+                }
+                
+            case 'ROUTE_ITEM':
+                return service.route_dialog(
+                    './circ/share/t_transit_dialog', 
+                    evt, params, options
+                ).then(function() { return final_resp });
+
+            case 'ASSET_COPY_NOT_FOUND':
+                return egAlertDialog.open(
+                    egCore.strings.UNCAT_ALERT_DIALOG, params)
+                    .result.then(function() {return final_resp});
+
+            case 'ITEM_NOT_CATALOGED':
+                evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+                if (options.no_precat_alert) 
+                    return $q.when(final_resp);
+                return egAlertDialog.open(
+                    egCore.strings.PRECAT_CHECKIN_MSG, params)
+                    .result.then(function() {return final_resp});
+
+            default:
+                console.warn('unhandled checkin response : ' + evt.textcode);
+                return $q.when(final_resp);
+        }
+    }
+
+    // collect transit, address, and hold info that's not already
+    // included in responses.
+    service.collect_route_data = function(tmpl, evt, params, options) {
+        var promises = [];
+        var data = {};
+
+        if (evt.org && !tmpl.match(/hold_shelf/)) {
+            promises.push(
+                service.get_org_addr(evt.org, 'holds_address')
+                .then(function(addr) { data.address = addr })
+            );
+        }
+
+        if (evt.payload.hold) {
+            promises.push(
+                egCore.pcrud.retrieve('au', 
+                    evt.payload.hold.usr(), {
+                        flesh : 1,
+                        flesh_fields : {'au' : ['card']}
+                    }
+                ).then(function(patron) {data.patron = patron})
+            );
+        }
+
+        if (!tmpl.match(/hold_shelf/)) {
+            promises.push(
+                service.find_copy_transit(evt, params, options)
+                .then(function(trans) {data.transit = trans})
+            );
+        }
+
+        return $q.all(promises).then(function() { return data });
+    }
+
+    service.route_dialog = function(tmpl, evt, params, options) {
+
+        return service.collect_route_data(tmpl, evt, params, options)
+        .then(function(data) {
+            
+            // All actions flow from the print data
+
+            var print_context = {
+                copy : egCore.idl.toHash(evt.payload.copy),
+                title : evt.title,
+                author : evt.author
+            }
+
+            if (data.transit) {
+                // route_dialog includes the "route to holds shelf" 
+                // dialog, which has no transit
+                print_context.transit = egCore.idl.toHash(data.transit);
+                print_context.dest_address = egCore.idl.toHash(data.address);
+                print_context.dest_location =
+                    egCore.idl.toHash(egCore.org.get(data.transit.dest()));
+            }
+
+            if (data.patron) {
+                print_context.hold = egCore.idl.toHash(evt.payload.hold);
+                print_context.patron = egCore.idl.toHash(data.patron);
+            }
+
+            function print_transit() {
+                var template = data.transit ? 
+                    (data.patron ? 'hold_transit_slip' : 'transit_slip') :
+                    'hold_shelf_slip';
+
+                return egCore.print.print({
+                    context : 'default', 
+                    template : template, 
+                    scope : print_context
+                });
+            }
+
+            // when auto-print is on, skip the dialog and go straight
+            // to printing.
+            if (options.auto_print_holds_transits) 
+                return print_transit();
+
+            return $modal.open({
+                templateUrl: tmpl,
+                controller: [
+                            '$scope','$modalInstance',
+                    function($scope , $modalInstance) {
+
+                    $scope.today = new Date();
+
+                    // copy the print scope into the dialog scope
+                    angular.forEach(print_context, function(val, key) {
+                        $scope[key] = val;
+                    });
+
+                    $scope.ok = function() {$modalInstance.close()}
+
+                    $scope.print = function() { 
+                        $modalInstance.close();
+                        print_transit();
+                    }
+                }]
+
+            }).result;
+        });
+    }
+
+    // action == what action to take if the user confirms the alert
+    service.copy_alert_dialog = function(evt, params, options, action) {
+        return egConfirmDialog.open(
+            egCore.strings.COPY_ALERT_MSG_DIALOG_TITLE, 
+            evt.payload,  // payload == alert message text
+            {   copy_barcode : params.copy_barcode,
+                ok : function() {},
+                cancel : function() {}
+            }
+        ).result.then(function() {
+            options.override = true;
+            return service[action](params, options);
+        });
+    }
+
+    // check the barcode.  If it's no good, show the warning dialog
+    // Resolves on success, rejected on error
+    service.test_barcode = function(bc) {
+
+        var ok = service.check_barcode(bc);
+        if (ok) return $q.when();
+
+        return $modal.open({
+            templateUrl: './circ/share/t_bad_barcode_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 
+                function($scope, $modalInstance) {
+                $scope.barcode = bc;
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result;
+    }
+
+    // check() and checkdigit() copied directly 
+    // from chrome/content/util/barcode.js
+
+    service.check_barcode = function(bc) {
+        if (bc != Number(bc)) return false;
+        bc = bc.toString();
+        // "16.00" == Number("16.00"), but the . is bad.
+        // Throw out any barcode that isn't just digits
+        if (bc.search(/\D/) != -1) return false;
+        var last_digit = bc.substr(bc.length-1);
+        var stripped_barcode = bc.substr(0,bc.length-1);
+        return service.barcode_checkdigit(stripped_barcode).toString() == last_digit;
+    }
+
+    service.barcode_checkdigit = function(bc) {
+        var reverse_barcode = bc.toString().split('').reverse();
+        var check_sum = 0; var multiplier = 2;
+        for (var i = 0; i < reverse_barcode.length; i++) {
+            var digit = reverse_barcode[i];
+            var product = digit * multiplier; product = product.toString();
+            var temp_sum = 0;
+            for (var j = 0; j < product.length; j++) {
+                temp_sum += Number( product[j] );
+            }
+            check_sum += Number( temp_sum );
+            multiplier = ( multiplier == 2 ? 1 : 2 );
+        }
+        check_sum = check_sum.toString();
+        var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10;
+        var check_digit = next_multiple_of_10 - Number(check_sum);
+        if (check_digit == 10) check_digit = 0;
+        return check_digit;
+    }
+
+    service.create_penalty = function(user_id) {
+        return $modal.open({
+            templateUrl: './circ/share/t_new_message_dialog',
+            controller: 
+                   ['$scope','$modalInstance','staffPenalties',
+            function($scope , $modalInstance , staffPenalties) {
+                $scope.focusNote = true;
+                $scope.penalties = staffPenalties;
+                $scope.args = {penalty : 21}; // default to Note
+                $scope.setPenalty = function(id) {
+                    args.penalty = id;
+                }
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }],
+            resolve : { staffPenalties : service.get_staff_penalty_types }
+        }).result.then(
+            function(args) {
+                var pen = new egCore.idl.ausp();
+                pen.usr(user_id);
+                pen.org_unit(egCore.auth.user().ws_ou());
+                pen.note(args.note);
+                pen.standing_penalty(args.penalty);
+                pen.staff(egCore.auth.user().id());
+                pen.set_date('now');
+                return egCore.pcrud.create(pen);
+            }
+        );
+    }
+
+    // assumes, for now anyway,  penalty type is fleshed onto usr_penalty.
+    service.edit_penalty = function(usr_penalty) {
+        return $modal.open({
+            templateUrl: './circ/share/t_new_message_dialog',
+            controller: 
+                   ['$scope','$modalInstance','staffPenalties',
+            function($scope , $modalInstance , staffPenalties) {
+                $scope.focusNote = true;
+                $scope.penalties = staffPenalties;
+                $scope.args = {
+                    penalty : usr_penalty.standing_penalty().id(),
+                    note : usr_penalty.note()
+                }
+                $scope.setPenalty = function(id) { args.penalty = id; }
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }],
+            resolve : { staffPenalties : service.get_staff_penalty_types }
+        }).result.then(
+            function(args) {
+                usr_penalty.note(args.note);
+                usr_penalty.standing_penalty(args.penalty);
+                return egCore.pcrud.update(usr_penalty);
+            }
+        );
+    }
+
+    return service;
+
+}]);
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/holds.js b/Open-ILS/web/js/ui/default/staff/circ/services/holds.js
new file mode 100644
index 0000000..161722c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/circ/services/holds.js
@@ -0,0 +1,605 @@
+/**
+ * Holds, yo
+ */
+
+angular.module('egCoreMod')
+
+.factory('egHolds',
+
+       ['$modal','$q','egCore','egAlertDialog','egConfirmDialog','egAlertDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog , egAlertDialog) {
+
+    var service = {};
+
+    service.fetch_holds = function(hold_ids) {
+        var deferred = $q.defer();
+
+        // FIXME: large batches using .authoritative result in many 
+        // stranded cstore backends on the server.  Needs investigation.
+        // For now, collect holds in a series of small batches.
+        // Fetch them serially both to avoid the above problem and
+        // to maintain order.
+        var batch_size = 5;
+        var index = 0;
+
+        function one_batch() {
+            var ids = hold_ids.slice(index, index + batch_size)
+                .filter(function(id) {return Boolean(id)}) // avoid nulls
+
+            console.debug('egHolds.fetch_holds => ' + ids);
+            index += batch_size;
+
+            if (!ids.length) {
+                deferred.resolve();
+                return;
+            }
+
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.hold.details.batch.retrieve.authoritative',
+                egCore.auth.token(), ids
+
+            ).then(
+                one_batch,  // kick off the next batch
+                null, 
+                function(hold_data) {
+                    var hold = hold_data.hold;
+                    hold_data.id = hold.id();
+                    service.local_flesh(hold_data);
+                    deferred.notify(hold_data);
+                }
+            );
+        }
+
+        one_batch(); // kick it off
+        return deferred.promise;
+    }
+
+
+    service.cancel_holds = function(hold_ids) {
+       
+        return $modal.open({
+            templateUrl : './circ/share/t_cancel_hold_dialog',
+            controller : 
+                ['$scope', '$modalInstance', 'cancel_reasons',
+                function($scope, $modalInstance, cancel_reasons) {
+                    $scope.args = {
+                        cancel_reasons : cancel_reasons,
+                        num_holds : hold_ids.length
+                    };
+                    
+                    $scope.cancel = function($event) {
+                        $modalInstance.dismiss();
+                        $event.preventDefault();
+                    }
+
+                    $scope.ok = function() {
+
+                        function cancel_one() {
+                            var hold_id = hold_ids.pop();
+                            if (!hold_id) {
+                                $modalInstance.close();
+                                return;
+                            }
+                            egCore.net.request(
+                                'open-ils.circ', 'open-ils.circ.hold.cancel',
+                                egCore.auth.token(), hold_id,
+                                $scope.args.cancel_reason.id(), 
+                                $scope.args.note
+                            ).then(function(resp) {
+                                if (evt = egCore.evt.parse(resp)) {
+                                    console.error('unable to cancel hold: ' 
+                                        + evt.toString());
+                                }
+                                cancel_one();
+                            });
+                        }
+
+                        cancel_one();
+                    }
+                }
+            ],
+            resolve : {
+                cancel_reasons : function() {
+                    return service.get_cancel_reasons();
+                }
+            }
+        }).result;
+    }
+
+    service.get_cancel_reasons = function() {
+        if (egCore.env.ahrcc) return $q.when(egCore.env.ahrcc.list);
+        return egCore.pcrud.retrieveAll('ahrcc', {}, {atomic : true})
+        .then(function(list) { return egCore.env.absorbList(list, 'ahrcc').list });
+    }
+
+    // Updates a batch of holds, notifies on each response.
+    // new_values = array of hashes describing values to change,
+    // including the id of the hold to change.
+    // e.g. {id : 1, mint_condition : true}
+    service.update_holds = function(new_values) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.update.batch',
+            egCore.auth.token(), null, new_values);
+    }
+
+    service.set_copy_quality = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_copy_quality_dialog',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+
+                    function update(val) {
+                        var vals = hold_ids.map(function(hold_id) {
+                            return {id : hold_id, mint_condition : val}})
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.good = function() { update(true) }
+                    $scope.any = function() { update(false) }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ]
+        }).result;
+    }
+
+    service.edit_pickup_lib = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_edit_pickup_lib',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.args = {}
+                    $scope.ok = function() { 
+                        var vals = hold_ids.map(function(hold_id) {
+                            return {
+                                id : hold_id, 
+                                pickup_lib : $scope.args.org_unit.id()
+                            }
+                        });
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ]
+        }).result;
+    }
+
+    service.get_sms_carriers = function() {
+        if (egCore.env.csc) return $q.when(egCore.env.csc.list);
+        return egCore.pcrud.retrieveAll('csc', {}, {atomic : true})
+        .then(function(list) { return egCore.env.absorbList(list, 'csc').list });
+    }
+
+    service.edit_notify_prefs = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_notification_prefs',
+            controller : 
+                ['$scope', '$modalInstance', 'sms_carriers',
+                function($scope, $modalInstance, sms_carriers) {
+                    $scope.args = {}
+                    $scope.sms_carriers = sms_carriers;
+                    $scope.num_holds = hold_ids.length;
+                    $scope.ok = function() { 
+
+                        var vals = hold_ids.map(function(hold_id) {
+                            var val = {id : hold_id};
+                            angular.forEach(
+                                ['email', 'phone', 'sms'],
+                                function(type) {
+                                    var key = type + '_notify';
+                                    if ($scope.args['update_' + key]) 
+                                        val[key] = $scope.args[key];
+                                }
+                            );
+                            if ($scope.args.update_sms_carrier)
+                                val.sms_carrier = $scope.args.sms_carrier.id();
+                            return val;
+                        });
+
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ],
+            resolve : {
+                sms_carriers : service.get_sms_carriers
+            }
+        }).result;
+    }
+
+    service.edit_dates = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+
+        // collects the fields from the dialog the user wishes to modify
+        function relay_to_update(modal_scope) {
+            var vals = hold_ids.map(function(hold_id) {
+                var val = {id : hold_id};
+                angular.forEach(
+                    ['thaw_date', 'request_time', 'expire_time', 'shelf_expire_time'], 
+                    function(field) {
+                        if (modal_scope.args['modify_' + field]) 
+                            val[field] = modal_scope.args[field].toISOString();
+                    }
+                );
+
+                return val;
+            });
+
+            console.log(JSON.stringify(vals,null,2));
+            return service.update_holds(vals);
+        }
+
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_dates',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    var today = new Date();
+                    $scope.args = {
+                        thaw_date : today,
+                        request_time : today,
+                        expire_time : today,
+                        shelf_expire_time : today
+                    }
+                    $scope.num_holds = hold_ids.length;
+                    $scope.ok = function() { 
+                        relay_to_update($scope).then($modalInstance.close);
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ],
+        }).result;
+    }
+
+    service.update_field_with_confirm = function(hold_ids, msg_key, field, value) {
+        if (!hold_ids.length) return $q.when();
+
+        return egConfirmDialog.open(
+            egCore.strings[msg_key], '', {num_holds : hold_ids.length})
+        .result.then(function() {
+
+            var vals = hold_ids.map(function(hold_id) {
+                val = {id : hold_id};
+                val[field] = value;
+                return val;
+            });
+            return service.update_holds(vals);
+        });
+    }
+
+    service.suspend_holds = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'SUSPEND_HOLDS', 'frozen', true);
+    }
+
+    service.activate_holds = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'ACTIVATE_HOLDS', 'frozen', false);
+    }
+
+    service.set_top_of_queue = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'SET_TOP_OF_QUEUE', 'cut_in_line', true);
+    }
+
+    service.clear_top_of_queue = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'CLEAR_TOP_OF_QUEUE', 'cut_in_line', null);
+    }
+
+    service.transfer_to_marked_title = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+
+        var bib_id = egCore.hatch.getLocalItem(
+            'eg.circ.hold.title_transfer_target');
+
+        if (!bib_id) {
+            // no target marked
+            return egAlertDialog.open(
+                egCore.strings.NO_HOLD_TRANSFER_TITLE_MARKED).result;
+        }
+
+        return egConfirmDialog.open(
+            egCore.strings.TRANSFER_HOLD_TO_TITLE, '', {
+                num_holds : hold_ids.length,
+                bib_id : bib_id
+            }
+        ).result.then(function() {
+            return egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.hold.change_title.specific_holds',
+                egCore.auth.token(), bib_id, hold_ids);
+        });
+    }
+
+    // serially retargets each hold
+    service.retarget = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        var deferred = $q.defer();
+
+        egConfirmDialog.open(
+            egCore.strings.RETARGET_HOLDS, '', 
+            {hold_ids : hold_ids.join(',')}
+
+        ).result.then(function() {
+
+            function do_one() {
+                var hold_id = hold_ids.pop();
+                if (!hold_id) {
+                    deferred.resolve();
+                    return;
+                }
+
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.hold.reset',
+                    egCore.auth.token(), hold_id).finally(do_one);
+            }
+
+            do_one(); // kick it off
+        });
+
+        return deferred.promise;
+    }
+
+    // fleshes orgs, etc. for hold data blobs retrieved from
+    // open-ils.circ.hold.details[.batch].retrieve
+    service.local_flesh = function(hold_data) {
+
+        hold_data.status_string = 
+            egCore.strings['HOLD_STATUS_' + hold_data.status] 
+            || hold_data.status;
+
+        var hold = hold_data.hold;
+        hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+        hold.current_shelf_lib(egCore.org.get(hold.current_shelf_lib()));
+        hold_data.id = hold.id();
+
+        // current_copy is not always fleshed in the API
+        if (hold.current_copy() && typeof hold.current_copy() != 'object')
+            hold.current_copy(hold_data.copy);
+    }
+
+    return service;
+}])
+
+/**  
+ * Action handlers for the common Hold grid UI.
+ * These generally scrub the data for valid input then pass the
+ * holds / copies / etc. off to the relevant action in egHolds or egCirc.
+ *
+ * Caller must apply a reset_page function, which is called after 
+ * most actionis are performed.
+ */
+.factory('egHoldGridActions', 
+       ['$window','$location','egCore','egHolds','egCirc',
+function($window , $location , egCore , egHolds , egCirc) {
+    
+    var service = {};
+
+    service.refresh = function() {
+        console.error('egHoldGridActions.refresh not defined!');
+    }
+
+    service.cancel_hold = function(items) {
+        var hold_ids = items.filter(function(item) {
+            return !item.hold.cancel_time();
+        }).map(function(item) {return item.hold.id()});
+
+        return egHolds.cancel_holds(hold_ids).then(service.refresh);
+    }
+
+    // jump to circ list for either 1) the targeted copy or
+    // 2) the hold target copy for copy-level holds
+    service.show_recent_circs = function(items) {
+        if (items.length && (copy = items[0].copy)) {
+            var url = $location.path(
+                '/cat/item/' + copy.id() + '/circ_list').absUrl();
+            $window.open(url, '_blank').focus();
+        }
+    }
+
+    function generic_update(items, action) {
+        if (!items.length) return $q.when();
+        var hold_ids = items.map(function(item) {return item.hold.id()});
+        return egHolds[action](hold_ids).then(service.refresh);
+    }
+
+    service.set_copy_quality = function(items) {
+        generic_update(items, 'set_copy_quality'); }
+    service.edit_pickup_lib = function(items) {
+        generic_update(items, 'edit_pickup_lib'); }
+    service.edit_notify_prefs = function(items) {
+        generic_update(items, 'edit_notify_prefs'); }
+    service.edit_dates = function(items) {
+        generic_update(items, 'edit_dates'); }
+    service.suspend = function(items) {
+        generic_update(items, 'suspend_holds'); }
+    service.activate = function(items) {
+        generic_update(items, 'activate_holds'); }
+    service.set_top_of_queue = function(items) {
+        generic_update(items, 'set_top_of_queue'); }
+    service.clear_top_of_queue = function(items) {
+        generic_update(items, 'clear_top_of_queue'); }
+    service.transfer_to_marked_title = function(items) {
+        generic_update(items, 'transfer_to_marked_title'); }
+
+    service.mark_damaged = function(items) {
+        var copy_ids = items
+            .filter(function(item) { return Boolean(item.copy) })
+            .map(function(item) { return item.copy.id() });
+        if (copy_ids.length) 
+            egCirc.mark_damaged(copy_ids).then(service.refresh);
+    }
+
+    service.mark_missing = function(items) {
+        var copy_ids = items
+            .filter(function(item) { return Boolean(item.copy) })
+            .map(function(item) { return item.copy.id() });
+        if (copy_ids.length) 
+            egCirc.mark_missing(copy_ids).then(service.refresh);
+    }
+
+    service.retarget = function(items) {
+        var hold_ids = items.map(function(item) { return item.hold.id() });
+        egHolds.retarget(hold_ids).then(service.refresh);
+    }
+
+    return service;
+}])
+
+/**
+ * Hold details interface 
+ */
+.directive('egHoldDetails', function() {
+    return {
+        restrict : 'AE',
+        templateUrl : './circ/share/t_hold_details',
+        scope : {
+            holdId : '=',
+            // if set, called whenever hold details are retrieved.  The
+            // argument is the hold blob returned from hold.details.retrieve
+            holdRetrieved : '=',
+            showPatron : '='
+        },
+        controller : [
+                    '$scope','$modal','egCore','egHolds','egCirc',
+            function($scope , $modal , egCore , egHolds , egCirc) {
+
+                function draw() {
+                    if (!$scope.holdId) return;
+
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.hold.details.retrieve.authoritative',
+                        egCore.auth.token(), $scope.holdId
+
+                    ).then(function(hold_data) { 
+                        egHolds.local_flesh(hold_data);
+    
+                        angular.forEach(hold_data, 
+                            function(val, key) { $scope[key] = val });
+
+                        // fetch + flesh the cancel_cause if needed
+                        if ($scope.hold.cancel_time()) {
+                            egHolds.get_cancel_reasons().then(function() {
+                                // egHolds caches the causes in egEnv
+                                $scope.hold.cancel_cause(
+                                    egCore.env.ahrcc.map[$scope.hold.cancel_cause()]);
+                            })
+                        }
+
+                        if ($scope.hold.current_copy()) {
+                            egCirc.flesh_copy_location($scope.hold.current_copy());
+                        }
+
+                        if ($scope.holdRetrieved)
+                            $scope.holdRetrieved(hold_data);
+
+                    });
+                }
+
+                $scope.show_notify_tab = function() {
+                    $scope.detail_tab = 'notify';
+                    egCore.pcrud.search('ahn',
+                        {hold : $scope.hold.id()}, 
+                        {flesh : 1, flesh_fields : {ahn : ['notify_staff']}}, 
+                        {atomic : true}
+                    ).then(function(nots) {
+                        $scope.hold.notifications(nots);
+                    });
+                }
+
+                $scope.delete_note = function(note) {
+                    egCore.pcrud.remove(note).then(function() {
+                        // remove the deleted note from the locally fleshed notes
+                        $scope.hold.notes(
+                            $scope.hold.notes().filter(function(n) {
+                                return n.id() != note.id()
+                            })
+                        );
+                    });
+                }
+
+                $scope.new_note = function() {
+                    return $modal.open({
+                        templateUrl : './circ/share/t_hold_note_dialog',
+                        controller : 
+                            ['$scope', '$modalInstance',
+                            function($scope, $modalInstance) {
+                                $scope.args = {};
+                                $scope.ok = function() {
+                                    $modalInstance.close($scope.args)
+                                },
+                                $scope.cancel = function($event) {
+                                    $modalInstance.dismiss();
+                                    $event.preventDefault();
+                                }
+                            }
+                        ]
+                    }).result.then(function(args) {
+                        var note = new egCore.idl.ahrn();
+                        note.hold($scope.hold.id());
+                        note.staff(true);
+                        note.slip(args.slip);
+                        note.pub(args.pub); 
+                        note.title(args.title);
+                        note.body(args.body);
+                        return egCore.pcrud.create(note).then(function(n) {
+                            $scope.hold.notes().push(n);
+                        });
+                    });
+                }
+
+                $scope.new_notification = function() {
+                    return $modal.open({
+                        templateUrl : './circ/share/t_hold_notification_dialog',
+                        controller : 
+                            ['$scope', '$modalInstance',
+                            function($scope, $modalInstance) {
+                                $scope.args = {};
+                                $scope.ok = function() {
+                                    $modalInstance.close($scope.args)
+                                },
+                                $scope.cancel = function($event) {
+                                    $modalInstance.dismiss();
+                                    $event.preventDefault();
+                                }
+                            }
+                        ]
+                    }).result.then(function(args) {
+                        var note = new egCore.idl.ahn();
+                        note.hold($scope.hold.id());
+                        note.method(args.method);
+                        note.note(args.note);
+                        note.notify_staff(egCore.auth.user().id());
+                        note.notify_time('now');
+                        return egCore.pcrud.create(note).then(function(n) {
+                            n.notify_staff(egCore.auth.user());
+                            $scope.hold.notifications().push(n);
+                        });
+                    });
+                }
+
+                $scope.$watch('holdId', function(newVal, oldVal) {
+                    if (newVal != oldVal) draw();
+                });
+
+                draw();
+            }
+        ]
+    }
+})
+
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/package.json b/Open-ILS/web/js/ui/default/staff/package.json
new file mode 100644
index 0000000..310ee6b
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/package.json
@@ -0,0 +1,28 @@
+{
+  "name": "evergreen-staff-client",
+  "description": "Evergreen ILS Browser Staff Client",
+  "version": "0.0.1",
+  "license": "GPL",
+  "homepage": "http://evergreen-ils.org/",
+  "devDependencies": {
+    "bower": "^1.3.3",
+    "grunt": "~0.4.4",
+    "grunt-cli": "^0.1.13",
+    "grunt-contrib-concat": "^0.4.0",
+    "grunt-contrib-copy": "^0.5.0",
+    "grunt-contrib-cssmin": "^0.9.0",
+    "grunt-contrib-jasmine": "^0.6.4",
+    "grunt-contrib-uglify": "^0.4.0",
+    "grunt-exec": "^0.4.5",
+    "grunt-karma": "^0.8.3",
+    "karma": "^0.12.14",
+    "karma-jasmine": "^0.1.5",
+    "karma-phantomjs-launcher": "^0.1.4",
+    "karma-script-launcher": "~0.1.0",
+    "karma-spec-reporter": "0.0.12",
+    "karma-story-reporter": "^0.2.2"
+  },
+  "scripts": {
+    "test": "grunt test"
+  }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/services/auth.js b/Open-ILS/web/js/ui/default/staff/services/auth.js
new file mode 100644
index 0000000..75ae9ba
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/auth.js
@@ -0,0 +1,262 @@
+/* Core Sevice - egAuth
+ *
+ * Manages login and auth session retrieval.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egAuth', 
+       ['$q','$timeout','$rootScope','egNet','egHatch', 
+function($q , $timeout , $rootScope , egNet , egHatch) {
+
+    var service = {
+        // the currently active user (au) object
+        user : function() {
+            return this._user;
+        },
+
+        // the currently active auth token string
+        token : function() {
+            return egHatch.getLocalItem('eg.auth.token');
+        },
+
+        // authtime in seconds
+        authtime : function() {
+            return egHatch.getLocalItem('eg.auth.time');
+        },
+
+        // the currently active workstation name
+        // For ws_ou or wsid(), see egAuth.user().ws_ou(), etc.
+        workstation : function() {
+            return this.ws;
+        }
+    };
+
+    /* Returns a promise, which is resolved if valid
+     * authtoken is found, otherwise rejected */
+    service.testAuthToken = function() {
+        var deferred = $q.defer();
+        var token = service.token();
+
+        if (token) {
+
+            egNet.request(
+                'open-ils.auth',
+                'open-ils.auth.session.retrieve', token)
+
+            .then(function(user) {
+                if (user && user.classname) {
+                    // authtoken test succeeded
+                    service._user = user;
+                    service.poll();
+                   
+                    if (user.wsid()) {
+                        // user previously logged in with a workstation. 
+                        // Find the workstation name from the list 
+                        // of configured workstations
+                        egHatch.getItem('eg.workstation.all')
+                        .then(function(all) { 
+                            if (all) {
+                                var ws = all.filter(
+                                    function(w) {return w.id == user.wsid()})[0];
+                                if (ws) service.ws = ws.name;
+                            }
+                            deferred.resolve(); // found WS
+                        });
+                    } else {
+                        deferred.resolve(); // no WS
+                    }
+                } else {
+                    // authtoken test failed
+                    egHatch.removeLocalItem('eg.auth.token');
+                    deferred.reject(); 
+                }
+            });
+
+        } else {
+            // no authtoken to test
+            deferred.reject();
+        }
+
+        return deferred.promise;
+    };
+
+    /**
+     * Returns a promise, which is resolved on successful 
+     * login and rejected on failed login.
+     */
+    service.login = function(args) {
+        var deferred = $q.defer();
+        egNet.request(
+            'open-ils.auth',
+            'open-ils.auth.authenticate.init', args.username).then(
+            function(seed) {
+                args.password = hex_md5(seed + hex_md5(args.password))
+                egNet.request(
+                    'open-ils.auth',
+                    'open-ils.auth.authenticate.complete', args).then(
+                    function(evt) {
+                        if (evt.textcode == 'SUCCESS') {
+                            service.ws = args.workstation; 
+                            service.poll();
+                            egHatch.setLocalItem(
+                                'eg.auth.token', evt.payload.authtoken);
+                            egHatch.setLocalItem(
+                                'eg.auth.time', evt.payload.authtime);
+                            deferred.resolve();
+                        } else {
+                            // note: the likely outcome here is a NO_SESION
+                            // server event, which results in broadcasting an 
+                            // egInvalidAuth by egNet. 
+                            console.error('login failed ' + js2JSON(evt));
+                            deferred.reject();
+                        }
+                    }
+                )
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    /**
+     * Force-check the validity of the authtoken on occasion. 
+     * This allows us to redirect an idle staff client back to the login
+     * page after the session times out.  Otherwise, the UI would stay
+     * open with potentially sensitive data visible.
+     * TODO: What is the practical difference (for a browser) between 
+     * checking auth validity and the ui.general.idle_timeout setting?
+     * Does that setting serve a purpose in a browser environment?
+     */
+    service.poll = function() {
+        if (!service.authtime()) return;
+
+        $timeout(
+            function() {
+                if (!service.authtime()) return;
+                egNet.request(                                                     
+                    'open-ils.auth',                                               
+                    'open-ils.auth.session.retrieve', service.token())   
+                .then(function(user) {
+                    if (user && user.classname) { // all good
+                        service.poll();
+                    } else {
+                        $rootScope.$broadcast('egAuthExpired') 
+                    }
+                })
+            },
+            // add a 5 second delay to give the token plenty of time
+            // to expire on the server.
+            service.authtime() * 1000 + 5000
+        );
+    }
+
+    service.logout = function() {
+        if (service.token()) {
+            egNet.request(
+                'open-ils.auth', 
+                'open-ils.auth.session.delete', 
+                service.token()); // fire and forget
+            egHatch.removeLocalItem('eg.auth.token');
+            egHatch.removeLocalItem('eg.auth.time');
+        }
+        service._user = null;
+    };
+
+    return service;
+}])
+
+
+/**
+ * Service for testing user permissions.
+ * Note: this cannot live within egAuth, because it creates a circular
+ * dependency of egOrg -> egEnv -> egAuth -> egOrg
+ */
+.factory('egPerm', 
+       ['$q','egNet','egAuth','egOrg',
+function($q , egNet , egAuth , egOrg) {
+    var service = {};
+
+    /*
+     * Returns the full list of org unit objects at which the currently
+     * logged in user has the selected permissions.
+     * @permList - list or string.  If a list, the response object is a
+     * hash of perm => orgList maps.  If a string, the response is the
+     * org list for the requested perm.
+     */
+    service.hasPermAt = function(permList, asId) {
+        var deferred = $q.defer();
+        var isArray = true;
+        if (!angular.isArray(permList)) {
+            isArray = false;
+            permList = [permList];
+        }
+        // as called, this method will return the top-most org unit of the
+        // sub-tree at which this user has the selected permission.
+        // From there, flesh the descendant orgs locally.
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.has_work_perm_at.batch',
+            egAuth.token(), permList
+        ).then(function(resp) {
+            var answer = {};
+            angular.forEach(permList, function(perm) {
+                var all = [];
+                angular.forEach(resp[perm], function(oneOrg) {
+                    all = all.concat(egOrg.descendants(oneOrg, asId));
+                });
+                answer[perm] = all;
+            });
+            if (!isArray) answer = answer[permList[0]];
+            deferred.resolve(answer);
+        });
+       return deferred.promise;
+    };
+
+
+    /**
+     * Returns a hash of perm => hasPermBool for each requested permission.
+     * If the authenticated user has no workstation, no checks are made
+     * and all permissions return false.
+     */
+    service.hasPermHere = function(permList) {
+        var response = {};
+
+        var isArray = true;
+        if (!angular.isArray(permList)) {
+            isArray = false;
+            permList = [permList];
+        }
+
+        // no workstation, all are false
+        if (egAuth.user().wsid() === null) {
+            console.warn("egPerm.hasPermHere() called with no workstation");
+            if (isArray) {
+                response = permList.map(function(perm) {
+                    return response[perm] = false;
+                });
+            } else {
+                response = false;
+            }
+            return $q.when(response);
+        }
+
+        ws_ou = Number(egAuth.user().ws_ou()); // from string
+
+        return service.hasPermAt(permList, true)
+        .then(function(orgMap) {
+            angular.forEach(orgMap, function(orgIds, perm) {
+                // each permission is mapped to a flat list of org unit ids,
+                // including descendants.  See if our workstation org unit
+                // is in the list.
+                response[perm] = orgIds.indexOf(ws_ou) > -1;
+            });
+            if (!isArray) response = response[permList[0]];
+            return response;
+        });
+    }
+
+    return service;
+}])
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/core.js b/Open-ILS/web/js/ui/default/staff/services/core.js
new file mode 100644
index 0000000..e0ef021
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/core.js
@@ -0,0 +1,6 @@
+
+/**
+ * egCoreMod houses all of the services, etc. required by all pages
+ * for basic functionality.
+ */
+angular.module('egCoreMod', ['cfp.hotkeys']);
diff --git a/Open-ILS/web/js/ui/default/staff/services/coresvc.js b/Open-ILS/web/js/ui/default/staff/services/coresvc.js
new file mode 100644
index 0000000..6909978
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/coresvc.js
@@ -0,0 +1,33 @@
+/**
+ * egCore service
+ *
+ * Aggregates all core services into a container service.  This allows
+ * use of core services without having to inject each individually.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCore', 
+       ['egIDL','egNet','egEnv','egOrg','egPCRUD','egEvent','egAuth',
+        'egPerm','egHatch','egPrint','egStartup','egStrings',
+function(egIDL , egNet , egEnv , egOrg , egPCRUD , egEvent , egAuth , 
+         egPerm , egHatch , egPrint , egStartup , egStrings) {
+
+    return {
+        idl     : egIDL,
+        net     : egNet,
+        env     : egEnv,
+        org     : egOrg,
+        pcrud   : egPCRUD,
+        evt     : egEvent,
+        auth    : egAuth,
+        perm    : egPerm,
+        hatch   : egHatch,
+        print   : egPrint,
+        startup : egStartup,
+        strings : egStrings
+    };
+
+}]);
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/eframe.js b/Open-ILS/web/js/ui/default/staff/services/eframe.js
new file mode 100644
index 0000000..b706a50
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/eframe.js
@@ -0,0 +1,210 @@
+angular.module('egCoreMod')
+
+/*
+ * Iframe container for (mostly legacy) embedded interfaces
+ */
+.directive('egEmbedFrame', function() {
+    return {
+        restrict : 'AE',
+        replace : true,
+        scope : {
+            // URL to load in the embed iframe
+            url : '=',
+
+            // optional hash of functions which augment or override 
+            // the stock xulG functions defined below.
+            handlers : '=',
+
+            // called after onload of each new iframe page
+            onchange : '=',
+        },
+
+        templateUrl : './share/t_eframe',
+
+        controller : 
+                   ['$scope','$window','$location','$q','$timeout','egCore',
+            function($scope , $window , $location , $q , $timeout , egCore) {
+
+            // Set the iframe height to just under the window height.
+            // leave room for the navbar, padding, margins, etc.
+            $scope.height = $window.outerHeight - 300;
+
+            // browser client doesn't use cookies, so we don't load the
+            // (at the time of writing, quite limited) angular.cookies
+            // module.  We could load something, but this seems to work
+            // well enough for setting the auth cookie (at least, until 
+            // it doesn't).
+            //
+            // note: document.cookie is smart enough to leave unreferenced
+            // cookies alone, so contrary to how this might look, it's not 
+            // deleting other cookies (anoncache, etc.)
+            
+            // delete any existing ses cookie
+            $window.document.cookie = "ses=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+            // push our authtoken in
+            $window.document.cookie = 'ses=' + egCore.auth.token() + '; path=/; secure'
+
+            // $location has functions for modifying paths and search,
+            // but they all assume you are staying within the angular
+            // app, which we are not.  Build the URLs by hand.
+            function open_tab(path) {
+                var url = 'https://' + $window.location.hostname + 
+                    egCore.env.basePath + path;
+                console.debug('egEmbedFrame opening tab ' + url);
+                $window.open(url, '_blank').focus();
+            }
+
+            // define our own xulG functions to be inserted into the
+            // iframe.  NOTE: window-level functions are bad.  Though
+            // there is probably a way, I was unable to correctly wire
+            // up the iframe onload handler within the controller or link
+            // funcs.  In any event, the code below is meant as a stop-gap
+            // for porting dojo, etc. apps to angular apps and should
+            // eventually go away.
+            // NOTE: catalog integration is not a stop-gap
+            $window.egEmbedFrameLoader = function(iframe) {
+
+                var page = iframe.contentWindow.location.href;
+                console.debug('egEmbedFrameLoader(): ' + page);
+
+                // reload ifram page w/o reloading the entire UI
+                $scope.reload = function() {
+                    iframe.contentWindow.location.replace(
+                        iframe.contentWindow.location);
+                }
+
+                // tell the iframe'd window its inside the staff client
+                iframe.contentWindow.IAMXUL = true;
+
+                // also tell it it's inside the browser client, which 
+                // may be needed in a few special cases.
+                iframe.contentWindow.IAMBROWSER /* hear me roar */ = true; 
+
+                // XUL has a dump() function which is occasinally called 
+                // from embedded browsers.
+                iframe.contentWindow.dump = function(msg) {
+                    console.debug('egEmbedFrame:dump(): ' + msg);
+                }
+
+                // define a few commonly used stock xulG handlers. 
+                
+                iframe.contentWindow.xulG = {
+                    // patron search
+                    spawn_search : function(search) {
+                        open_tab('/circ/patron/search?search=' 
+                            + encodeURIComponent(js2JSON(search)));
+                    },
+
+                    // edit an existing user
+                    spawn_editor : function(info) {
+                        if (info.usr) {
+                            open_tab('/circ/patron/register/edit/' + info.usr);
+                        
+                        } else if (info.clone) {
+                            // FIXME: The save-and-clone operation in the
+                            // patron editor results in this action.  
+                            // For some reason, this specific function results
+                            // in a new browser window opening instead of a 
+                            // browser tab.  Possibly this is caused by the 
+                            // fact that the action occurs as a result of a
+                            // button click instead of an href.  *shrug*.
+                            // It's obnoxious.
+                            open_tab('/circ/patron/register/clone/' + info.clone);
+                        } 
+                    },
+
+                    // open a user account
+                    new_patron_tab : function(tab_info, usr_info) {
+                        open_tab('/circ/patron/' + usr_info.id + '/checkout');
+                    },
+
+                    get_barcode_and_settings_async : function(barcode, only_settings) {
+                        if (!barcode) return $q.reject();
+                        var deferred = $q.defer();
+
+                        var barcode_promise = $q.when(barcode);
+                        if (!only_settings) {
+
+                            // first verify / locate the barcode
+                            barcode_promise = egCore.net.request(
+                                'open-ils.actor',
+                                'open-ils.actor.get_barcodes',
+                                egCore.auth.token(), 
+                                egCore.auth.user().ws_ou(), 'actor', barcode
+                            ).then(function(resp) {
+
+                                if (!resp || egCore.evt.parse(resp) || !resp.length) {
+                                    console.error('user not found: ' + barcode);
+                                    deferred.reject();
+                                    return null;
+                                } 
+
+                                resp = resp[0];
+                                return barcode = resp.barcode;
+                            });
+                        }
+
+                        barcode_promise.then(function(barcode) {
+                            if (!barcode) return;
+
+                            return egCore.net.request(
+                                'open-ils.actor',
+                                'open-ils.actor.user.fleshed.retrieve_by_barcode',
+                                egCore.auth.token(), barcode);
+
+                        }).then(function(user) {
+                            if (!user) return null;
+
+                            if (e = egCore.evt.parse(user)) {
+                                console.error('user fetch failed : ' + e.toString());
+                                deferred.reject();
+                                return null;
+                            }
+
+                            // copied more or less directly from XUL menu.js
+                            var settings = {};
+                            for(var i = 0; i < user.settings().length; i++) {
+                                settings[user.settings()[i].name()] = 
+                                    JSON2js(user.settings()[i].value());
+                            }
+
+                            if(!settings['opac.default_phone'] && user.day_phone()) 
+                                settings['opac.default_phone'] = user.day_phone();
+                            if(!settings['opac.hold_notify'] && settings['opac.hold_notify'] !== '') 
+                                settings['opac.hold_notify'] = 'email:phone';
+
+                            // Taken from patron/util.js format_name
+                            // FIXME: I18n
+                            var patron_name = 
+                                ( user.prefix() ? user.prefix() + ' ' : '') +
+                                user.family_name() + ', ' +
+                                user.first_given_name() + ' ' +
+                                ( user.second_given_name() ? user.second_given_name() + ' ' : '' ) +
+                                ( user.suffix() ? user.suffix() : '');
+
+                            deferred.resolve({
+                                "barcode": barcode, 
+                                "settings" : settings, 
+                                "user_email" : user.email(), 
+                                "patron_name" : patron_name
+                            });
+                        });
+
+                        return deferred.promise;
+                    }
+                }
+
+                if ($scope.handlers) {
+                    $scope.handlers.reload = $scope.reload;
+                    angular.forEach($scope.handlers, function(val, key) {
+                        iframe.contentWindow.xulG[key] = val;
+                    });
+                }
+
+                if ($scope.onchange) $scope.onchange(page);
+            }
+        }]
+    }
+})
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/env.js b/Open-ILS/web/js/ui/default/staff/services/env.js
new file mode 100644
index 0000000..25198fa
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/env.js
@@ -0,0 +1,165 @@
+/**
+ * Core Service - egEnv
+ *
+ * Manages startup data loading and data caching.  
+ * All registered loaders run * simultaneously.  When all promises 
+ * are resolved, the promise * returned by egEnv.load() is resolved.
+ *
+ * There are two main uses cases for egEnv:
+ *
+ * 1. When loading a variety of objects on page load, having them
+ * loaded with egEnv ensures that the load will happen in parallel
+ * and that it will complete before egStartup completes, which is 
+ * generally before page controllers run.
+ *
+ * 2. When loading generic IDL data across different services,
+ * having them all stash the data in egEnv means they each have
+ * an agreed-upon cache mechanism.
+ *
+ * It's also a good place to stash other environmental tidbits...
+ *
+ * Generic and class-based loaders are supported.  
+ *
+ * To load a registred class, push the class hint onto 
+ * egEnv.loadClasses.  
+ *
+ * // will cause all 'pgt' objects to be fetched
+ * egEnv.loadClasses.push('pgt');
+ *
+ * To register a new class loader,attach a loader function to 
+ * egEnv.classLoaders, keyed on the class hint, which returns a promise.
+ *
+ * egEnv.classLoaders.ccs = function() { 
+ *    // loads copy status objects, returns promise
+ * };
+ *
+ * Generic loaders go onto the egEnv.loaders array.  Each should
+ * return a promise.
+ *
+ * egEnv.loaders.push(function() {
+ *    return egNet.request(...)
+ *    .then(function(stuff) { console.log('stuff!') 
+ * });
+ */
+
+angular.module('egCoreMod')
+
+// env fetcher
+.factory('egEnv', 
+       ['$q','$window','egAuth','egPCRUD','egIDL',
+function($q,  $window , egAuth,  egPCRUD,  egIDL) { 
+
+    var service = {
+        // collection of custom loader functions
+        loaders : []
+    };
+
+
+    // <base href="<basePath>"/> from the current index page
+    // Currently defaults to /eg/staff for all pages.
+    // Use $location.path() to jump around within an app.
+    // Use egEnv.basePath to create URLs to new apps.
+    // NOTE: the dynamic version below derived from the DOM does not
+    // work w/ unit tests.  Use hard-coded value instead for now.
+    service.basePath = '/eg/staff/';
+        //$window.document.getElementsByTagName('base')[0].getAttribute('href');
+
+    /* returns a promise, loads all of the specified classes */
+    service.load = function() {
+        // always assume the user is logged in
+        if (!egAuth.user()) return $q.when();
+
+        var allPromises = [];
+        var classes = this.loadClasses;
+        console.debug('egEnv loading classes => ' + classes);
+
+        angular.forEach(classes, function(cls) {
+            allPromises.push(service.classLoaders[cls]());
+        });
+        angular.forEach(this.loaders, function(loader) {
+            allPromises.push(loader());
+        });
+
+        return $q.all(allPromises).then(
+            function() { console.debug('egEnv load complete') });
+    };
+
+    /** given a tree-shaped collection, captures the tree and
+     *  flattens the tree for absorption.
+     */
+    service.absorbTree = function(tree, class_) {
+        var list = [];
+        function squash(node) {
+            list.push(node);
+            angular.forEach(node.children(), squash);
+        }
+        squash(tree);
+        var blob = service.absorbList(list, class_);
+        blob.tree = tree;
+    };
+
+    /** caches the object list both as the list and an id => object map */
+    service.absorbList = function(list, class_) {
+        var blob;
+        var pkey = egIDL.classes[class_].pkey;
+
+        if (service[class_]) {
+            // appending data to an existing class.  Useful for receiving 
+            // class elements as-needed.  Avoid adding items which are 
+            // already tracked in the list.
+            blob = service[class_];
+            angular.forEach(list, function(item) {
+                if (!service[class_].map[item[pkey]()]) 
+                    blob.list.push(item);
+            });
+        } else {
+            blob = {list : list, map : {}};
+        }
+
+        angular.forEach(list, function(item) {blob.map[item[pkey]()] = item});
+        service[class_] = blob;
+        return blob;
+    };
+
+    /* 
+     * list of classes to load on every page, regardless of whether
+     * a page-specific list is provided.
+     */
+    service.loadClasses = ['aou'];
+
+    /*
+     * Default class loaders.  Only add classes directly to this file
+     * that are loaded practically always.  All other app-specific
+     * classes should be registerd from within the app.
+     */
+    service.classLoaders = {
+        aou : function() {
+
+            // EXPERIMENT: cache the org tree in session storage.
+            // This means that if the org tree changes, users will have to
+            // open the client in a new browser tab to clear the cached tree.
+            var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree');
+            if (treeJSON) {
+                console.debug('serving org tree from cache');
+                var tree = JSON2js(treeJSON);
+                service.absorbTree(tree, 'aou')
+                return $q.when(tree);
+            }
+
+            return egPCRUD.search('aou', {parent_ou : null}, 
+                {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+            ).then(
+                function(tree) {
+                    $window.sessionStorage.setItem(
+                        'eg.env.aou.tree', js2JSON(tree));
+                    service.absorbTree(tree, 'aou')
+                }
+            );
+        },
+    };
+
+    return service;
+}]);
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/event.js b/Open-ILS/web/js/ui/default/staff/services/event.js
new file mode 100644
index 0000000..dbf0d82
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/event.js
@@ -0,0 +1,56 @@
+/**
+ * Core Service - egEvent
+ *
+ * Models / tests event objects returned by many server APIs. 
+ * E.g.
+ * {
+ *  "stacktrace":"..."
+ *  "ilsevent":"1575",
+ *  "pid":"28258",
+ *  "desc":"The requested container_biblio_record_entry_bucket was not found",
+ *  "payload":"2",
+ *  "textcode":"CONTAINER_BIBLIO_RECORD_ENTRY_BUCKET_NOT_FOUND",
+ *  "servertime":"Wed Nov 6 16:05:50 2013"
+ * }
+ *
+ * var evt = egEvent.parse(thing);
+ * if (evt) console.error(evt);
+ *
+ */
+
+angular.module('egCoreMod')
+
+.factory('egEvent', function() {
+
+    return {
+        parse : function(thing) {
+
+            function EGEvent(args) {
+                this.code = args.ilsevent;
+                this.textcode = args.textcode;
+                this.desc = args.desc;
+                this.payload = args.payload;
+                this.debug = args.stacktrace;
+                this.servertime = args.servertime;
+                this.ilsperm = args.ilsperm;
+                this.ilspermloc = args.ilspermloc;
+                this.note = args.note;
+                this.success = this.textcode == 'SUCCESS';
+                this.toString = function() {
+                    var s = 'Event: ' + (this.code || '') + ':' + 
+                        this.textcode + ' -> ' + new String(this.desc);
+                    if(this.ilsperm)
+                        s += ' ' + this.ilsperm + '@' + this.ilspermloc;
+                    if(this.note)
+                        s += '\n' + this.note;
+                    return s;
+                }
+            }
+            
+            if(thing && typeof thing == 'object' && 'textcode' in thing)
+                return new EGEvent(thing);
+            return null;
+        }
+    }
+});
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/services/file.js b/Open-ILS/web/js/ui/default/staff/services/file.js
new file mode 100644
index 0000000..8dc0c2b
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/file.js
@@ -0,0 +1,28 @@
+/**
+ * File upload reader.
+ * http://stackoverflow.com/questions/17063000/ng-model-for-input-type-file
+ *
+ * After reading, the contents will be available in the scope variable
+ * referred to by container="..."
+ */
+
+angular.module('egCoreMod')
+.directive("egFileReader", [function () {
+    return {
+        scope: {
+            container: "="
+        },
+        link: function (scope, element, attributes) {
+            // TODO: support DataURL, etc. via attrs
+            element.bind("change", function (changeEvent) {
+                var reader = new FileReader();
+                reader.onload = function (loadEvent) {
+                    scope.$apply(function () {
+                        scope.container = loadEvent.target.result;
+                    });
+                }
+                reader.readAsText(changeEvent.target.files[0]);
+            });
+        }
+    }
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/grid.js b/Open-ILS/web/js/ui/default/staff/services/grid.js
new file mode 100644
index 0000000..a554e93
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/grid.js
@@ -0,0 +1,1522 @@
+angular.module('egGridMod', 
+    ['egCoreMod', 'egUiMod', 'ui.bootstrap'])
+
+.directive('egGrid', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+
+            // IDL class hint (e.g. "aou")
+            idlClass : '@',
+
+            // default page size
+            pageSize : '@',
+
+            // if true, grid columns are derived from all non-virtual
+            // fields on the base idlClass
+            autoFields : '@',
+
+            // grid preferences will be stored / retrieved with this key
+            persistKey : '@',
+
+            // field whose value is unique and may be used for item
+            // reference / lookup.  This will usually be someting like
+            // "id".  This is not needed when using autoFields, since we
+            // can determine the primary key directly from the IDL.
+            idField : '@',
+
+            // Reference to externally provided egGridDataProvider
+            itemsProvider : '=',
+
+            // comma-separated list of supported or disabled grid features
+            // supported features:
+            //  -display : columns are hidden by default
+            //  -sort    : columns are unsortable by default 
+            //  -multisort : sort priorities config disabled by default
+            features : '@',
+
+            // optional primary grid label
+            mainLabel : '@',
+
+            // if true, use the IDL class label as the mainLabel
+            autoLabel : '=', 
+
+            // optional context menu label
+            menuLabel : '@',
+
+            // Hash of control functions.
+            //
+            //  These functions are defined by the calling scope and 
+            //  invoked as-is by the grid w/ the specified parameters.
+            //
+            //  itemRetrieved     : function(item) {}
+            //  allItemsRetrieved : function() {}
+            //
+            //  ---------------
+            //  These functions are defined by the grid and thus
+            //  replace any values defined for these attributes from the
+            //  calling scope.
+            //
+            //  activateItem  : function(item) {}
+            //  allItems      : function(allItems) {}
+            //  selectedItems : function(selected) {}
+            //  selectItems   : function(ids) {}
+            //  setQuery      : function(queryStruct) {} // causes reload
+            //  setSort       : function(sortSturct) {} // causes reload
+            gridControls : '=',
+        },
+
+        // TODO: avoid hard-coded url
+        templateUrl : '/eg/staff/share/t_autogrid', 
+
+        link : function(scope, element, attrs) {     
+            // link() is called after page compilation, which means our
+            // eg-grid-field's have been parsed and loaded.  Now it's 
+            // safe to perform our initial page load.
+
+            // load auto fields after eg-grid-field's so they are not clobbered
+            scope.handleAutoFields();
+            scope.collect();
+        },
+
+        controller : [
+                    '$scope','$q','egCore','egGridFlatDataProvider','$location',
+                    'egGridColumnsProvider','$filter','$window','$sce','$timeout',
+            function($scope,  $q , egCore,  egGridFlatDataProvider , $location,
+                     egGridColumnsProvider , $filter , $window , $sce , $timeout) {
+
+            var grid = this;
+
+            grid.init = function() {
+                grid.offset = 0;
+                grid.limit = Number($scope.pageSize) || 25;
+                $scope.items = [];
+                $scope.showGridConf = false;
+                grid.totalCount = -1;
+                $scope.selected = {};
+                $scope.actions = []; // actions for selected items
+                $scope.menuItems = []; // global actions
+
+                // remove some unneeded values from the scope to reduce bloat
+
+                grid.idlClass = $scope.idlClass;
+                delete $scope.idlClass;
+
+                grid.persistKey = $scope.persistKey;
+                delete $scope.persistKey;
+
+                grid.indexField = $scope.idField;
+                delete $scope.idField;
+
+                grid.dataProvider = $scope.itemsProvider;
+
+                var features = ($scope.features) ? 
+                    $scope.features.split(',') : [];
+                delete $scope.features;
+
+                if (!grid.indexField && grid.idlClass)
+                    grid.indexField = egCore.idl.classes[grid.idlClass].pkey;
+
+                grid.columnsProvider = egGridColumnsProvider.instance({
+                    idlClass : grid.idlClass,
+                    defaultToHidden : (features.indexOf('-display') > -1),
+                    defaultToNoSort : (features.indexOf('-sort') > -1),
+                    defaultToNoMultiSort : (features.indexOf('-multisort') > -1)
+                });
+
+                $scope.handleAutoFields = function() {
+                    if ($scope.autoFields) {
+                        if (grid.autoLabel) {
+                            $scope.mainLabel = 
+                                egCore.idl.classes[grid.idlClass].label;
+                        }
+                        grid.columnsProvider.compileAutoColumns();
+                        delete $scope.autoFields;
+                    }
+                }
+   
+                if (!grid.dataProvider) {
+                    // no provider, um, provided.
+                    // Use a flat data provider
+
+                    grid.selfManagedData = true;
+                    grid.dataProvider = egGridFlatDataProvider.instance({
+                        indexField : grid.indexField,
+                        idlClass : grid.idlClass,
+                        columnsProvider : grid.columnsProvider,
+                        query : $scope.query
+                    });
+                }
+
+                $scope.itemFieldValue = grid.dataProvider.itemFieldValue;
+                $scope.indexValue = function(item) {
+                    return grid.indexValue(item)
+                };
+
+                grid.applyControlFunctions();
+
+                grid.loadConfig().then(function() { 
+                    // link columns to scope after loadConfig(), since it
+                    // replaces the columns array.
+                    $scope.columns = grid.columnsProvider.columns;
+                });
+
+                // NOTE: grid.collect() is first called from link(), not here.
+            }
+
+            // link our control functions into the gridControls 
+            // scope object so the caller can access them.
+            grid.applyControlFunctions = function() {
+
+                // we use some of these controls internally, so sett
+                // them up even if the caller doesn't request them.
+                var controls = $scope.gridControls || {};
+
+                // link in the control functions
+                controls.selectedItems = function() {
+                    return grid.getSelectedItems()
+                }
+
+                controls.allItems = function() {
+                    return $scope.items;
+                }
+
+                controls.selectItems = function(ids) {
+                    if (!ids) return;
+                    $scope.selected = {};
+                    angular.forEach(ids, function(i) {
+                        $scope.selected[''+i] = true;
+                    });
+                }
+
+                // if the caller provided a functional setQuery,
+                // extract the value before replacing it
+                if (controls.setQuery) {
+                    grid.dataProvider.query = 
+                        controls.setQuery();
+                }
+
+                controls.setQuery = function(query) {
+                    grid.dataProvider.query = query;
+                    controls.refresh();
+                }
+
+                // if the caller provided a functional setSort
+                // extract the value before replacing it
+                grid.dataProvider.sort = 
+                    controls.setSort ?  controls.setSort() : [];
+
+                controls.setSort = function(sort) {
+                    controls.refresh();
+                }
+
+                controls.refresh = function(noReset) {
+                    if (!noReset) grid.offset = 0;
+                    grid.collect();
+                }
+
+                controls.setLimit = function(limit) {
+                    grid.limit = limit;
+                }
+                controls.getLimit = function() {
+                    return grid.limit;
+                }
+                controls.setOffset = function(offset) {
+                    grid.offset = offset;
+                }
+                controls.getOffset = function() {
+                    return grid.offset;
+                }
+
+                grid.dataProvider.refresh = controls.refresh;
+                grid.controls = controls;
+            }
+
+            // add a new (global) grid menu item
+            grid.addMenuItem = function(item) {
+                $scope.menuItems.push(item);
+                var handler = item.handler;
+                item.handler = function() {
+                    $scope.gridMenuIsOpen = false; // close menu
+                    if (handler) {
+                        handler(item, 
+                            item.handlerData, grid.getSelectedItems());
+                    }
+                }
+            }
+
+            // add a selected-items action
+            grid.addAction = function(act) {
+                $scope.actions.push(act);
+            }
+
+            // remove the stored column configuration preferenc, then recover 
+            // the column visibility information from the initial page load.
+            $scope.resetColumns = function() {
+                $scope.gridColumnPickerIsOpen = false;
+                egCore.hatch.removeItem('eg.grid.' + grid.persistKey)
+                .then(function() {
+                    grid.columnsProvider.reset(); 
+                    if (grid.selfManagedData) grid.collect();
+                });
+            }
+
+            $scope.showAllColumns = function() {
+                grid.columnsProvider.showAllColumns();
+            }
+
+            $scope.hideAllColumns = function() {
+                grid.columnsProvider.hideAllColumns();
+            }
+
+            $scope.toggleColumnVisibility = function(col) {
+                $scope.gridColumnPickerIsOpen = false;
+                col.visible = !col.visible;
+
+                // egGridFlatDataProvider only retrieves data to be
+                // displayed.  When column visibility changes, it's
+                // necessary to fetch the newly visible column data.
+                if (grid.selfManagedData) grid.collect();
+            }
+
+            // save the columns configuration (position, sort, width) to
+            // eg.grid.<persist-key>
+            $scope.saveConfig = function() {
+                $scope.gridColumnPickerIsOpen = false;
+
+                if (!grid.persistKey) {
+                    console.warn(
+                        "Cannot save settings without a grid persist-key");
+                    return;
+                }
+
+                // only store information about visible columns.
+                var conf = grid.columnsProvider.columns.filter(
+                    function(col) {return Boolean(col.visible) });
+
+                // now scrunch the data down to just the needed info
+                conf = conf.map(function(col) {
+                    var c = {name : col.name}
+                    // Apart from the name, only store non-default values.
+                    // No need to store col.visible, since that's implicit
+                    if (col.flex != 2) c.flex = col.flex;
+                    if (Number(col.sort)) c.sort = Number(c.sort);
+                    return c;
+                });
+
+                egCore.hatch.setItem('eg.grid.' + grid.persistKey, conf)
+                .then(function() { 
+                    // Save operation performed from the grid configuration UI.
+                    // Hide the configuration UI and re-draw w/ sort applied
+                    if ($scope.showGridConf) 
+                        $scope.toggleConfDisplay();
+                });
+            }
+
+            // load the columns configuration (position, sort, width) from
+            // eg.grid.<persist-key> and apply the loaded settings to the
+            // columns on our columnsProvider
+            grid.loadConfig = function() {
+                if (!grid.persistKey) return $q.when();
+
+                return egCore.hatch.getItem('eg.grid.' + grid.persistKey)
+                .then(function(conf) {
+                    if (!conf) return;
+
+                    var columns = grid.columnsProvider.columns;
+                    var new_cols = [];
+
+                    angular.forEach(conf, function(col) {
+                        var grid_col = columns.filter(
+                            function(c) {return c.name == col.name})[0];
+
+                        if (!grid_col) {
+                            // saved column does not match a column in the 
+                            // current grid.  skip it.
+                            return;
+                        }
+
+                        grid_col.flex = col.flex || 2;
+                        grid_col.sort = col.sort || 0;
+                        // all saved columns are assumed to be true
+                        grid_col.visible = true;
+                        new_cols.push(grid_col);
+                    });
+
+                    // columns which are not expressed within the saved 
+                    // configuration are marked as non-visible and 
+                    // appended to the end of the new list of columns.
+                    angular.forEach(columns, function(col) {
+                        var found = conf.filter(
+                            function(c) {return (c.name == col.name)})[0];
+                        if (!found) {
+                            col.visible = false;
+                            new_cols.push(col);
+                        }
+                    });
+
+                    grid.columnsProvider.columns = new_cols;
+                    grid.compileSort();
+                });
+            }
+
+            $scope.onContextMenu = function($event) {
+                var col = angular.element($event.target).attr('column');
+                console.log('selected column ' + col);
+            }
+
+            $scope.page = function() {
+                return (grid.offset / grid.limit) + 1;
+            }
+
+            $scope.goToPage = function(page) {
+                page = Number(page);
+                if (angular.isNumber(page) && page > 0) {
+                    grid.offset = (page - 1) * grid.limit;
+                    grid.collect();
+                }
+            }
+
+            $scope.offset = function(o) {
+                if (angular.isNumber(o))
+                    grid.offset = o;
+                return grid.offset 
+            }
+
+            $scope.limit = function(l) { 
+                if (angular.isNumber(l))
+                    grid.limit = l;
+                return grid.limit 
+            }
+
+            $scope.onFirstPage = function() {
+                return grid.offset == 0;
+            }
+
+            $scope.hasNextPage = function() {
+                // we have less data than requested, there must
+                // not be any more pages
+                if (grid.count() < grid.limit) return false;
+
+                // if the total count is not known, assume that a full
+                // page of data implies more pages are available.
+                if (grid.totalCount == -1) return true;
+
+                // we have a full page of data, but is there more?
+                return grid.totalCount > (grid.offset + grid.count());
+            }
+
+            $scope.incrementPage = function() {
+                grid.offset += grid.limit;
+                grid.collect();
+            }
+
+            $scope.decrementPage = function() {
+                if (grid.offset < grid.limit) {
+                    grid.offset = 0;
+                } else {
+                    grid.offset -= grid.limit;
+                }
+                grid.collect();
+            }
+
+            // number of items loaded for the current page of results
+            grid.count = function() {
+                return $scope.items.length;
+            }
+
+            // returns the unique identifier value for the provided item
+            // for internal consistency, indexValue is always coerced 
+            // into a string.
+            grid.indexValue = function(item) {
+                if (angular.isObject(item)) {
+                    if (item !== null) {
+                        if (angular.isFunction(item[grid.indexField]))
+                            return ''+item[grid.indexField]();
+                        return ''+item[grid.indexField]; // flat data
+                    }
+                }
+                // passed a non-object; assume it's an index
+                return ''+item; 
+            }
+
+            // fires the action handler function for a context action
+            $scope.actionLauncher = function(action) {
+                if (!action.handler) {
+                    console.error(
+                        'No handler specified for "' + action.label + '"');
+                    return;
+                }
+
+                try {
+                    action.handler(grid.getSelectedItems());
+                } catch(E) {
+                    console.error('Error executing handler for "' 
+                        + action.label + '" => ' + E + "\n" + E.stack);
+                }
+            }
+
+            // returns the list of selected item objects
+            grid.getSelectedItems = function() {
+                return $scope.items.filter(
+                    function(item) {
+                        return Boolean($scope.selected[grid.indexValue(item)]);
+                    }
+                );
+            }
+
+            grid.getItemByIndex = function(index) {
+                for (var i = 0; i < $scope.items.length; i++) {
+                    var item = $scope.items[i];
+                    if (grid.indexValue(item) == index) 
+                        return item;
+                }
+            }
+
+            // selects one row after deselecting all of the others
+            grid.selectOneItem = function(index) {
+                $scope.selected = {};
+                $scope.selected[index] = true;
+            }
+
+            // selects or deselects an item, without affecting the others.
+            // returns true if the item is selected; false if de-selected.
+            grid.toggleSelectOneItem = function(index) {
+                if ($scope.selected[index]) {
+                    delete $scope.selected[index];
+                    return false;
+                } else {
+                    return $scope.selected[index] = true;
+                }
+            }
+
+            grid.selectAllItems = function() {
+                angular.forEach($scope.items, function(item) {
+                    $scope.selected[grid.indexValue(item)] = true
+                });
+            }
+
+            $scope.$watch('selectAll', function(newVal) {
+                if (newVal) {
+                    grid.selectAllItems();
+                } else {
+                    $scope.selected = {};
+                }
+            });
+
+            // returns true if item1 appears in the list before item2;
+            // false otherwise.  this is slightly more efficient that
+            // finding the position of each then comparing them.
+            // item1 / item2 may be an item or an item index
+            grid.itemComesBefore = function(itemOrIndex1, itemOrIndex2) {
+                var idx1 = grid.indexValue(itemOrIndex1);
+                var idx2 = grid.indexValue(itemOrIndex2);
+
+                // use for() for early exit
+                for (var i = 0; i < $scope.items.length; i++) {
+                    var idx = grid.indexValue($scope.items[i]);
+                    if (idx == idx1) return true;
+                    if (idx == idx2) return false;
+                }
+                return false;
+            }
+
+            // 0-based position of item in the current data set
+            grid.indexOf = function(item) {
+                var idx = grid.indexValue(item);
+                for (var i = 0; i < $scope.items.length; i++) {
+                    if (grid.indexValue($scope.items[i]) == idx)
+                        return i;
+                }
+                return -1;
+            }
+
+            grid.modifyColumnFlex = function(column, val) {
+                column.flex += val;
+                // prevent flex:0;  use hiding instead
+                if (column.flex < 1)
+                    column.flex = 1;
+            }
+            $scope.modifyColumnFlex = function(col, val) {
+                grid.modifyColumnFlex(col, val);
+            }
+
+            // handles click, control-click, and shift-click
+            $scope.handleRowClick = function($event, item) {
+                var index = grid.indexValue(item);
+
+                var origSelected = Object.keys($scope.selected);
+
+                if ($event.ctrlKey || $event.metaKey /* mac command */) {
+                    // control-click
+                    if (grid.toggleSelectOneItem(index)) 
+                        grid.lastSelectedItemIndex = index;
+
+                } else if ($event.shiftKey) { 
+                    // shift-click
+
+                    if (!grid.lastSelectedItemIndex || 
+                            index == grid.lastSelectedItemIndex) {
+                        grid.selectOneItem(index);
+                        grid.lastSelectedItemIndex = index;
+
+                    } else {
+
+                        var selecting = false;
+                        var ascending = grid.itemComesBefore(
+                            grid.lastSelectedItemIndex, item);
+                        var startPos = 
+                            grid.indexOf(grid.lastSelectedItemIndex);
+
+                        // update to new last-selected
+                        grid.lastSelectedItemIndex = index;
+
+                        // select each row between the last selected and 
+                        // currently selected items
+                        while (true) {
+                            startPos += ascending ? 1 : -1;
+                            var curItem = $scope.items[startPos];
+                            if (!curItem) break;
+                            var curIdx = grid.indexValue(curItem);
+                            $scope.selected[curIdx] = true;
+                            if (curIdx == index) break; // all done
+                        }
+                    }
+                        
+                } else {
+                    grid.selectOneItem(index);
+                    grid.lastSelectedItemIndex = index;
+                }
+            }
+
+            // Builds a sort expression from column sort priorities.
+            // called on page load and any time the priorities are modified.
+            grid.compileSort = function() {
+                var sortList = grid.columnsProvider.columns.filter(
+                    function(col) { return Number(col.sort) != 0 }
+                ).sort( 
+                    function(a, b) { 
+                        if (Math.abs(a.sort) < Math.abs(b.sort))
+                            return -1;
+                        return 1;
+                    }
+                );
+
+                if (sortList.length) {
+                    grid.dataProvider.sort = sortList.map(function(col) {
+                        var blob = {};
+                        blob[col.name] = col.sort < 0 ? 'desc' : 'asc';
+                        return blob;
+                    });
+                }
+            }
+
+            // builds a sort expression using a single column, 
+            // toggling between ascending and descending sort.
+            $scope.quickSort = function(col_name) {
+                var sort = grid.dataProvider.sort;
+                if (sort && sort.length &&
+                    sort[0] == col_name) {
+                    var blob = {};
+                    blob[col_name] = 'desc';
+                    grid.dataProvider.sort = [blob];
+                } else {
+                    grid.dataProvider.sort = [col_name];
+                }
+
+                grid.offset = 0;
+                grid.collect();
+            }
+
+            // show / hide the grid configuration row
+            $scope.toggleConfDisplay = function() {
+                if ($scope.showGridConf) {
+                    $scope.showGridConf = false;
+                    if (grid.columnsProvider.hasSortableColumn()) {
+                        // only refresh the grid if the user has the
+                        // ability to modify the sort priorities.
+                        grid.compileSort();
+                        grid.offset = 0;
+                        grid.collect();
+                    }
+                } else {
+                    $scope.showGridConf = true;
+                }
+
+                $scope.gridColumnPickerIsOpen = false;
+            }
+
+            // called when a dragged column is dropped onto itself
+            // or any other column
+            grid.onColumnDrop = function(target) {
+                if (angular.isUndefined(target)) return;
+                if (target == grid.dragColumn) return;
+                var srcIdx, targetIdx, srcCol;
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col, idx) {
+                        if (col.name == grid.dragColumn) {
+                            srcIdx = idx;
+                            srcCol = col;
+                        } else if (col.name == target) {
+                            targetIdx = idx;
+                        }
+                    }
+                );
+
+                if (srcIdx < targetIdx) targetIdx--;
+
+                // move src column from old location to new location in 
+                // the columns array, then force a page refresh
+                grid.columnsProvider.columns.splice(srcIdx, 1);
+                grid.columnsProvider.columns.splice(targetIdx, 0, srcCol);
+                $scope.$apply(); 
+            }
+
+            // prepares a string for inclusion within a CSV document
+            // by escaping commas and quotes and removing newlines.
+            grid.csvDatum = function(str) {
+                str = ''+str;
+                if (!str) return '';
+                str = str.replace(/\n/g, '');
+                if (str.match(/\,/) || str.match(/"/)) {                                     
+                    str = str.replace(/"/g, '""');
+                    str = '"' + str + '"';                                           
+                } 
+                return str;
+            }
+
+            // sets the download file name and inserts the current CSV
+            // into a Blob URL for browser download.
+            $scope.generateCSVExportURL = function() {
+                $scope.gridColumnPickerIsOpen = false;
+
+                // let the file name describe the grid
+                $scope.csvExportFileName = 
+                    ($scope.mainLabel || grid.persistKey || 'eg_grid_data')
+                    .replace(/\s+/g, '_') + '_' + $scope.page();
+
+                // toss the CSV into a Blob and update the export URL
+                var csv = grid.generateCSV();
+                var blob = new Blob([csv], {type : 'text/plain'});
+                $scope.csvExportURL = 
+                    ($window.URL || $window.webkitURL).createObjectURL(blob);
+            }
+
+            $scope.printCSV = function() {
+                $scope.gridColumnPickerIsOpen = false;
+                egCore.hatch.print('default', 'text/plain', grid.generateCSV())
+                .then(function() { console.debug('print complete') });
+            }
+
+            // generates CSV for the currently visible grid contents
+            grid.generateCSV = function() {
+                var csvStr = '';
+                var colCount = grid.columnsProvider.columns.length;
+
+                // columns
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col) {
+                        if (!col.visible) return;
+                        csvStr += grid.csvDatum(col.label);
+                        csvStr += ',';
+                    }
+                );
+
+                csvStr = csvStr.replace(/,$/,'\n');
+
+                // items
+                angular.forEach($scope.items, function(item) {
+                    angular.forEach(grid.columnsProvider.columns, 
+                        function(col) {
+                            if (!col.visible) return;
+                            // bare value
+                            var val = grid.dataProvider.itemFieldValue(item, col);
+                            // filtered value (dates, etc.)
+                            val = $filter('egGridValueFilter')(val, col);
+                            csvStr += grid.csvDatum(val);
+                            csvStr += ',';
+                        }
+                    );
+                    csvStr = csvStr.replace(/,$/,'\n');
+                });
+
+                return csvStr;
+            }
+
+            // Interpolate the value for column.linkpath within the context
+            // of the row item to generate the final link URL.
+            $scope.generateLinkPath = function(col, item) {
+                return egCore.strings.$replace(col.linkpath, {item : item});
+            }
+
+            // If a column provides its own HTML template, translate it,
+            // using the current item for the template scope.
+            // note: $sce is required to avoid security restrictions and
+            // is OK here, since the template comes directly from a
+            // local HTML template (not user input).
+            $scope.translateCellTemplate = function(col, item) {
+                var html = egCore.strings.$replace(col.template, {item : item});
+                return $sce.trustAsHtml(html);
+            }
+
+            $scope.collect = function() { grid.collect() }
+
+            // asks the dataProvider for a page of data
+            grid.collect = function() {
+
+                // avoid firing the collect if there is nothing to collect.
+                if (grid.selfManagedData && !grid.dataProvider.query) return;
+
+                if (grid.collecting) return; // avoid parallel collect()
+                grid.collecting = true;
+
+                console.debug('egGrid.collect() offset=' 
+                    + grid.offset + '; limit=' + grid.limit);
+
+                // ensure all of our dropdowns are closed
+                // TODO: git rid of these and just use dropdown-toggle, 
+                // which is more reliable.
+                $scope.gridColumnPickerIsOpen = false;
+                $scope.gridRowCountIsOpen = false;
+                $scope.gridPageSelectIsOpen = false;
+
+                $scope.items = [];
+                $scope.selected = {};
+                grid.dataProvider.get(grid.offset, grid.limit).then(
+                function() {
+                    if (grid.controls.allItemsRetrieved)
+                        grid.controls.allItemsRetrieved();
+                },
+                null, 
+                function(item) {
+                    if (item) {
+                        $scope.items.push(item)
+                        if (grid.controls.itemRetrieved)
+                            grid.controls.itemRetrieved(item);
+                    }
+                }).finally(function() { 
+                    console.debug('egGrid.collect() complete');
+                    grid.collecting = false 
+                });
+            }
+
+            grid.init();
+        }]
+    };
+})
+
+/**
+ * eg-grid-field : used for collecting custom field data from the templates.
+ * This directive does not direct display, it just passes data up to the 
+ * parent grid.
+ */
+.directive('egGridField', function() {
+    return {
+        require : '^egGrid',
+        restrict : 'AE',
+        scope : {
+            name  : '@', // required; unique name
+            path  : '@', // optional; flesh path
+            label : '@', // optional; display label
+            flex  : '@',  // optional; default flex width
+            dateformat : '@', // optional: passed down to egGridValueFilter
+
+            // if a field is part of an IDL object, but we are unable to
+            // determine the class, because it's nested within a hash
+            // (i.e. we can't navigate directly to the object via the IDL),
+            // idlClass lets us specify the class.  This is particularly
+            // useful for nested wildcard fields.
+            parentIdlClass : '@', 
+
+            // optional: for non-IDL columns, specifying a datatype
+            // lets the caller control which display filter is used.
+            // datatype should match the standard IDL datatypes.
+            datatype : '@'
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+
+            // boolean fields are presented as value-less attributes
+            angular.forEach(
+                [
+                    'visible', 
+                    'hidden', 
+                    'sortable', 
+                    'nonsortable',
+                    'multisortable',
+                    'nonmultisortable',
+                    'required' // if set, always fetch data for this column
+                ],
+                function(field) {
+                    if (angular.isDefined(attrs[field]))
+                        scope[field] = true;
+                }
+            );
+
+            // any HTML content within the field is its custom template
+            var tmpl = element.html();
+            if (tmpl && !tmpl.match(/^\s*$/))
+                scope.template = tmpl
+
+            egGridCtrl.columnsProvider.add(scope);
+            scope.$destroy();
+        }
+    };
+})
+
+/**
+ * eg-grid-action : used for specifying actions which may be applied
+ * to items within the grid.
+ */
+.directive('egGridAction', function() {
+    return {
+        require : '^egGrid',
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+            label   : '@', // Action label
+            handler : '=',  // Action function handler
+            divider : '='
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+            egGridCtrl.addAction({
+                label : scope.label,
+                divider : scope.divider,
+                handler : scope.handler
+            });
+            scope.$destroy();
+        }
+    };
+})
+
+.factory('egGridColumnsProvider', ['egCore', function(egCore) {
+
+    function ColumnsProvider(args) {
+        var cols = this;
+        cols.columns = [];
+        cols.stockVisible = [];
+        cols.idlClass = args.idlClass;
+        cols.defaultToHidden = args.defaultToHidden;
+        cols.defaultToNoSort = args.defaultToNoSort;
+        cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
+
+        // resets column width, visibility, and sort behavior
+        // Visibility resets to the visibility settings defined in the 
+        // template (i.e. the original egGridField values).
+        cols.reset = function() {
+            angular.forEach(cols.columns, function(col) {
+                col.flex = 2;
+                col.sort = 0;
+                if (cols.stockVisible.indexOf(col.name) > -1) {
+                    col.visible = true;
+                } else {
+                    col.visible = false;
+                }
+            });
+        }
+
+        // returns true if any columns are sortable
+        cols.hasSortableColumn = function() {
+            return cols.columns.filter(
+                function(col) {
+                    return col.sortable || col.multisortable;
+                }
+            ).length > 0;
+        }
+
+        cols.showAllColumns = function() {
+            $scope.gridColumnPickerIsOpen = false;
+            angular.forEach(cols.columns, function(column) {
+                column.visible = true;
+            });
+            if (grid.selfManagedData) grid.collect();
+        }
+
+        cols.hideAllColumns = function() {
+            $scope.gridColumnPickerIsOpen = false;
+            angular.forEach(cols.columns, function(col) {
+                delete col.visible;
+            });
+            // note: no need to fetch new data if no columns are visible
+        }
+
+        cols.indexOf = function(name) {
+            for (var i = 0; i < cols.columns.length; i++) {
+                if (cols.columns[i].name == name) 
+                    return i;
+            }
+            return -1;
+        }
+
+        cols.findColumn = function(name) {
+            return cols.columns[cols.indexOf(name)];
+        }
+
+        cols.compileAutoColumns = function() {
+            var idl_class = egCore.idl.classes[cols.idlClass];
+
+            angular.forEach(
+                idl_class.fields.sort(
+                    function(a, b) { return a.name < b.name ? -1 : 1 }),
+                function(field) {
+                    if (field.virtual) return;
+                    if (field.datatype == 'link' || field.datatype == 'org_unit') {
+                        // if the field is a link and the linked class has a
+                        // "selector" field specified, use the selector field
+                        // as the display field for the columns.
+                        // flattener will take care of the fleshing.
+                        if (field['class']) {
+                            var selector_field = egCore.idl.classes[field['class']].fields
+                                .filter(function(f) { return Boolean(f.selector) })[0];
+                            if (selector_field) {
+                                field.path = field.name + '.' + selector_field.selector;
+                            }
+                        }
+                    }
+                    cols.add(field, true);
+                }
+            );
+        }
+
+        // if a column definition has a path with a wildcard, create
+        // columns for all non-virtual fields at the specified 
+        // position in the path.
+        cols.expandPath = function(colSpec) {
+
+            var dotpath = colSpec.path.replace(/\.?\*$/,'');
+            var class_obj;
+
+            if (colSpec.parentIdlClass) {
+                class_obj = egCore.idl.classes[colSpec.parentIdlClass];
+
+            } else {
+
+                class_obj = egCore.idl.classes[cols.idlClass];
+                if (!class_obj) return;
+
+                var path_parts = dotpath.split(/\./);
+
+                // find the IDL class definition for the last element in the
+                // path before the .*
+                // an empty path_parts means expand the root class
+                if (path_parts) {
+                    for (var path_idx in path_parts) {
+                        var part = path_parts[path_idx];
+                        var idl_field = class_obj.field_map[part];
+
+                        // unless we're at the end of the list, this field should
+                        // link to another class.
+                        if (idl_field && idl_field['class'] && (
+                            idl_field.datatype == 'link' || 
+                            idl_field.datatype == 'org_unit')) {
+                            class_obj = egCore.idl.classes[idl_field['class']];
+                        } else {
+                            if (path_idx < (path_parts.length - 1)) {
+                                // we ran out of classes to hop through before
+                                // we ran out of path components
+                                console.error("egGrid: invalid IDL path: " + dotpath);
+                            }
+                        }
+                    }
+                }
+            }
+
+            if (class_obj) {
+                angular.forEach(class_obj.fields, function(field) {
+
+                    // Only show wildcard fields where we have data to show
+                    // Virtual and un-fleshed links will not have any data.
+                    if (field.virtual || (
+                        field.datatype == 'link' || field.datatype == 'org_unit'))
+                        return;
+
+                    var col = cols.cloneFromScope(colSpec);
+                    col.path = dotpath + '.' + field.name;
+                    cols.add(col, false, true, 
+                        {idl_field : field, idl_class : class_obj});
+                });
+
+            } else {
+                console.error(
+                    "egGrid: wildcard path does not resolve to an object: "
+                    + dotpath);
+            }
+        }
+
+        // angular.clone(scopeObject) is not permittable.  Manually copy
+        // the fields over that we need (so the scope object can go away).
+        cols.cloneFromScope = function(colSpec) {
+            return {
+                name  : colSpec.name,
+                label : colSpec.label,
+                path  : colSpec.path,
+                flex  : Number(colSpec.flex) || 2,
+                sort  : Number(colSpec.sort) || 0,
+                required : colSpec.required,
+                linkpath : colSpec.linkpath,
+                template : colSpec.template,
+                visible  : colSpec.visible,
+                hidden   : colSpec.hidden,
+                datatype : colSpec.datatype,
+                sortable : colSpec.sortable,
+                nonsortable      : colSpec.nonsortable,
+                multisortable    : colSpec.multisortable,
+                nonmultisortable : colSpec.nonmultisortable,
+                dateformat       : colSpec.dateformat,
+                parentIdlClass   : colSpec.parentIdlClass
+            };
+        }
+
+
+        // Add a column to the columns collection.
+        // Columns may come from a slim eg-columns-field or 
+        // directly from the IDL.
+        cols.add = function(colSpec, fromIDL, fromExpand, idl_info) {
+
+            // First added column with the specified path takes precedence.
+            // This allows for specific definitions followed by wildcard
+            // definitions.  If a match is found, back out.
+            if (cols.columns.filter(function(c) {
+                return (c.path == colSpec.path) })[0]) {
+                //console.debug('skipping column ' + colSpec.path);
+                return;
+            }
+
+            var column = fromExpand ? colSpec : cols.cloneFromScope(colSpec);
+
+            if (column.path && column.path.match(/\*$/)) 
+                return cols.expandPath(colSpec);
+
+            if (!column.name) column.name = column.path;
+            if (!column.path) column.path = column.name;
+
+            if (column.visible || (!cols.defaultToHidden && !column.hidden))
+                column.visible = true;
+
+            if (column.sortable || (!cols.defaultToNoSort && !column.nonsortable))
+                column.sortable = true;
+
+            if (column.multisortable || 
+                (!cols.defaultToNoMultiSort && !column.nonmultisortable))
+                column.multisortable = true;
+
+            cols.columns.push(column);
+
+            // Track which columns are visible by default in case we
+            // need to reset column visibility
+            if (column.visible) 
+                cols.stockVisible.push(column.name);
+
+            if (fromIDL) return; // directly from egIDL.  nothing left to do.
+
+            // lookup the matching IDL field
+            if (!idl_info && cols.idlClass) 
+                idl_info = cols.idlFieldFromPath(column.path);
+
+            if (!idl_info) {
+                // column is not represented within the IDL
+                column.adhoc = true; 
+                return; 
+            }
+
+            column.datatype = idl_info.idl_field.datatype;
+            
+            if (!column.label) {
+                column.label = idl_info.idl_field.label || column.name;
+                /*
+                // append class label to column label to better differentiate
+                // columns in the selector.
+                // Disabled for now, since it results in columns w/ really
+                // long names, making the grid unappealing when any of
+                // these colmns are selected.
+                // TODO: consider nesting the colum picker by class?
+                if (fromExpand) {
+                    var label = 
+                        idl_info.idl_class.label || idl_info.idl_class.name;
+                    column.label = label + '::' + column.label;
+                }
+                */
+            }
+        },
+
+        // finds the IDL field from the dotpath, using the columns
+        // idlClass as the base.
+        cols.idlFieldFromPath = function(dotpath) {
+            var class_obj = egCore.idl.classes[cols.idlClass];
+            var path_parts = dotpath.split(/\./);
+
+            var idl_field;
+            for (var path_idx in path_parts) {
+                var part = path_parts[path_idx];
+                idl_field = class_obj.field_map[part];
+
+                if (idl_field && idl_field['class'] && (
+                    idl_field.datatype == 'link' || 
+                    idl_field.datatype == 'org_unit')) {
+                    class_obj = egCore.idl.classes[idl_field['class']];
+                }
+                // else, path is not in the IDL, which is fine
+            }
+
+            if (!idl_field) return null;
+
+            return {
+                idl_field :idl_field,
+                idl_class : class_obj
+            };
+        }
+    }
+
+    return {
+        instance : function(args) { return new ColumnsProvider(args) }
+    }
+}])
+
+
+/*
+ * Generic data provider template class.  This is basically an abstract
+ * class factory service whose instances can be locally modified to 
+ * meet the needs of each individual grid.
+ */
+.factory('egGridDataProvider', 
+           ['$q','$timeout','$filter','egCore',
+    function($q , $timeout , $filter , egCore) {
+
+        function GridDataProvider(args) {
+            var gridData = this;
+            if (!args) args = {};
+
+            gridData.sort = [];
+            gridData.get = args.get;
+            gridData.query = args.query;
+            gridData.idlClass = args.idlClass;
+            gridData.columnsProvider = args.columnsProvider;
+
+            // Delivers a stream of array data via promise.notify()
+            // Useful for passing an array of data to egGrid.get()
+            // If a count is provided, the array will be trimmed to
+            // the range defined by count and offset
+            gridData.arrayNotifier = function(arr, offset, count) {
+                if (!arr || arr.length == 0) return $q.when();
+                if (count) arr = arr.slice(offset, offset + count);
+                var def = $q.defer();
+                // promise notifications are only witnessed when delivered
+                // after the caller has his hands on the promise object
+                $timeout(function() {
+                    angular.forEach(arr, def.notify);
+                    def.resolve();
+                });
+                return def.promise;
+            }
+
+            // Calls the grid refresh function.  Once instantiated, the
+            // grid will replace this function with it's own refresh()
+            gridData.refresh = function(noReset) { }
+
+            if (!gridData.get) {
+                // returns a promise whose notify() delivers items
+                gridData.get = function(index, count) {
+                    console.error("egGridDataProvider.get() not implemented");
+                }
+            }
+
+            // attempts a flat field lookup first.  If the column is not
+            // found on the top-level object, attempts a nested lookup
+            // TODO: consider a caching layer to speed up template 
+            // rendering, particularly for nested objects?
+            gridData.itemFieldValue = function(item, column) {
+                if (column.name in item) {
+                    if (typeof item[column.name] == 'function') {
+                        return item[column.name]();
+                    } else {
+                        return item[column.name];
+                    }
+                } else {
+                    return gridData.nestedItemFieldValue(item, column);
+                }
+            }
+
+            // TODO: deprecate me
+            gridData.flatItemFieldValue = function(item, column) {
+                console.warn('gridData.flatItemFieldValue deprecated; '
+                    + 'leave provider.itemFieldValue unset');
+                return item[column.name];
+            }
+
+            // given an object and a dot-separated path to a field,
+            // extract the value of the field.  The path can refer
+            // to function names or object attributes.  If the final
+            // value is an IDL field, run the value through its
+            // corresponding output filter.
+            gridData.nestedItemFieldValue = function(obj, column) {
+                if (obj === null || obj === undefined || obj === '') return '';
+                if (!column.path) return obj;
+
+                var idl_field;
+                var parts = column.path.split('.');
+
+                angular.forEach(parts, function(step, idx) {
+                    // object is not fleshed to the expected extent
+                    if (!obj || typeof obj != 'object') {
+                        obj = '';
+                        return;
+                    }
+
+                    var cls = obj.classname;
+                    if (cls && (class_obj = egCore.idl.classes[cls])) {
+                        idl_field = class_obj.field_map[step];
+                        obj = obj[step] ? obj[step]() : '';
+                    } else {
+                        if (angular.isFunction(obj[step])) {
+                            obj = obj[step]();
+                        } else {
+                            obj = obj[step];
+                        }
+                    }
+                });
+
+                // We found a nested IDL object which may or may not have 
+                // been configured as a top-level column.  Grab the datatype.
+                if (idl_field && !column.datatype) 
+                    column.datatype = idl_field.datatype;
+
+                if (obj === null || obj === undefined || obj === '') return '';
+                return obj;
+            }
+        }
+
+        return {
+            instance : function(args) {
+                return new GridDataProvider(args);
+            }
+        };
+    }
+])
+
+
+// Factory service for egGridDataManager instances, which are
+// responsible for collecting flattened grid data.
+.factory('egGridFlatDataProvider', 
+           ['$q','egCore','egGridDataProvider',
+    function($q , egCore , egGridDataProvider) {
+
+        return {
+            instance : function(args) {
+                var provider = egGridDataProvider.instance(args);
+
+                provider.get = function(offset, count) {
+
+                    // no query means no call
+                    if (!provider.query || 
+                            angular.equals(provider.query, {})) 
+                        return $q.when();
+
+                    // find all of the currently visible columns
+                    var queryFields = {}
+                    angular.forEach(provider.columnsProvider.columns, 
+                        function(col) {
+                            // only query IDL-tracked columns
+                            if (!col.adhoc && (col.required || col.visible))
+                                queryFields[col.name] = col.path;
+                        }
+                    );
+
+                    return egCore.net.request(
+                        'open-ils.fielder',
+                        'open-ils.fielder.flattened_search',
+                        egCore.auth.token(), provider.idlClass, 
+                        queryFields, provider.query,
+                        {   sort : provider.sort,
+                            limit : count,
+                            offset : offset
+                        }
+                    );
+                }
+                //provider.itemFieldValue = provider.flatItemFieldValue;
+                return provider;
+            }
+        };
+    }
+])
+
+.directive('egGridColumnDragSource', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+            angular.element(element).attr('draggable', 'true');
+
+            element.bind('dragstart', function(e) {
+                egGridCtrl.dragColumn = attrs.column;
+                egGridCtrl.dragType = attrs.dragType || 'move'; // or resize
+                egGridCtrl.colResizeDir = 0;
+
+                if (egGridCtrl.dragType == 'move') {
+                    // style the column getting moved
+                    angular.element(e.target).addClass(
+                        'eg-grid-column-move-handle-active');
+                }
+            });
+
+            element.bind('dragend', function(e) {
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass(
+                        'eg-grid-column-move-handle-active');
+                }
+            });
+        }
+    };
+})
+
+.directive('egGridColumnDragDest', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+
+            element.bind('dragover', function(e) { // required for drop
+                e.stopPropagation();
+                e.preventDefault();
+                e.dataTransfer.dropEffect = 'move';
+
+                if (egGridCtrl.colResizeDir == 0) return; // move
+
+                var cols = egGridCtrl.columnsProvider;
+                var srcCol = egGridCtrl.dragColumn;
+                var srcColIdx = cols.indexOf(srcCol);
+
+                if (egGridCtrl.colResizeDir == -1) {
+                    if (cols.indexOf(attrs.column) <= srcColIdx) {
+                        egGridCtrl.modifyColumnFlex(
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), -1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column shrinks by one, column to the
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], 1);
+                        }
+                        scope.$apply();
+                    }
+                } else {
+                    if (cols.indexOf(attrs.column) > srcColIdx) {
+                        egGridCtrl.modifyColumnFlex( 
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), 1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column grows by one, column to the 
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], -1);
+                        }
+
+                        scope.$apply();
+                    }
+                }
+            });
+
+            element.bind('dragenter', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).addClass('eg-grid-col-hover');
+                } else {
+                    // resize grips are on the right side of each column.
+                    // dragenter will either occur on the source column 
+                    // (dragging left) or the column to the right.
+                    if (egGridCtrl.colResizeDir == 0) {
+                        if (egGridCtrl.dragColumn == attrs.column) {
+                            egGridCtrl.colResizeDir = -1; // west
+                        } else {
+                            egGridCtrl.colResizeDir = 1; // east
+                        }
+                    }
+                }
+            });
+
+            element.bind('dragleave', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                }
+            });
+
+            element.bind('drop', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                egGridCtrl.colResizeDir = 0;
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                    egGridCtrl.onColumnDrop(attrs.column); // move the column
+                }
+            });
+        }
+    };
+})
+ 
+.directive('egGridMenuItem', function() {
+    return {
+        restrict : 'AE',
+        require : '^egGrid',
+        scope : {
+            label : '@',  
+            handler : '=', // onclick handler function
+            divider : '=', // if true, show a divider only
+            handlerData : '=', // if set, passed as second argument to handler
+            disabled : '=', // function
+            hidden : '=' // function
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+            egGridCtrl.addMenuItem({
+                label : scope.label,
+                handler : scope.handler,
+                divider : scope.divider,
+                disabled : scope.disabled,
+                hidden : scope.hidden,
+                handlerData : scope.handlerData
+            });
+            scope.$destroy();
+        }
+    };
+})
+
+
+
+/**
+ * Translates bare IDL object values into display values.
+ * 1. Passes dates through the angular date filter
+ * 2. Translates bools to Booleans so the browser can display translated 
+ *    value.  (Though we could manually translate instead..)
+ * Others likely to follow...
+ */
+.filter('egGridValueFilter', ['$filter', function($filter) {                         
+    return function(value, column) {                                             
+        switch(column.datatype) {                                                
+            case 'bool':                                                       
+                switch(value) {
+                    // Browser will translate true/false for us                    
+                    case 't' : 
+                    case '1' :  // legacy
+                    case true:
+                        return ''+true;
+                    case 'f' : 
+                    case '0' :  // legacy
+                    case false:
+                        return ''+false;
+                    // value may be null,  '', etc.
+                    default : return '';
+                }
+            case 'timestamp':                                                  
+                // canned angular date filter FTW                              
+                if (!column.dateformat) 
+                    column.dateformat = 'shortDate';
+                return $filter('date')(value, column.dateformat);
+            case 'money':                                                  
+                return $filter('currency')(value);
+            default:                                                           
+                return value;                                                  
+        }                                                                      
+    }                                                                          
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/hatch.js b/Open-ILS/web/js/ui/default/staff/services/hatch.js
new file mode 100644
index 0000000..9e41d91
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/hatch.js
@@ -0,0 +1,428 @@
+/**
+ * Core Service - egHatch
+ *
+ * Dispatches print and data storage requests to the appropriate handler.
+ *
+ * With each top-level request, if a connection to Hatch is established,
+ * the request is relayed.  If a connection has not been attempted, an
+ * attempt is made then the request is handled.  If Hatch is known to be
+ * inaccessible, requests are routed to local handlers.
+ *
+ * Most handlers also provide direct remote and local variants to the
+ * application can decide to which to use as needed.
+ *
+ * Local storage requests are handled by $window.localStorage.
+ *
+ * Note that all top-level and remote requests return promises.  All
+ * local requests return immediate values, since local requests are
+ * never asynchronous.
+ *
+ * BEWARE: never store "fieldmapper" objects, since their structure
+ * may change over time as the IDL changes.  Always flatten objects
+ * into key/value pairs before calling set*Item()
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egHatch',
+           ['$q','$window','$timeout','$interpolate','$http',
+    function($q , $window , $timeout , $interpolate , $http) {
+
+    var service = {};
+    service.msgId = 0;
+    service.messages = {};
+    service.pending = [];
+    service.socket = null;
+    service.hatchAvailable = null;
+    service.defaultHatchURL = 'wss://localhost:8443/hatch'; 
+
+    // write a message to the Hatch websocket
+    service.sendToHatch = function(msg) {
+        var msg2 = {};
+
+        // shallow copy and scrub msg before sending
+        angular.forEach(msg, function(val, key) {
+            if (key.match(/deferred/)) return;
+            msg2[key] = val;
+        });
+
+        console.debug("sending to Hatch: " + JSON.stringify(msg2,null,2));
+        service.socket.send(JSON.stringify(msg2));
+    }
+
+    // Send the request to Hatch if it's available.  
+    // Otherwise handle the request locally.
+    service.attemptHatchDelivery = function(msg) {
+
+        msg.msgid = service.msgId++;
+        msg.deferred = $q.defer();
+
+        if (service.hatchAvailable === false) { // Hatch is closed
+            msg.deferred.reject(msg);
+
+        } else if (service.hatchAvailable === true) { // Hatch is open
+            // Hatch is known to be open
+            service.messages[msg.msgid] = msg;
+            service.sendToHatch(msg);
+
+        } else {  // Hatch status unknown; attempt to connect
+            service.messages[msg.msgid] = msg;
+            service.pending.push(msg);
+            service.hatchConnect();
+        }
+
+        return msg.deferred.promise;
+    }
+
+
+    // resolve the promise on the given request and remove
+    // it from our tracked requests.
+    service.resolveRequest = function(msg) {
+
+        if (!service.messages[msg.msgid]) {
+            console.warn('no cached message for ' 
+                + msg.msgid + ' : ' + JSON.stringify(msg, null, 2));
+            return;
+        }
+
+        // for requests sent through Hatch, only the cached 
+        // request will have the original promise attached
+        msg.deferred = service.messages[msg.msgid].deferred;
+        delete service.messages[msg.msgid]; // un-cache
+
+        // resolve / reject
+        if (msg.error) {
+            throw new Error(
+            "egHatch command failed : " 
+                + JSON.stringify(msg.error, null, 2));
+        } else {
+            msg.deferred.resolve(msg.content);
+        } 
+    }
+
+    service.hatchClosed = function() {
+        service.socket = null;
+        service.printers = [];
+        service.printConfig = {};
+        while ( (msg = service.pending.shift()) ) {
+            msg.deferred.reject(msg);
+            delete service.messages[msg.msgid];
+        }
+        if (service.onHatchClose)
+            service.onHatchClose();
+    }
+
+    service.hatchURL = function() {
+        return service.getLocalItem('eg.hatch.url') 
+            || service.defaultHatchURL;
+    }
+
+    // Returns true if Hatch is required or if we are currently
+    // communicating with the Hatch service. 
+    service.usingHatch = function() {
+        return service.hatchAvailable || service.hatchRequired();
+    }
+
+    // Returns true if this browser (via localStorage) is 
+    // configured to require Hatch.
+    service.hatchRequired = function() {
+        return service.getLocalItem('eg.hatch.required');
+    }
+
+    service.hatchConnect = function() {
+
+        if (service.socket && 
+            service.socket.readyState == service.socket.CONNECTING) {
+            // connection in progress.  Nothing to do.  Our queued
+            // message will be delivered when onopen() fires
+            return;
+        }
+
+        try {
+            service.socket = new WebSocket(service.hatchURL());
+        } catch(e) {
+            service.hatchAvailable = false;
+            service.hatchClosed();
+            return;
+        }
+
+        service.socket.onopen = function() {
+            console.debug('connected to Hatch');
+            service.hatchAvailable = true;
+            if (service.onHatchOpen) 
+                service.onHatchOpen();
+            while ( (msg = service.pending.shift()) ) {
+                service.sendToHatch(msg);
+            };
+        }
+
+        service.socket.onclose = function() {
+            if (service.hatchAvailable === false) return; // already registered
+
+            // onclose() will be called regularly as we disconnect from
+            // Hatch via timeouts.  Return hatchAvailable to its unknow state
+            service.hatchAvailable = null;
+            service.hatchClosed();
+        }
+
+        service.socket.onerror = function() {
+            if (service.hatchAvailable === false) return; // already registered
+            service.hatchAvailable = false;
+            console.debug(
+                "unable to connect to Hatch server at " + service.hatchURL());
+            service.hatchClosed();
+        }
+
+        service.socket.onmessage = function(evt) {
+            var msgStr = evt.data;
+            if (!msgStr) throw new Error("Hatch returned empty message");
+
+            var msgObj = JSON.parse(msgStr);
+            console.debug('Hatch says ' + JSON.stringify(msgObj, null, 2));
+            service.resolveRequest(msgObj); 
+        }
+    }
+
+    service.getPrintConfig = function() {
+        if (service.printConfig) 
+            return $q.when(service.printConfig);
+
+        return service.getRemoteItem('eg.print.config')
+        .then(function(conf) { 
+            return (service.printConfig = conf || {}) 
+        });
+    }
+
+    service.setPrintConfig = function(conf) {
+        service.printConfig = conf;
+        return service.setRemoteItem('eg.print.config', conf);
+    }
+
+
+    service.remotePrint = function(
+        context, contentType, content, withDialog) {
+
+        return service.getPrintConfig().then(
+            function(conf) {
+                // print configuration retrieved; print
+                return service.attemptHatchDelivery({
+                    action : 'print',
+                    config : conf[context],
+                    content : content, 
+                    contentType : contentType,
+                    showDialog : withDialog,
+                });
+            }
+        );
+    }
+
+    // launch the print dialog then attach the resulting configuration
+    // to the requested context, then store the final values.
+    service.configurePrinter = function(context, printer) {
+
+        // load current settings
+        return service.getPrintConfig()
+
+        // dispatch the print configuration request
+        .then(function(config) {
+
+            // loaded remote config
+            if (!config[context]) config[context] = {};
+            config[context].printer = printer;
+            return service.attemptHatchDelivery({
+                key : 'no-op', 
+                action : 'print-config',
+                config : config[context]
+            })
+        })
+
+        // set the returned settings to the requested context
+        .then(function(newconf) {
+            if (angular.isObject(newconf)) {
+                newconf.printer = printer;
+                return service.printConfig[context] = newconf;
+            } else {
+                console.warn("configurePrinter() returned " + newconf);
+            }
+        })
+
+        // store the newly linked settings
+        .then(function() {
+            service.setItem('eg.print.config', service.printConfig);
+        })
+
+        // return the final settings to the caller
+        .then(function() {return service.printConfig});
+    }
+
+    service.getPrinters = function() {
+        if (service.printers) // cached printers
+            return $q.when(service.printers);
+
+        return service.attemptHatchDelivery({action : 'printers'}).then(
+
+            // we have remote printers; sort by name and return
+            function(printers) {
+                service.printers = printers.sort(
+                    function(a,b) {return a.name < b.name ? -1 : 1});
+                return service.printers;
+            },
+
+            // remote call failed and there is no such thing as local
+            // printers; return empty set.
+            function() { return [] } 
+        );
+    }
+
+    // get the value for a stored item
+    service.getItem = function(key) {
+        return service.getRemoteItem(key)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to getItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                return service.getLocalItem(msg.key);
+            }
+        );
+    }
+
+    service.getRemoteItem = function(key) {
+        return service.attemptHatchDelivery({
+            key : key,
+            action : 'get', 
+        });
+    }
+
+    service.getLocalItem = function(key) {
+        var val = $window.localStorage.getItem(key);
+        if (val == null) return;
+        return JSON.parse(val);
+    }
+
+    service.setItem = function(key, value) {
+        var str = JSON.stringify(value);
+        return service.setRemoteItem(key, str)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to setItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                return service.setLocalItem(msg.key, null, str);
+            }
+        );
+    }
+
+    // set the value for a stored or new item
+    service.setRemoteItem = function(key, value) {
+        return service.attemptHatchDelivery({
+            key : key, 
+            value : value, 
+            action : 'set',
+        });
+    }
+
+    // Set the value for the given key
+    // If the value is raw, pass it as 'value'.  If it was
+    // externally JSONified, pass it via jsonified.
+    service.setLocalItem = function(key, value, jsonified) {
+        if (jsonified === undefined ) 
+            jsonified = JSON.stringify(value);
+        $window.localStorage.setItem(key, jsonified);
+    }
+
+    // appends the value to the existing item stored at key.
+    // If not item is found at key, this behaves just like setItem()
+    service.appendItem = function(key, value) {
+        return service.appendRemoteItem(key, value)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to appendItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                service.appendLocalItem(msg.key, msg.value);
+            }
+        );
+    }
+
+    service.appendRemoteItem = function(key, value) {
+        return service.attemptHatchDelivery({
+            key : key, 
+            value : value, 
+            action : 'append',
+        });
+    }
+
+    // assumes the appender and appendee are both strings
+    // TODO: support arrays as well
+    service.appendLocalItem = function(key, value) {
+        var item = service.getLocalItem(key);
+        if (item) {
+            if (typeof item != 'string') {
+                logger.warn("egHatch.appendLocalItem => "
+                    + "cannot append to a non-string item: " + key);
+                return;
+            }
+            value = item + value; // concatenate our value
+        }
+        service.setLocalitem(key, value);
+    }
+
+    // remove a stored item
+    service.removeItem = function(key) {
+        return service.removeRemoteItem(key)['catch'](
+            function(msg) { 
+                return service.removeLocalItem(msg.key) 
+            }
+        );
+    }
+
+    service.removeRemoteItem = function(key) {
+        return service.attemptHatchDelivery({
+            key : key,
+            action : 'remove'
+        });
+    }
+
+    service.removeLocalItem = function(key) {
+        $window.localStorage.removeItem(key);
+    }
+
+    // if set, prefix limits the return set to keys starting with 'prefix'
+    service.getKeys = function(prefix) {
+        return service.getRemoteKeys(prefix)['catch'](
+            function() { 
+                if (service.hatchRequired()) {
+                    console.error("Unable to get pref keys; "
+                     + "hatchRequired=true, but hatch is not connected");
+                     return [];
+                }
+                return service.getLocalKeys(prefix) 
+            }
+        );
+    }
+
+    service.getRemoteKeys = function(prefix) {
+        return service.attemptHatchDelivery({
+            key : prefix,
+            action : 'keys'
+        });
+    }
+
+    service.getLocalKeys = function(prefix) {
+        var keys = [];
+        var idx = 0;
+        while ( (k = $window.localStorage.key(idx++)) !== null) {
+            // key prefix match test
+            if (prefix && k.substr(0, prefix.length) != prefix) continue; 
+            keys.push(k);
+        }
+        return keys;
+    }
+
+    return service;
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/idl.js b/Open-ILS/web/js/ui/default/staff/services/idl.js
new file mode 100644
index 0000000..5934063
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/idl.js
@@ -0,0 +1,141 @@
+/**
+ * Core Service - egIDL
+ *
+ * IDL parser
+ * usage:
+ *  var aou = new egIDL.aou();
+ *  var fullIDL = egIDL.classes;
+ *
+ *  IDL TODO:
+ *
+ * 1. selector field only appears once per class.  We could save
+ *    a lot of IDL (network) space storing it only once at the 
+ *    class level.
+ * 2. we don't need to store array_position in /IDL2js since it
+ *    can be derived at parse time.  Ditto saving space.
+ */
+angular.module('egCoreMod')
+
+.factory('egIDL', ['$window', function($window) {
+
+    var service = {};
+
+    service.parseIDL = function() {
+        //console.debug('egIDL.parseIDL()');
+
+        // retain a copy of the full IDL within the service
+        service.classes = $window._preload_fieldmapper_IDL;
+
+        // keep this reference around (note: not a clone, just a ref)
+        // so that unit tests, which repeatedly instantiate the
+        // service will work.
+        //$window._preload_fieldmapper_IDL = null;
+
+        /**
+         * Creates the class constructor and getter/setter
+         * methods for each IDL class.
+         */
+        function mkclass(cls, fields) {
+
+            service[cls] = function(seed) {
+                this.a = seed || [];
+                this.classname = cls;
+                this._isfieldmapper = true;
+            }
+
+            /** creates the getter/setter methods for each field */
+            angular.forEach(fields, function(field, idx) {
+                service[cls].prototype[fields[idx].name] = function(n) {
+                    if (arguments.length==1) this.a[idx] = n;
+                    return this.a[idx];
+                }
+            });
+
+            // global class constructors required for JSON_v1.js
+            $window[cls] = service[cls]; 
+        }
+
+        for (var cls in service.classes) 
+            mkclass(cls, service.classes[cls].fields);
+    };
+
+    /**
+     * Generate a hash version of an IDL object.
+     *
+     * Flatten determines if nested objects should be squashed into
+     * the top-level hash.
+     *
+     * If 'flatten' is false, e.g.:
+     *
+     * {"call_number" : {"label" :  "foo"}}
+     *
+     * If 'flatten' is true, e.g.:
+     *
+     * e.g.  {"call_number.label" : "foo"}
+     */
+    service.toHash = function(obj, flatten) {
+        if (!angular.isObject(obj)) return obj; // arrays are objects
+
+        if (angular.isArray(obj)) { // NOTE: flatten arrays not supported
+            return obj.map(function(item) {return service.toHash(item)});
+        }
+
+        var field_names = obj.classname ? 
+            Object.keys(service.classes[obj.classname].field_map) :
+            Object.keys(obj);
+
+        var hash = {};
+        angular.forEach(
+            field_names,
+            function(field) { 
+
+                var val = service.toHash(
+                    angular.isFunction(obj[field]) ? 
+                        obj[field]() : obj[field], 
+                    flatten
+                );
+
+                if (flatten && angular.isObject(val)) {
+                    angular.forEach(val, function(sub_val, key) {
+                        var fname = field + '.' + key;
+                        hash[fname] = sub_val;
+                    });
+
+                } else if (val !== undefined) {
+                    hash[field] = val;
+                }
+            }
+        );
+
+        return hash;
+    }
+
+    // Transforms a flattened hash (see toHash() or egGridFlatDataProvider)
+    // to a nested hash.
+    //
+    // e.g. {"call_number.label" : "foo"} => {"call_number":{"label":"foo"}}
+    service.flatToNestedHash = function(obj) {
+        var hash = {};
+        angular.forEach(obj, function(val, key) {
+            var parts = key.split('.');
+            var sub_hash = hash;
+            var last_key;
+            for (var i = 0; i < parts.length; i++) {
+                var part = parts[i];
+                if (i == parts.length - 1) {
+                    sub_hash[part] = val;
+                    break;
+                } else {
+                    if (!sub_hash[part])
+                        sub_hash[part] = {};
+                    sub_hash = sub_hash[part];
+                }
+            }
+        });
+
+        return hash;
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/navbar.js b/Open-ILS/web/js/ui/default/staff/services/navbar.js
new file mode 100644
index 0000000..8806d65
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/navbar.js
@@ -0,0 +1,79 @@
+angular.module('egCoreMod')
+
+.directive('egNavbar', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        templateUrl : 'eg-navbar-template',
+        link : function(scope, element, attrs) {
+
+            // Find all eg-accesskey entries within the menu and attach
+            // hotkey handlers for each.  
+            // jqlite doesn't support selectors, so we have to 
+            // manually navigate to the elements we're interested in.
+            function inspect(elm) {
+                elm = angular.element(elm);
+                if (elm.attr('eg-accesskey')) {
+                    scope.addHotkey(
+                        elm.attr('eg-accesskey'),
+                        elm.attr('href'),
+                        elm.attr('eg-accesskey-desc')
+                    );
+                }
+                angular.forEach(elm.children(), inspect);
+            }
+            inspect(element);
+        },
+
+        controller:['$scope','$window','$location','hotkeys','egCore',
+            function($scope , $window , $location , hotkeys , egCore) {
+
+                function navTo(path) {                                           
+                    // $location.path() does not want a leading ".",
+                    // which <a>'s will have.  
+                    // Note: avoid using $location.path() to derive the new
+                    // URL, since it creates an intermediate path change.
+                    path = path.replace(/^\./,'');
+                    var reg = new RegExp($location.path());
+                    $window.location.href = 
+                        $window.location.href.replace(reg, path);
+                }       
+
+                // adds a keyboard shortcut
+                // http://chieffancypants.github.io/angular-hotkeys/
+                $scope.addHotkey = function(key, path, desc) {                 
+                    hotkeys.add(key, desc, function() { navTo(path) });
+                };
+
+                $scope.applyLocale = function(locale) {
+                    // EGWeb.pm can change the locale for us w/ the right param
+                    // Note: avoid using $location.search() to derive a new
+                    // URL, since it creates an intermediate path change.
+                    // Instead, use the ham-fisted approach of killing any
+                    // search args and applying the args we want.
+                    $window.location.href = 
+                        $window.location.href.replace(
+                            /(\?|\&).*/,
+                            '?set_eg_locale=' + encodeURIComponent(locale)
+                        );
+                }
+
+                // tied to logout link
+                $scope.logout = function() {
+                    egCore.auth.logout();
+                    return true;
+                };
+
+                egCore.startup.go().then(
+                    function() {
+                        if (egCore.auth.user()) {
+                            $scope.username = egCore.auth.user().usrname();
+                            $scope.workstation = egCore.auth.workstation();
+                        }
+                    }
+                );
+            }
+        ]
+    }
+});
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/services/net.js b/Open-ILS/web/js/ui/default/staff/services/net.js
new file mode 100644
index 0000000..156d554
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/net.js
@@ -0,0 +1,100 @@
+/**
+ * Core Service - egNet
+ *
+ * Promise wrapper for OpenSRF network calls.
+ * http://docs.angularjs.org/api/ng.$q
+ *
+ * promise.notify() is called with each streamed response.
+ *
+ * promise.resolve() is called when the request is complete 
+ * and passes as its value the response received from the 
+ * last call to onresponse().  If no calls to onresponse()
+ * were made (i.e. no responses delivered) no value will
+ * be passed to resolve(), hence any value seen by the client
+ * will be 'undefined'.
+ *
+ * Example: Call with one response and no error checking:
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *    function(data) { 
+ *      // data == undefined if no responses were received
+ *      // data == null if last response was a null value
+ *      console.log(data) 
+ *    });
+ *
+ * Example: capture streaming responses, error checking
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *      function(data) { console.log('all done') },
+ *      function(err)  { console.log('error: ' + err) },
+ *      functoin(data) { console.log('received stream response ' + data) }
+ *  );
+ */
+
+angular.module('egCoreMod')
+
+.factory('egNet', 
+       ['$q','$rootScope','egEvent', 
+function($q,  $rootScope,  egEvent) {
+
+    var net = {};
+
+    // raises the egAuthExpired event on NO_SESSION
+    net.checkResponse = function(resp) {
+        var content = resp.content();
+        if (!content) return null;
+        var evt = egEvent.parse(content);
+        if (evt && evt.textcode == 'NO_SESSION') {
+            $rootScope.$broadcast('egAuthExpired') 
+        } else {
+            return content;
+        }
+    };
+
+    net.request = function(service, method) {
+        var last;
+        var deferred = $q.defer();
+        var params = Array.prototype.slice.call(arguments, 2);
+        console.debug('egNet ' + method);
+        new OpenSRF.ClientSession(service).request({
+            async  : true,
+            method : method,
+            params : params,
+            oncomplete : function() {
+                deferred.resolve(last);
+            },
+            onresponse : function(r) {
+                last = net.checkResponse(r.recv());
+                deferred.notify(last);
+            },
+            onerror : function(msg) {
+                // 'msg' currently tells us very little, so don't 
+                // bother JSON-ifying it, since there is the off
+                // chance that JSON-ification could fail, e.g if 
+                // the object has circular refs.
+                console.error(method + 
+                    ' (' + params + ')  failed.  See server logs.');
+                deferred.reject(msg);
+            },
+            onmethoderror : function(req, statCode, statMsg) { 
+                console.error('error calling method ' + 
+                method + ' : ' + statCode + ' : ' + statMsg);
+            }
+
+        }).send();
+
+        return deferred.promise;
+    }
+
+    // In addition to the service and method names, accepts a single array
+    // as the collection of API call parameters.  This array will get 
+    // expanded to individual arguments in the final server call.
+    // This is useful when the server call expects each param to be
+    // a top-level value, but the set of params is dynamic.
+    net.requestWithParamList = function(service, method, params) {
+        var args = [service, method].concat(params);
+        return net.request.apply(net, args);
+    }
+
+    return net;
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/org.js b/Open-ILS/web/js/ui/default/staff/services/org.js
new file mode 100644
index 0000000..8ff7137
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/org.js
@@ -0,0 +1,115 @@
+/**
+ * Core Service - egOrg
+ *
+ * TODO: more docs
+ */
+angular.module('egCoreMod')
+
+.factory('egOrg', 
+       ['$q','egEnv','egAuth','egNet',
+function($q,  egEnv,  egAuth,  egNet) { 
+
+    var service = {};
+
+    // org unit settings cache.
+    // This allows the caller to avoid local caches
+    service.cachedSettings = {};
+
+    service.get = function(node_or_id) {
+        if (typeof node_or_id == 'object')
+            return node_or_id;
+        return egEnv.aou.map[node_or_id];
+    };
+
+    service.list = function() {
+        return egEnv.aou.list;
+    };
+
+    service.tree = function() {
+        return egEnv.aou.tree;
+    }
+
+    // list of org_unit objects or IDs for ancestors + me
+    service.ancestors = function(node_or_id, as_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [node];
+        while( (node = service.get(node.parent_ou())))
+            nodes.push(node);
+        if (as_id) 
+            return nodes.map(function(n){return n.id()});
+        return nodes;
+    };
+
+    // list of org_unit objects  or IDs for me + descendants
+    service.descendants = function(node_or_id, as_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [];
+        function descend(n) {
+            nodes.push(n);
+            angular.forEach(n.children(), descend);
+        }
+        descend(node);
+        if (as_id) 
+            return nodes.map(function(n){return n.id()});
+        return nodes;
+    }
+
+    // list of org_unit objects or IDs for ancestors + me + descendants
+    service.fullPath = function(node_or_id, as_id) {
+        var list = service.ancestors(node_or_id).concat(
+          service.descendants(node_or_id).slice(1));
+        if (as_id) 
+            return list.map(function(n){return n.id()});
+        return list;
+    }
+
+    // returns a promise, resolved with a hash of setting name =>
+    // setting value for the selected org unit.  Org unit defaults to 
+    // auth workstation org unit.
+    service.settings = function(names, ou_id) {
+        var deferred = $q.defer();
+        ou_id = ou_id || egAuth.user().ws_ou();
+        var here = (ou_id == egAuth.user().ws_ou());
+
+        // allow non-array
+        if (!angular.isArray(names)) names = [names];
+        
+        if (here) { 
+            // only cache org settings retrieved for the current 
+            // workstation org unit.
+            var newNames = [];
+            angular.forEach(names, function(name) {
+                if (!angular.isDefined(service.cachedSettings[name]))
+                    newNames.push(name)
+            });
+
+            // only retrieve uncached values
+            names = newNames;
+            if (names.length == 0)
+                return $q.when(service.cachedSettings);
+        }
+
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.ou_setting.ancestor_default.batch',
+            ou_id, names, egAuth.token()
+        ).then(function(blob) {
+            var settings = {};
+            angular.forEach(blob, function(val, key) {
+                // val is either null or a structure containing the value
+                settings[key] = val ? val.value : null;
+                if (here) service.cachedSettings[key] = settings[key];
+            });
+
+            // resolve with cached settings if 'here', since 'settings'
+            // will only contain settings we had to retrieve
+            deferred.resolve(here ? service.cachedSettings : settings);
+        });
+        return deferred.promise;
+    }
+
+    return service;
+}]);
+ 
diff --git a/Open-ILS/web/js/ui/default/staff/services/pcrud.js b/Open-ILS/web/js/ui/default/staff/services/pcrud.js
new file mode 100644
index 0000000..1f78b17
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/pcrud.js
@@ -0,0 +1,298 @@
+/**
+ * Core Service - egPCRUD
+ *
+ * PCRUD client.
+ *
+ * Factory for PCRUDContext objects with pass-through service-level API.
+ *
+ * For most types of communication, where the client expects to make a
+ * single request which egPCRUD manages internally, use the service-
+ * level API.
+ *
+ * All service-level APIs (except connect()) return a promise, whose
+ * notfiy() channels individual responses (think: onresponse) and 
+ * whose resolve() channels the last received response (think: 
+ * oncomplete), consistent with egNet.request().  If only one response
+ * is expected (e.g. retrieve(), or .atomic searches), notify() 
+ * handlers are not required.
+ *
+ * egPCRUD.retrieve('aou', 1)
+ * .then(function(org) { console.log(org.shortname()) });
+ *
+ * egPCRUD.search('aou', {id : [1,2,3]})
+ * .then(function(orgs) { console.log(orgs.length) } );
+ *
+ * egPCRUD.search('aou', {id : {'!=' : null}}, {limit : 10})
+ * .then(...);
+ *
+ * For requests where the caller needs to manually connect and make
+ * individual API calls, the service.connect() call will create and
+ * pass a PCRUDContext object as the argument to the connect promise 
+ * resolver.  The PCRUDContext object can be used to make subsequent 
+ * pcrud calls directly.
+ *
+ * egPCRUD.connnect()
+ * .then(function(ctx) { return ctx.retrieve('aou', 1) })
+ * .then(function(org) { console.log(org.id()); ctx.disconnect() })
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egPCRUD', ['$q','$rootScope','egAuth','egIDL', 
+             function($q , $rootScope , egAuth , egIDL) { 
+    
+    var service = {};
+
+    // create service-level pass through functions 
+    // for one-off PCRUDContext actions.
+    angular.forEach(['connect', 'retrieve', 'retrieveAll', 
+        'search', 'create', 'update', 'remove', 'apply'],
+        function(action) {
+            service[action] = function() {
+                var ctx = new PCRUDContext();
+                return ctx[action].apply(ctx, arguments);
+            }
+        }
+    );
+
+    /*
+     * Since services are singleton objectss, we need an internal 
+     * class to manage individual PCRUD conversations.  
+     */
+    var PCRUDContextIdent = 0; // useful for debug logging
+    function PCRUDContext() {
+        var self = this;
+        this.xact_close_mode = 'rollback';
+        this.ident = PCRUDContextIdent++;
+        this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+
+        this.toString = function() {
+            return '[PCRUDContext ' + this.ident + ']';
+        };
+
+        this.log = function(msg) {
+            console.debug(this + ': ' + msg);
+        };
+
+        this.err = function(msg) {
+            console.error(this + ': ' + msg);
+        };
+
+        this.connect = function() {
+            this.log('connect');
+            var deferred = $q.defer();
+            this.session.connect({onconnect : 
+                function() {deferred.resolve(self)}});
+            return deferred.promise;
+        };
+
+        this.disconnect = function() {
+            this.log('disconnect');
+            this.session.disconnect();
+        };
+
+        this.retrieve = function(fm_class, pkey, pcrud_ops) {
+            return this._dispatch(
+                'open-ils.pcrud.retrieve.' + fm_class,
+                [egAuth.token(), pkey, pcrud_ops]
+            );
+        };
+
+        this.retrieveAll = function(fm_class, pcrud_ops, req_ops) {
+            var search = {};
+            search[egIDL.classes[fm_class].pkey] = {'!=' : null};
+            return this.search(fm_class, search, pcrud_ops, req_ops);
+        };
+
+        this.search = function (fm_class, search, pcrud_ops, req_ops) {
+            req_ops = req_ops || {};
+
+            var return_type = req_ops.idlist ? 'id_list' : 'search';
+            var method = 'open-ils.pcrud.' + return_type + '.' + fm_class;
+
+            if (req_ops.atomic) method += '.atomic';
+
+            return this._dispatch(method, 
+                [egAuth.token(), search, pcrud_ops]);
+        };
+
+        this.create = function(list) {return this.CUD('create', list)};
+        this.update = function(list) {return this.CUD('update', list)};
+        this.remove = function(list) {return this.CUD('delete', list)};
+        this.apply  = function(list) {return this.CUD('apply',  list)};
+
+        this.xactClose = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.' + this.xact_close_mode,
+                [egAuth.token()]
+            );
+        };
+
+        this.xactBegin = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.begin',
+                [egAuth.token()]
+            );
+        };
+
+        this._dispatch = function(method, params) {
+            if (this.authoritative) {
+                return this._wrap_xact(
+                    function() {
+                        return self._send_request(method, params);
+                    }
+                );
+            } else {
+                return this._send_request(method, params)
+            }
+        };
+
+
+        // => connect
+        // => xact_begin 
+        // => action
+        // => xact_close(commit/rollback) 
+        // => disconnect
+        // Returns a promise
+        // main_func should return a promise
+        this._wrap_xact = function(main_func) {
+            var deferred = $q.defer();
+
+            // 1. connect
+            this.connect().then(function() {
+
+            // 2. start the transaction
+            self.xactBegin().then(function() {
+
+            // 3. execute the main body 
+            main_func().then(
+                // main body complete
+                function(lastResp) {  
+
+                    // 4. close the transaction
+                    self.xactClose().then(function() {
+                        // 5. disconnect
+                        self.disconnect();
+                        // 6. all done
+                        deferred.resolve(lastResp);
+                    });
+                },
+
+                // main body error handler
+                function() {}, 
+
+                // main body notify() handler
+                function(data) {deferred.notify(data)}
+            );
+
+            })}); // close 'em all up.
+
+            return deferred.promise;
+        };
+
+        this._send_request = function(method, params) {
+            this.log('_send_request(' + method + ')');
+            var deferred = $q.defer();
+            var lastResp;
+            this.session.request({
+                method : method,
+                params : params,
+                onresponse : function(r) {
+                    var resp = r.recv();
+                    if (resp && (lastResp = resp.content())) {
+                        deferred.notify(lastResp);
+                    } else {
+                        // pcrud requests should always return something
+                        self.err(method + " returned no response");
+                    }
+                },
+                oncomplete : function() {
+                    deferred.resolve(lastResp);
+                },
+
+                onmethoderror : function(req, stat, stat_text) {
+                    self.err(method + " failed. \ncode => " 
+                        + stat + "\nstatus => " + stat_text 
+                        + "\nparams => " + js2JSON(params));
+
+                    if (stat == 401) {
+                        // 401 is the PCRUD equivalent of a NO_SESSION event
+                        $rootScope.$broadcast('egAuthExpired');
+                    }
+
+                    deferred.reject(req);
+                }
+                // Note: no onerror handler for websockets connections,
+                // because errors exist and are reported as top-level
+                // conditions, not request-specific conditions.
+                // Practically every error we care about (minus loss of 
+                // connection) will be reported as a method error.
+            }).send();
+
+            return deferred.promise;
+        };
+
+        this.CUD = function (action, list) {
+            this.log('CUD(): ' + action);
+
+            this.cud_idx = 0;
+            this.cud_action = action;
+            this.xact_close_mode = 'commit';
+            this.cud_list = list;
+            this.cud_deferred = $q.defer();
+
+            if (!angular.isArray(list) || list.classname)
+                this.cud_list = [list];
+
+            return this._wrap_xact(
+                function() {
+                    self._CUD_next_request();
+                    return self.cud_deferred.promise;
+                }
+            );
+        }
+
+        /**
+         * Loops through the list of objects to update and sends
+         * them one at a time to the server for processing.  Once
+         * all are done, the cud_deferred promise is resolved.
+         */
+        this._CUD_next_request = function() {
+
+            if (this.cud_idx >= this.cud_list.length) {
+                this.cud_deferred.resolve(this.cud_last);
+                return;
+            }
+
+            var action = this.cud_action;
+            var fm_obj = this.cud_list[this.cud_idx++];
+
+            if (action == 'auto') {
+                if (fm_obj.ischanged()) action = 'update';
+                if (fm_obj.isnew())     action = 'create';
+                if (fm_obj.isdeleted()) action = 'delete';
+
+                if (action == 'auto') {
+                    // object does not need updating; move along
+                    this._CUD_next_request();
+                }
+            }
+
+            this._send_request(
+                'open-ils.pcrud.' + action + '.' + fm_obj.classname,
+                [egAuth.token(), fm_obj]).then(
+                function(data) {
+                    // update actions return one response.
+                    // no notify() handler needed.
+                    self.cud_last = data;
+                    self.cud_deferred.notify(data);
+                    self._CUD_next_request();
+                }
+            );
+           
+        };
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/print.js b/Open-ILS/web/js/ui/default/staff/services/print.js
new file mode 100644
index 0000000..bcb8aa2
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/print.js
@@ -0,0 +1,199 @@
+/**
+ * egPrint : manage print templates, process templates, print content
+ *
+ * TODO: create configurable links between print template and context.
+ */
+angular.module('egCoreMod')
+
+.factory('egPrint',
+       ['$q','$window','$timeout','$http','egHatch','egAuth','egIDL','egOrg',
+function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg) {
+
+    var service = {};
+
+    service.template_base_path = 'share/print_templates/t_';
+
+    /*
+     * context  : 'default', 'receipt','label', etc. 
+     * scope    : data loaded into the template environment
+     * template : template name (e.g. 'checkout', 'transit_slip'
+     * content  : content to print.  If 'template' is set, content is
+     *            derived from the template.
+     * content_type : 'text/html', 'text/plain', 'text/csv'
+     * show_dialog  : boolean, if true, print dialog is shown.  This setting
+     *                only affects remote printers, since browser printers
+     *                do not allow such control
+     */
+    service.print = function(args) {
+        if (!args) return $q.when();
+
+        if (args.template) {
+            // fetch the template, then proceed to printing
+
+            return service.getPrintTemplate(args.template)
+            .then(function(content) {
+                args.content = content;
+                if (!args.content_type) args.content_type = 'html';
+                return service.print_content(args);
+            });
+
+        } 
+
+        return service.print_content(args);
+    }
+
+    // add commonly used attributes to the print scope
+    service.fleshPrintScope = function(scope) {
+        if (!scope) scope = {};
+        scope.today = new Date().toISOString();
+        scope.staff = egIDL.toHash(egAuth.user());
+        scope.current_location = 
+            egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+    }
+
+    // Template has been fetched (or no template needed) 
+    // Process the template and send the result off to the printer.
+    service.print_content = function(args) {
+        service.fleshPrintScope(args.scope);
+
+        var promise;
+        if (args.content_type == 'text/html') {
+
+            // all HTML content is assumed to require compilation, 
+            // regardless of the print destination
+            promise = service.ingest_print_content(
+                args.content_type, args.content, args.scope);
+
+        } else {
+            // text content does not require compilation for remote printing
+            promise = $q.when();
+        }
+
+        // TODO: link print context to template type
+        var context = args.context || 'default';
+
+        return promise.then(function(html) {
+
+            return egHatch.remotePrint(context,
+                args.content_type, html, args.show_dialog)['catch'](
+
+                function(msg) {
+                    // remote print not available; 
+
+                    if (egHatch.hatchRequired()) {
+                        console.error("Unable to print data; "
+                         + "hatchRequired=true, but hatch is not connected");
+                         return $q.reject();
+                    }
+
+                    if (args.content_type != 'text/html') {
+                        // text content does require compilation 
+                        // (absorption) for browser printing
+                        return service.ingest_print_content(
+                            args.content_type, args.content, args.scope
+                        ).then(function() { $window.print() });
+                    } else {
+                        // HTML content is already ingested and accessible
+                        // within the page to the printer.  
+                        $window.print();
+                    }
+                }
+            );
+        });
+    }
+
+    // loads an HTML print template by name from the server
+    // If no template is available in local/hatch storage, 
+    // fetch the template as an HTML file from the server.
+    service.getPrintTemplate = function(name) {
+        var deferred = $q.defer();
+
+        egHatch.getItem('eg.print.template.' + name)
+        .then(function(html) {
+
+            if (html) {
+                // we have a locally stored template
+                deferred.resolve(html);
+                return;
+            }
+
+            var path = service.template_base_path + name;
+            console.debug('fetching template ' + path);
+
+            $http.get(path)
+            .success(function(data) { deferred.resolve(data) })
+            .error(function() {
+                console.error('unable to locate print template: ' + name);
+                deferred.reject();
+            });
+        });
+
+        return deferred.promise;
+    }
+
+    service.storePrintTemplate = function(name, html) {
+        return egHatch.setItem('eg.print.template.' + name, html);
+    }
+
+    return service;
+}])
+
+
+/**
+ * Container for inserting print data into the browser page.
+ * On insert, $window.print() is called to print the data.
+ * The div housing eg-print-container must apply the correct
+ * print media CSS to ensure this content (and not the rest
+ * of the page) is printed.
+ */
+
+// FIXME: only apply print CSS when print commands are issued via the 
+// print container, otherwise using the browser's native print page 
+// option will always result in empty pages.  Move the print CSS
+// out of the standalone CSS file and put it into a template file
+// for this directive.
+.directive('egPrintContainer', ['$compile', function($compile) {
+    return {
+        restrict : 'AE',
+        scope : {}, // isolate our scope
+        link : function(scope, element, attrs) {
+            scope.elm = element;
+        },
+        controller : 
+                   ['$scope','$q','$window','$timeout','egHatch','egPrint',
+            function($scope , $q , $window , $timeout , egHatch , egPrint) {
+
+                egPrint.ingest_print_content = function(type, content, printScope) {
+
+                    if (type == 'text/csv' || type == 'text/plain') {
+                        // preserve newlines, spaces, etc.
+                        content = '<pre>' + content + '</pre>';
+                    }
+
+                    $scope.elm.html(content);
+
+                    var sub_scope = $scope.$new(true);
+                    angular.forEach(printScope, function(val, key) {
+                        sub_scope[key] = val;
+                    })
+
+                    var resp = $compile($scope.elm.contents())(sub_scope);
+
+                    var deferred = $q.defer();
+                    $timeout(function(){
+                        // give the $digest a chance to complete then
+                        // resolve with the compiled HTML from our
+                        // print container
+
+                        deferred.resolve(
+                            resp.contents()[0].parentNode.innerHTML
+                        );
+                    });
+
+                    return deferred.promise;
+                }
+            }
+        ]
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js
new file mode 100644
index 0000000..670248c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/startup.js
@@ -0,0 +1,84 @@
+/**
+ * Core Service - egStartup
+ *
+ * Coordinates all startup routines and consolidates them into
+ * a single startup promise.  Startup can be launched from multiple
+ * controllers, etc., but only one startup routine will be run.
+ *
+ * If no valid authtoken is found, startup will exit early and 
+ * change the page href to the login page.  Otherwise, the global
+ * promise returned by startup.go() will be resolved after all
+ * async data is arrived.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egStartup', 
+       ['$q','$rootScope','$location','$window','egIDL','egAuth','egEnv',
+function($q,  $rootScope,  $location,  $window,  egIDL,  egAuth,  egEnv) {
+
+    var service = { promise : null }
+
+    // returns true if we are staying on the current page
+    // false if we are redirecting to login
+    service.expiredAuthHandler = function() {
+        console.debug('egStartup.expiredAuthHandler()');
+        egAuth.logout(); // clean up
+
+        // no need to redirect if we're on the /login page
+        if ($location.path() == '/login') return true;
+
+        // change locations to the login page, using the current page
+        // as the 'route_to' destination on /login
+        $window.location.href = $location
+            .path('/login')
+            .search({route_to : 
+                $window.location.pathname + $window.location.search})
+            .absUrl();
+
+        return false;
+    }
+
+    // if during startup or any time in the future we encounter an expired
+    // authtoken, call our epired token handler
+    // we handle this here instead egAuth, since it affects the flow
+    // of the startup routines when no valid token exists during startup.
+    $rootScope.$on('egAuthExpired', function() {service.expiredAuthHandler()});
+
+    service.go = function () {
+        if (service.promise) {
+            // startup already started, return our existing promise
+            return service.promise;
+        } 
+
+        // create a new promise and fire off startup
+        var deferred = $q.defer();
+        service.promise = deferred.promise;
+
+        // IDL parsing is sync.  No promises required
+        egIDL.parseIDL();
+        egAuth.testAuthToken().then(
+
+            // testAuthToken resolved
+            function() { 
+                egEnv.load().then(
+                    function() { deferred.resolve() }, 
+                    function() { 
+                        deferred.reject('egEnv did not resolve')
+                    }
+                );
+            },
+
+            // testAuthToken rejected
+            function() { 
+                console.log('egAuth found no valid authtoken');
+                if (service.expiredAuthHandler()) deferred.resolve();
+            }
+        );
+
+        return service.promise;
+    }
+    
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/statusbar.js b/Open-ILS/web/js/ui/default/staff/services/statusbar.js
new file mode 100644
index 0000000..29968f9
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/statusbar.js
@@ -0,0 +1,60 @@
+/**
+ * egStatusBar
+ *
+ * Displays key information and messages to the user.
+ *
+ * Currently displays network connection status, egHatch connection
+ * status, and messages delivered via 
+ * $scope.$emit('egStatusBarMessage', msg)
+ */
+
+angular.module('egCoreMod')
+
+.directive('egStatusBar', function() {
+    return {
+        restrict : 'AE',
+        replace : true,
+        templateUrl : 'eg-status-bar-template',
+        controller : [
+                    '$scope','$rootScope','egHatch',
+            function($scope , $rootScope , egHatch) {
+            $scope.messages = []; // keep a log of recent messages
+
+            $scope.netConnected = function() {
+                // TODO: should should be abstracted through egNet
+                return OpenSRF.websocketConnected();
+            }
+
+            // update the UI whenever we lose connection
+            OpenSRF.onWebSocketClosed = function() {
+                $scope.$apply();
+            }
+
+            $scope.hatchConnected = function() {
+                return egHatch.hatchAvailable;
+            }
+
+            // update the UI whenever we lose connection
+            egHatch.onHatchClose = function() {
+                $scope.$apply();
+            }
+
+            // update the UI whenever we lose connection
+            egHatch.onHatchOpen = function() {
+                $scope.$apply();
+            }
+
+            $scope.hatchConnect = function() {
+                egHatch.hatchConnect();
+            }
+
+            $rootScope.$on('egStatusBarMessage', function(evt, args) {
+                $scope.messages.unshift(args.message);
+
+                // ensure the list does not exceed 10 messages
+                // TODO: configurable?
+                $scope.messages.splice(10, 1); 
+            });
+        }]
+    }
+});
diff --git a/Open-ILS/web/js/ui/default/staff/services/strings.js b/Open-ILS/web/js/ui/default/staff/services/strings.js
new file mode 100644
index 0000000..d9a801b
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/strings.js
@@ -0,0 +1,22 @@
+/**
+ * egStrings : service for tracking page-specific string translations.
+ *
+ * Convience functions embedded herein are prefixed with "$" to avoid
+ * collisions with string keys, which are linked directly to the 
+ * service.
+ *
+ * egStrings.A_STRING = 'hello, world {{foo}';
+ *
+ * egStrings.$replace(egStrings.A_STRING, {foo : 'bar'})
+ *
+ */
+
+angular.module('egCoreMod').factory('egStrings', 
+['$interpolate', function($interpolate) { 
+    return {
+        '$replace' : function(str, args) {
+            if (!str) return '';
+            return $interpolate(str)(args);
+        }
+    } 
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
new file mode 100644
index 0000000..c960ffb
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -0,0 +1,297 @@
+/**
+  * UI tools and directives.
+  */
+angular.module('egUiMod', ['egCoreMod', 'ui.bootstrap'])
+
+
+/**
+ * <input focus-me="iAmOpen"/>
+ * $scope.iAmOpen = true;
+ */
+.directive('focusMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.focusMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].focus()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+/**
+ * <input blur-me="pleaseBlurMe"/>
+ * $scope.pleaseBlurMe = true
+ * Useful for de-focusing when no other obvious focus target exists
+ */
+.directive('blurMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.blurMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].blur()});
+            });
+            element.bind('focus', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+
+// <input select-me="iWantToBeSelected"/>
+// $scope.iWantToBeSelected = true;
+.directive('selectMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.selectMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].select()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+
+// 'reverse' filter 
+// <div ng-repeat="item in items | reverse">{{item.name}}</div>
+// http://stackoverflow.com/questions/15266671/angular-ng-repeat-in-reverse
+// TODO: perhaps this should live elsewhere
+.filter('reverse', function() {
+    return function(items) {
+        return items.slice().reverse();
+    };
+})
+
+
+/**
+ * egAlertDialog.open({message : 'hello {{name}}'}).result.then(
+ *     function() { console.log('alert closed') });
+ */
+.factory('egAlertDialog', 
+
+        ['$modal','$interpolate',
+function($modal , $interpolate) {
+    var service = {};
+
+    service.open = function(message, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_alert_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.ok = function() {
+                        if (msg_scope && msg_scope.ok) msg_scope.ok();
+                        $modalInstance.close()
+                    }
+                }
+            ]
+        });
+    }
+
+    return service;
+}])
+
+/**
+ * egConfirmDialog.open("some message goes {{here}}", {
+ *  here : 'foo', ok : function() {}, cancel : function() {}});
+ */
+.factory('egConfirmDialog', 
+    
+       ['$modal','$interpolate',
+function($modal, $interpolate) {
+    var service = {};
+
+    service.open = function(title, message, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_confirm_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.title = $interpolate(title)(msg_scope);
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.ok = function() {
+                        if (msg_scope.ok) msg_scope.ok();
+                        $modalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (msg_scope.cancel) msg_scope.cancel();
+                        $modalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+/**
+ * egPromptDialog.open(
+ *    "prompt message goes {{here}}", 
+ *    promptValue,  // optional
+ *    {
+ *      here : 'foo',  
+ *      ok : function(value) {console.log(value)}, 
+ *      cancel : function() {console.log('prompt denied')}
+ *    }
+ *  );
+ */
+.factory('egPromptDialog', 
+    
+       ['$modal','$interpolate',
+function($modal, $interpolate) {
+    var service = {};
+
+    service.open = function(message, promptValue, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_prompt_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.args = {value : promptValue || ''};
+                    $scope.focus = true;
+                    $scope.ok = function() {
+                        if (msg_scope.ok) msg_scope.ok($scope.args.value);
+                        $modalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (msg_scope.cancel) msg_scope.cancel();
+                        $modalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+
+/**
+ * Nested org unit selector modeled as a Bootstrap dropdown button.
+ */
+.directive('egOrgSelector', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        replace : true, // makes styling easier
+        scope : {
+            selected : '=', // defaults to workstation or root org
+            
+            // Each org unit is passed into this function and, for
+            // any org units where the response value is true, the
+            // org unit will not be added to the selector.
+            hiddenTest : '=',
+
+            // Caller can either $watch(selected, ..) or register an
+            // onchange handler.
+            onchange : '=',
+
+            // optional primary drop-down button label
+            label : '@'
+        },
+
+        // any reason to move this into a TT2 template?
+        template : 
+            '<div class="btn-group eg-org-selector" dropdown>'
+            + '<button type="button" class="btn btn-default dropdown-toggle">'
+             + '<span style="padding-right: 5px;">{{getSelectedName()}}</span>'
+             + '<span class="caret"></span>'
+           + '</button>'
+           + '<ul class="dropdown-menu">'
+             + '<li ng-repeat="org in orgList" ng-hide="hiddenTest(org.id)">'
+               + '<a href dropdown-toggle ng-click="orgChanged(org)"'
+                 + 'style="padding-left: {{org.depth * 10 + 5}}px">'
+                 + '{{org.shortname}}'
+               + '</a>'
+             + '</li>'
+           + '</ul>'
+          + '</div>',
+
+        controller : ['$scope','$timeout','egOrg','egAuth',
+              function($scope , $timeout , egOrg , egAuth) {
+
+            // avoid linking the full fleshed tree to the scope by 
+            // tossing in a flattened list.
+            $scope.orgList = egOrg.list().map(function(org) {
+                return {
+                    id : org.id(),
+                    shortname : org.shortname(), 
+                    depth : org.ou_type().depth()
+                }
+            });
+
+            $scope.getSelectedName = function() {
+                if ($scope.selected)
+                    return $scope.selected.shortname();
+                return $scope.label;
+            }
+
+            $scope.orgChanged = function(org) {
+                $scope.selected = egOrg.get(org.id);
+                if ($scope.onchange) $scope.onchange($scope.selected);
+            }
+
+            if (!$scope.selected)
+                $scope.selected = egOrg.get(egAuth.user().ws_ou());
+        }]
+    }
+})
+
+
+/*
+http://stackoverflow.com/questions/18061757/angular-js-and-html5-date-input-value-how-to-get-firefox-to-show-a-readable-d
+
+This directive allows us to use html5 input type="date" (for Chrome) and 
+gracefully fall back to a regular ISO text input for Firefox.
+It also allows us to abstract away some browser finickiness.
+*/
+.directive(
+    'egDateInput',
+    function(dateFilter) {
+        return {
+            require: 'ngModel',
+            template: '<input type="date"></input>',
+            replace: true,
+            link: function(scope, elm, attrs, ngModelCtrl) {
+
+                // since this is a date-only selector, set the time
+                // portion to 00:00:00, which should better match the
+                // user's expectations.  Note this allows us to retain
+                // the timezone.
+                function strip_time(date) {
+                    if (!date) date = new Date();
+                    date.setHours(0);
+                    date.setMinutes(0);
+                    date.setSeconds(0);
+                    date.setMilliseconds(0);
+                    return date;
+                }
+
+                ngModelCtrl.$formatters.unshift(function (modelValue) {
+                    // apply strip_time here in case the user never 
+                    // modifies the date value.
+                    return dateFilter(strip_time(modelValue), 'yyyy-MM-dd');
+                });
+                
+                ngModelCtrl.$parsers.unshift(function(viewValue) {
+                    return strip_time(new Date(viewValue));
+                });
+            },
+        };
+})
diff --git a/Open-ILS/web/js/ui/default/staff/services/user.js b/Open-ILS/web/js/ui/default/staff/services/user.js
new file mode 100644
index 0000000..0ed5cac
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/user.js
@@ -0,0 +1,56 @@
+/** 
+ * Service for fetching fleshed user objects.
+ */
+
+angular.module('egUserMod', ['egCoreMod'])
+
+.factory('egUser', 
+       ['$q','$timeout','egNet','egAuth','egOrg',
+function($q,  $timeout,  egNet,  egAuth,  egOrg) {
+
+    var service = {
+        defaultFleshFields : [
+            'card',                                                                
+            'standing_penalties',                                                  
+            'addresses',                                                           
+            'billing_address',                                                     
+            'mailing_address',                                                     
+            'stat_cat_entries',                                                    
+            'usr_activity' 
+        ]
+    };
+
+    service.get = function(userId, args) {
+        var deferred = $q.defer();
+
+        var fields = service.defaultFleshFields;
+        if (args) {
+            if (args.useFields) { 
+                // overridde flesh fields
+                fields = args.useFields; 
+            }
+            if (args.addFields) {
+                // append flesh fields
+                fields = fields.concat(args.addFields);
+            }
+        }
+            
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.fleshed.retrieve',
+            egAuth.token(), userId, fields).then(
+            function(user) {
+                if (user && user.classname == 'au') {
+                    deferred.resolve(user);
+                } else {
+                    deferred.reject(user);
+                }
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js b/Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js
new file mode 100644
index 0000000..956d46c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js
@@ -0,0 +1,50 @@
+/**
+ * Mock data required by multiple unit tests.
+ */
+
+window._eg_mock_data = {
+
+    // builds a mock org unit tree fleshed with ou_types and
+    // absorbs the tree into egEnv
+    orgTree : function(egIDL, egEnv) {
+        var type1 = new egIDL.aout();
+        type1.id(1);
+        type1.depth(0);
+
+        var type2 = new egIDL.aout();
+        type2.id(2);
+        type2.depth(1);
+        type2.parent(1);
+
+        var type3 = new egIDL.aout();
+        type3.id(3);
+        type3.depth(2);
+        type3.parent(2);
+
+        var org1 = new egIDL.aou(); 
+        org1.id(1);
+        org1.ou_type(type1);
+
+        var org2 = new egIDL.aou(); 
+        org2.id(2); 
+        org2.parent_ou(1);
+        org2.ou_type(type2);
+
+        var org3 = new egIDL.aou(); 
+        org3.id(3); 
+        org3.parent_ou(1);
+        org3.ou_type(type2);
+
+        var org4 = new egIDL.aou(); 
+        org4.id(4); 
+        org4.parent_ou(2);
+        org4.ou_type(type3);
+
+        org1.children([org2, org3]);
+        org2.children([org4]);
+        org3.children([]);
+        org4.children([]);
+
+        egEnv.absorbTree(org1, 'aou');
+    }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl b/Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl
new file mode 100644
index 0000000..fbf998f
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl
@@ -0,0 +1,22 @@
+#!/usr/bin/perl
+use strict; use warnings;
+use XML::LibXML;
+use XML::LibXSLT;
+my $out_file = 'IDL2js.js';
+my $idl_file = '../../../../../../../examples/fm_IDL.xml';
+my $xsl_file = '/openils/var/xsl/fm_IDL2js.xsl'; # FIXME: hard-coded path
+
+my $xslt = XML::LibXSLT->new();
+my $style_doc = XML::LibXML->load_xml(location => $xsl_file, no_cdata=>1);
+my $stylesheet = $xslt->parse_stylesheet($style_doc);
+my $idl_doc = XML::LibXML->load_xml(location => $idl_file);
+my $results = $stylesheet->transform($idl_doc);
+my $output = $stylesheet->output_as_bytes($results);
+
+open(IDL, ">$out_file") or die "Cannot open IDL2js file $out_file : $!\n";
+
+print IDL $output;
+
+close(IDL);
+
+
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
new file mode 100644
index 0000000..a27ca66
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
@@ -0,0 +1,88 @@
+module.exports = function(config){
+    config.set({
+    basePath : '../',
+
+    // config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+    files : [
+      'build/js/angular.min.js',
+      'build/js/angular-route.min.js',
+      'bower_components/angular-mocks/angular-mocks.js', // testing only
+      'build/js/ui-bootstrap.min.js',
+      'build/js/hotkeys.min.js',
+      /* OpenSRF must be installed first */
+      '/openils/lib/javascript/md5.js',
+      '/openils/lib/javascript/JSON_v1.js',
+      '/openils/lib/javascript/opensrf.js',
+      '/openils/lib/javascript/opensrf_ws.js',
+
+      // mock data for testing only
+      'test/data/IDL2js.js',
+      'test/data/eg_mock.js',
+
+      // service/*.js have to be loaded in order
+      'services/core.js',
+      'services/idl.js',
+      'services/strings.js',
+      'services/event.js',
+      'services/net.js',
+      'services/auth.js',
+      'services/pcrud.js',
+      'services/env.js',
+      'services/org.js',
+      'services/hatch.js',
+      'services/print.js',
+      'services/coresvc.js',
+      'services/user.js',
+      'services/startup.js',
+      'services/ui.js',
+      'services/statusbar.js',
+      'services/grid.js',
+      'services/navbar.js',
+      // load app scripts
+      'app.js',
+      'circ/**/*.js',
+      'cat/**/*.js',
+      'admin/**/*.js',
+      'test/unit/egIDL.js', // order matters for some of these
+      'test/unit/egOrg.js', 
+      'test/unit/**/*.js'
+    ],
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
+    reporters: ['spec'],  // detailed report
+    //reporters: ['progress'], // summary report
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch : false,
+
+    frameworks: ['jasmine'],
+
+    browsers: ['PhantomJS'],
+
+    // web server port
+    port: 9876,
+
+    /*
+    coverageReporter: {
+      type : 'html',
+      dir : 'coverage/',
+    },
+
+    preprocessors: {
+      '../src/*.js': ['coverage']
+    },
+    */
+
+    // If browser does not capture in given timeout [ms], kill it
+    captureTimeout: 60000,
+
+    // Continuous Integration mode
+    // if true, it capture browsers, run tests and exit
+    singleRun: true
+})}
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egCore.js b/Open-ILS/web/js/ui/default/staff/test/unit/egCore.js
new file mode 100644
index 0000000..d19a371
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egCore.js
@@ -0,0 +1,18 @@
+'use strict';
+
+describe('egCore', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should wrap services', inject(function(egCore, egIDL) {
+        expect(egCore.idl).toBe(egIDL);
+    }));
+
+    it('should wrap services', inject(function(egCore, egIDL) {
+        expect(egCore.auth).not.toBe(egIDL);
+    }));
+
+    it('should not wrap non-services', inject(function(egCore) {
+        expect(egCore.junk).not.toBeDefined();
+    }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js b/Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js
new file mode 100644
index 0000000..7b17653
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js
@@ -0,0 +1,44 @@
+'use strict';
+
+describe('egEvent', function(){
+    beforeEach(module('egCoreMod'));
+
+    var evt = {                                                                           
+        ilsevent: "12345",                                                         
+        pid: "12345",                                                             
+        desc: "Test Event Description",
+        payload: {test : 'xyz'},                                                             
+        textcode: "TEST_EVENT",
+        servertime: "Wed Nov 6 16:05:50 2013"                                     
+    };
+
+    it('should parse an event object', inject(function(egEvent) {
+        expect(egEvent.parse(evt)).not.toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse({})).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse({abc : '123'})).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse([])).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse('STRING')).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse(true)).toBe(null);
+    }));
+
+    it('should stringify an event', inject(function(egEvent) {
+        expect(egEvent.parse(evt).toString()).toBe(
+            'Event: 12345:TEST_EVENT -> Test Event Description')
+    }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js b/Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js
new file mode 100644
index 0000000..eac4669
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js
@@ -0,0 +1,21 @@
+'use strict';
+
+describe('egHomeControllers', function(){
+  beforeEach(module('egHome'));
+
+  /* ---- LoginCtrl ---------------------------------- */
+
+  var loginCtrl, loginScope;
+  beforeEach(inject(function ($rootScope, $controller, $location) {
+      // pass the workstation name via (mock) URL param
+      $location.search({ws : 'TestWorkstation'});
+
+      loginScope = $rootScope.$new();
+      loginCtrl = $controller('LoginCtrl', {$scope: loginScope});
+  }));
+
+  it('should focus the login controller', inject(function() {
+    expect(loginScope.focusMe).toBe(true);
+  }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js b/Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js
new file mode 100644
index 0000000..bbf4e35
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js
@@ -0,0 +1,25 @@
+'use strict';
+
+describe('egIDL', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should parse the IDL', inject(function(egIDL) {
+        egIDL.parseIDL();
+        expect(egIDL.classes.aou.fields.length).toBeGreaterThan(0);
+    }));
+
+    it('should create an aou object', inject(function(egIDL) {
+        egIDL.parseIDL();
+        var org = new egIDL.aou();
+        expect(typeof org.id).toBe('function');
+    }));
+
+    it('should create an aou object with accessor/mutators', inject(function(egIDL) {
+        egIDL.parseIDL();
+        var org = new egIDL.aou();
+        org.name('AN ORG');
+        expect(org.name()).toBe('AN ORG');
+    }));
+});
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js b/Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js
new file mode 100644
index 0000000..f919d66
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js
@@ -0,0 +1,37 @@
+'use strict';
+
+describe('egOrg', function(){
+    beforeEach(module('egCoreMod'));
+
+    function mkTree(egIDL, egEnv) { // FIXME: external sample data
+        egIDL.parseIDL();
+        window._eg_mock_data.orgTree(egIDL, egEnv);
+    }
+
+    it('should provide get by ID', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.get(egEnv.aou.tree.id())).toBe(egEnv.aou.tree);
+    }));
+
+    it('should provide get by node', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.get(egEnv.aou.tree).id()).toBe(egEnv.aou.tree.id());
+    }));
+
+    it('should provide ancestors', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.ancestors(2, true)).toEqual([2, 1]);
+    }));
+
+    it('should provide descendants', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.descendants(2, true)).toEqual([2, 4]);
+    }));
+
+    it('should provide full path', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.fullPath(4, true)).toEqual([4, 2, 1]);
+    }));
+});
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js b/Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js
new file mode 100644
index 0000000..60e9b15
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js
@@ -0,0 +1,31 @@
+'use strict';
+
+describe('egPatronAppTest', function(){
+  beforeEach(module('egPatronApp'));
+
+  // basic controller sanity checks
+  
+  var patronCtrl, patronScope;
+  beforeEach(inject(function ($rootScope, $controller, $location) {
+      patronScope = $rootScope.$new();
+      patronCtrl = $controller('PatronCtrl', {$scope: patronScope});
+  }));
+
+  /** patronSvc tests **/
+  describe('patronSvcTests', function() {
+
+    it('patronSvc should start with empty lists', inject(function(patronSvc) {
+        expect(patronSvc.patrons.length).toEqual(0);
+    }));
+
+    it('patronSvc reset should clear data', inject(function(patronSvc) {
+        patronSvc.checkout_overrides.a = 1;
+        expect(Object.keys(patronSvc.checkout_overrides).length).toBe(1);
+        patronSvc.resetPatronLists();
+        expect(Object.keys(patronSvc.checkout_overrides).length).toBe(0);
+        expect(patronSvc.holds.length).toBe(0);
+    }));
+
+  });
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js b/Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js
new file mode 100644
index 0000000..fe81e08
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js
@@ -0,0 +1,14 @@
+'use strict';
+
+describe('egStrings', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should interpolate values', inject(function(egStrings) {
+
+        egStrings.FOO = 'Hello, {{planet}}';
+
+        expect(egStrings.$replace(egStrings.FOO, {planet : 'Earth'}))
+       .toBe('Hello, Earth');
+    }));
+
+});
diff --git a/Open-ILS/web/opac/locale/en-US/lang.dtd b/Open-ILS/web/opac/locale/en-US/lang.dtd
index 54b63fe..2da48ef 100644
--- a/Open-ILS/web/opac/locale/en-US/lang.dtd
+++ b/Open-ILS/web/opac/locale/en-US/lang.dtd
@@ -3549,6 +3549,9 @@
 <!ENTITY staff.patron.user_edit.depth.label "Depth">
 <!ENTITY staff.patron.user_edit.grantable.label "Grantable">
 <!ENTITY staff.patron.user_edit.save.label "Save">
+<!ENTITY staff.patron.user_edit.display_perm.select_one "-- Select One --">
+<!ENTITY staff.patron.user_edit.save_user.depth_required "Depth is required to set the permission.">
+<!ENTITY staff.patron.user_edit.save_user.user_modified_successfully "User successfully modified.">
 <!ENTITY staff.patron.ue.ev_user_editor.label "Evergreen User Editor">
 <!ENTITY staff.patron.ue.user_greeting.label "Welcome ">
 <!ENTITY staff.patron.ue.interface_note.label "Note: required or invalid fields are <span style='border-bottom: 2px solid red;'>marked with color</span>">
diff --git a/Open-ILS/xul/staff_client/server/patron/user_edit.xhtml b/Open-ILS/xul/staff_client/server/patron/user_edit.xhtml
new file mode 100644
index 0000000..9b7bbf0
--- /dev/null
+++ b/Open-ILS/xul/staff_client/server/patron/user_edit.xhtml
@@ -0,0 +1,176 @@
+<?xml version='1.0'?>
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<!-- LOCALIZATION -->
+<!DOCTYPE window PUBLIC "" ""[
+    <!--#include virtual="/opac/locale/${locale}/lang.dtd"-->
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xi="http://www.w3.org/2001/XInclude">
+
+        <head>
+                <title>&staff.patron.user_edit.title;</title>
+                <script language='javascript' src='/opac/common/js/utils.js'> </script>
+                <script language='javascript' src='/opac/common/js//config.js'> </script>
+                <script language='javascript' src='/opac/common/js/CGI.js'> </script>
+
+                <script language='javascript' src='/opac/common/js/slimtree.js'> </script>
+                <script language='javascript' src='/opac/common/js/JSON_v1.js'> </script>
+                <script language='javascript' src='/opac/common/js/fmall.js'> </script>
+                <script language='javascript' src='/opac/common/js/fmgen.js'> </script>
+                <script language='javascript' src='/opac/common/js/opac_utils.js'> </script>
+                <script language='javascript' src='/opac/common/js/<!--#echo var="locale"-->/OrgTree.js'> </script>
+                <script language='javascript' src='/opac/common/js/org_utils.js'> </script>
+                <script language='javascript' src='/opac/common/js/init.js'> </script>
+                <script language='javascript' src='/opac/common/js/RemoteRequest.js'> </script>
+                <script language='javascript' src='/opac/common/js/sprintf.js'> </script>
+                <script language='javascript' src='user_edit_xhtml.js'> </script>
+
+
+        <style>
+
+            /*
+            .messagecatalog { -moz-binding: url( /xul/server/main/bindings.xml#messagecatalog ) }
+            */
+
+            .stay_hidden {        visibility: hidden;
+                        display: none;
+
+            }
+
+            .hideme {        visibility: hidden;
+                        display: none;
+            }
+
+            table {        border-collapse: collapse;
+                    margin-bottom: 10px;
+                    margin-top: 10px;
+            }
+
+            th {        
+                    white-space: nowrap;
+                    padding-top: 15px;
+                    padding-bottom: 10px;
+                    text-align: center;
+                    border-top: solid black 1px;
+            }
+
+            td.odd {    background-color: lightcyan; }
+
+            td.label {    text-align: right;
+                    padding-right: 10px;
+            }
+
+            td.value {    text-align: left;
+                    padding-left: 10px;
+            }
+
+            input[disabled='true'] { color: black; }
+
+        </style>
+        </head>
+
+    <div class="messagecatalog" id="patronStrings" src="/xul/server/locale/<!--#echo var='locale'-->/patron.properties" />
+
+    <body onload="try { setTimeout(init_editor,1) } catch(E) { alert(js2JSON(E)); }">
+        <form method="GET" name="editor" id="editor" >
+
+        <table width="100%">
+            <tr>
+                <td class="label">&staff.patron.user_edit.user_name.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.usrname" id="user.usrname"/></td>
+                <td class="label">&staff.patron.user_edit.barcode.label;</td>
+                <td class="value"><input type="text" name="user.card.barcode" id="user.card.barcode" disabled="true"/></td>
+            </tr>
+            <tr>
+                <td class="label">&staff.patron.user_edit.firstname.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.first_given_name" id="user.first_given_name"/></td>
+                <td class="label">&staff.patron.user_edit.middlename.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.second_given_name" id="user.second_given_name"/></td>
+                <td class="label">&staff.patron.user_edit.lastname.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.family_name" id="user.family_name"/></td>
+            </tr>
+            <tr class='advanced hideme'>
+                <td class="value" colspan="6">
+                    <table width="100%">
+                        <thead>
+                            <tr>
+                                <th></th>
+                                <th>&staff.patron.user_edit.working_location.label;</th>
+                            </tr>
+                        </thead>
+                        <tbody id="work_ous" name="work_ous"/>
+                    </table>
+                </td>
+            </tr>
+            <tr class='advanced hideme'>
+                <td class="value" colspan="6">
+                    <table width="100%">
+                        <thead>
+                            <tr>
+                                <th>&staff.patron.user_edit.permission.label;</th>
+                                <th>&staff.patron.user_edit.applied.label;</th>
+                                <th>&staff.patron.user_edit.depth.label;</th>
+                                <th>&staff.patron.user_edit.grantable.label;</th>
+                            </tr>
+                        </thead>
+                        <tbody id="permissions" name="permissions"/>
+                    </table>
+                </td>
+            </tr>
+
+        </table>
+
+        <button onclick="save_user(); return false;">&staff.patron.user_edit.save.label;</button>
+        </form>
+
+
+        <div class='hideme' id="permission-tmpl">
+            <table>
+                <tr name='prow'>
+                    <td class="value" name='plabel'>
+                        <span name="p.code"></span>
+                    </td>
+                    <td class="value" name='papply'>
+                        <input type="checkbox" name="p.id" onclick="set_perm(this.parentNode.parentNode);"/>
+                    </td>
+                    <td class="value" name='pdepth'>
+                        <select onchange="set_perm(this.parentNode.parentNode);" name="p.depth"/>
+                    </td>
+                    <td class="value" name='pgrant'>
+                        <input type="checkbox" name="p.grantable" onclick="set_perm(this.parentNode.parentNode);"/>
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+
+        <div class='hideme' id="work_ou-tmpl">
+            <table>
+                <tr name='wrow'>
+                    <td class="value" name='wapply'>
+                        <input type="checkbox" name="a.id" onclick="set_work_ou(this.parentNode.parentNode);"/>
+                    </td>
+                    <td class="value" name='label'>
+                        <span name="a.name"></span>
+                        (<span name="a.shortname"></span>)
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+        <div class="hideme"><!-- embedded string -->
+            <span id="staff.patron.user_edit.display_perm.select_one">
+                &staff.patron.user_edit.display_perm.select_one;
+            </span>
+            <span id="staff.patron.user_edit.save_user.depth_required">
+                &staff.patron.user_edit.save_user.depth_required;
+            </span>
+            <span id="staff.patron.user_edit.save_user.user_modified_successfully">
+                &staff.patron.user_edit.save_user.user_modified_successfully;
+            </span>
+        </div>
+
+    </body>
+</html>
+
diff --git a/Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js b/Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js
new file mode 100644
index 0000000..3b78ad5
--- /dev/null
+++ b/Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js
@@ -0,0 +1,499 @@
+var cgi;
+var orgTree;
+var user;
+var ses_id;
+var user_groups = [];
+var adv_items = [];
+var user_perms = [];
+var perm_list = [];
+var ou_type_list = [];
+var user_work_ous = [];
+var work_ou_list = [];
+
+function $(id) { return document.getElementById(id); }
+
+function set_work_ou(row) {
+        var wid = findNodeByName(row,'a.id').getAttribute('workou_id');
+        var wapply = findNodeByName(row,'a.id').checked;
+
+        var w;
+        for (var i in user_work_ous) {
+                if (!user_work_ous[i]) continue;
+                if (user_work_ous[i].work_ou() == wid) {
+                        w = user_work_ous[i];
+                        if (wapply) {
+                                w.isdeleted(0);
+                                w.ischanged(1);
+                        } else {
+                                if (w.isnew()) {
+                                        user_work_ous[i] = null;
+                                } else {
+                                        w.isdeleted(1);
+                                }
+                        }
+                        break;
+                }
+        }
+
+        if (!w) {
+                if (wapply) {
+                        p = new puwoum();
+                        p.isnew(1);
+                        p.work_ou(wid);
+                        p.usr(user.id());
+
+                        user_work_ous.push(p);
+                }
+        }
+}
+
+function set_perm(row) {
+    var pid = findNodeByName(row,'p.code').getAttribute('permid');
+    var papply = findNodeByName(row,'p.id').checked;
+    var pdepth = findNodeByName(row,'p.depth').options[findNodeByName(row,'p.depth').selectedIndex].value;
+    var pgrant = findNodeByName(row,'p.grantable').checked;
+
+    var p;
+    for (var i in user_perms) {
+        if (user_perms[i].perm() == pid) {
+            p = user_perms[i];
+            if (papply) {
+                p.isdeleted(0);
+                p.ischanged(1);
+                p.depth(pdepth);
+                p.grantable(pgrant ? 1 : 0);
+            } else {
+                if (p.isnew()) {
+                    user_perms[i] = null;
+                } else {
+                    p.isdeleted(1);
+                }
+            }
+            break;
+        }
+    }
+
+    if (!p) {
+        if (papply) {
+            p = new pupm();
+            p.isnew(1);
+            p.perm(pid);
+            p.usr(user.id());
+            p.depth('' + pdepth);
+            p.grantable(pgrant ? 1 : 0);
+
+            user_perms.push(p);
+        }
+    }
+
+}
+
+function save_user () {
+
+    try {
+
+        var save_perms = [];
+        for (var i in user_perms) {
+            // Group based perm? skip it.
+            if (user_perms[i].id() < 0) continue;
+
+            if (user_perms[i].depth() == null) {
+                var p;
+                for (var j in perm_list) {
+                    if (perm_list[j].id() == user_perms[i].perm()) {
+                        p = perm_list[j];
+                        break;
+                    }
+                }
+
+                alert(
+                    $('staff.patron.user_edit.save_user.depth_required').innerHTML
+                    + '\n' + p.code()
+                );
+
+                throw new Error(
+                    $('staff.patron.user_edit.save_user.depth_required').innerHTML
+                    + '\n' + p.code()
+                );
+            }
+
+            save_perms.push( user_perms[i] );
+        }
+
+        var save_ous = [];
+        for (var i in user_work_ous) {
+            if (!user_work_ous[i]) continue;
+            save_ous.push( user_work_ous[i] );
+        }
+
+        var req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.work_ous.update', ses_id, save_ous );
+        req.send(true);
+        var wok = req.getResultObject();
+
+        if (wok.ilsevent) throw wok;
+
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.permissions.update', ses_id, save_perms );
+        req.send(true);
+        var pok = req.getResultObject();
+
+        if (pok.ilsevent) throw pok;
+
+        if (pok || wok) {
+            alert($('staff.patron.user_edit.save_user.user_modified_successfully').innerHTML);
+            // on_patron_save comes from the browser client
+            if (window.xulG && xulG.on_patron_save) xulG.on_patron_save();
+        }
+
+        init_editor();
+
+    } catch (e) {
+        dump( js2JSON( e ));
+        alert( js2JSON( e ));
+    };
+
+
+
+    return false;
+}
+
+var adv_mode = true;
+function apply_adv_mode (root) {
+    adv_items = findNodesByClass(root,'advanced');
+    for (var i in adv_items) {
+        adv_mode ?
+            removeCSSClass(adv_items[i], 'hideme') :
+            addCSSClass(adv_items[i], 'hideme');
+    }
+}
+
+function init_editor (u) {
+    
+    var x = document.getElementById('editor').elements;
+    
+    cgi = new CGI();
+    if (cgi.param('adv')) adv_mode = true; 
+    try {
+        if (xulG) if (xulG.adv) adv_mode = true;
+        if (xulG) if (xulG.params) if (xulG.params.adv) adv_mode = true;
+    } catch (e) {}
+
+    apply_adv_mode(document.getElementById('editor'));
+
+    ses_id = cgi.param('ses'); 
+    try {
+        if (xulG) if (xulG.ses) ses_id = xulG.ses;
+        if (xulG) if (xulG.params) if (xulG.params.ses) ses_id = xulG.params.ses;
+    } catch (e) {}
+
+    var usr_id = cgi.param('usr'); 
+    try {
+        if (xulG) if (xulG.usr_id) usr_id = xulG.usr_id;
+        if (xulG) if (xulG.params) if (xulG.params.usr_id) usr_id = xulG.params.usr_id;
+    } catch (e) {}
+
+    var usr_barcode = cgi.param('barcode'); 
+    try {
+        if (xulG) if (xulG.usr_barcode) usr_ibarcode = xulG.usr_barcode;
+        if (xulG) if (xulG.params) if (xulG.params.usr_barcode) usr_ibarcode = xulG.params.usr_barcode;
+    } catch (e) {}
+
+    try {
+        var req;
+        if (usr_id) {
+            req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve', ses_id, usr_id );
+        } else {
+            req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve_by_barcode', ses_id, usr_barcode );
+        }
+        req.send(true);
+        user = req.getResultObject();
+    } catch (E) {
+        alert(E);
+    }
+
+    if (user.usrname()) x['user.usrname'].value = user.usrname();
+    x['user.usrname'].setAttribute('onchange','user.usrname(this.value)');
+
+    if (user.card() && user.card().barcode()) x['user.card.barcode'].value = user.card().barcode();
+    x['user.card.barcode'].setAttribute('onchange','user.card().barcode(this.value)');
+
+    if (user.first_given_name()) x['user.first_given_name'].value = user.first_given_name();
+    x['user.first_given_name'].setAttribute('onchange','user.first_given_name(this.value)');
+
+    if (user.second_given_name()) x['user.second_given_name'].value = user.second_given_name();
+    x['user.second_given_name'].setAttribute('onchange','user.second_given_name(this.value);');
+
+    if (user.family_name()) x['user.family_name'].value = user.family_name();
+    x['user.family_name'].setAttribute('onchange','user.family_name(this.value)');
+
+    // grab the editing staff user object
+    req = new RemoteRequest( 'open-ils.auth', 'open-ils.auth.session.retrieve', ses_id );
+    req.send(true);
+    var staff = req.getResultObject();
+
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id );
+    req.send(true);
+    var staff_perms = req.getResultObject();
+
+    // Get the top of the staff perm org for ASSIGN_WORK_ORG_UNIT
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.perm.highest_org', ses_id, staff.id(), 'ASSIGN_WORK_ORG_UNIT' );
+    req.send(true);
+    var top_work_ou = req.getResultObject();
+
+    // and now, the orgs where this staff member can apply the perms
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_tree.descendants.retrieve', top_work_ou);
+    req.send(true);
+    var work_ou_tree = req.getResultObject();
+
+    // and now, the orgs where this staff member can apply the perms
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.get_work_ous', ses_id, user.id());
+    req.send(true);
+    user_work_ous = req.getResultObject();
+
+    // and finally, the ou types
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_types.retrieve' );
+    req.send(true);
+    ou_type_list = req.getResultObject();
+
+    user_perms = [];
+    perm_list = [];
+    if (user.id() > 0) {
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id, user.id() );
+        req.send(true);
+        user_perms = req.getResultObject();
+
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.retrieve' );
+        req.send(true);
+        perm_list = req.getResultObject();
+    }
+
+    f = document.getElementById('permissions');
+    while (f.firstChild) f.removeChild(f.lastChild);
+
+    var rcount = 0;
+    for (var i in perm_list.sort(function(a,b){ if (a.code() < b.code()) return -1;return 1; }))
+        display_perm(f,perm_list[i],staff_perms, rcount++);
+
+    f = document.getElementById('work_ous');
+    while (f.firstChild) f.removeChild(f.lastChild);
+
+    //flatten the ou tree, keep only those with can_hav_users = true
+    work_ou_list = [];
+    trim_ou_tree( [work_ou_tree], work_ou_list );
+
+    rcount = 0;
+    for (var i in work_ou_list.sort( function(a,b){ if (a.name() < b.name()) return -1;return 1; }) )
+        display_work_ou(f,work_ou_list[i], rcount++);
+
+    return true;
+}
+
+function grep ( code, list ) {
+    var ret = [];
+    for (var i in list) {
+        if (code(list[i])) ret.push(list[i]);
+    }
+    return ret;
+}
+
+function trim_ou_tree (tree, list) {
+    for (var i in tree) {
+        if (!tree[i]) continue;
+
+        var type = grep( function(x) {return x.id() == tree[i].ou_type()}, ou_type_list )[0];
+        if ( type && type.can_have_users() == 't' )
+            list.push(tree[i]);
+
+        if (tree[i].children()) trim_ou_tree(tree[i].children(), list);
+    }
+}
+
+function display_work_ou (root,ou_def,r) {
+
+    var wrow = findNodeByName(document.getElementById('work_ou-tmpl'), 'wrow').cloneNode(true);
+    root.appendChild(wrow);
+
+    var label_cell = findNodeByName(wrow,'label');
+    findNodeByName(label_cell,'a.name').appendChild(text(ou_def.name()));
+    findNodeByName(label_cell,'a.shortname').appendChild(text(ou_def.shortname()));
+    if (r % 2) label_cell.className += ' odd';
+
+    var apply_cell = findNodeByName(wrow,'wapply');
+    findNodeByName(apply_cell,'a.id').setAttribute('workou_id', ou_def.id());
+    if (r % 2) apply_cell.className += ' odd';
+
+    var has_it = grep(
+        function(x){ return x.work_ou() == ou_def.id() },
+        user_work_ous
+    ).length;
+
+    findNodeByName(apply_cell,'a.id').checked = has_it > 0 ? true : false;
+}
+
+function display_perm (root,perm_def,staff_perms, r) {
+
+    var prow = findNodeByName(document.getElementById('permission-tmpl'), 'prow').cloneNode(true);
+    root.appendChild(prow);
+
+    var all = false;
+    for (var i in staff_perms) {
+        if (staff_perms[i].perm() == -1) {
+            all = true;
+            break;
+        }
+    }
+
+
+    var sp,up;
+    if (!all) {
+        for (var i in staff_perms) {
+            if (perm_def.id() == staff_perms[i].perm() || staff_perms[i].perm() == -1) {
+                sp = staff_perms[i];
+                break;
+            }
+        }
+    }
+
+    for (var i in user_perms) {
+        if (perm_def.id() == user_perms[i].perm())
+            up = user_perms[i];
+    }
+
+
+    var dis = false;
+    if ((up && up.id() < 0) || !sp || !sp.grantable()) dis = true; 
+    if (all) dis = false; 
+
+    var label_cell = findNodeByName(prow,'plabel');
+    findNodeByName(label_cell,'p.code').appendChild(text(perm_def.code()));
+    findNodeByName(label_cell,'p.code').setAttribute('title', perm_def.description());
+    findNodeByName(label_cell,'p.code').setAttribute('permid', perm_def.id());
+    if (r % 2) label_cell.className += ' odd';
+
+    var apply_cell = findNodeByName(prow,'papply');
+    findNodeByName(apply_cell,'p.id').disabled = dis;
+    findNodeByName(apply_cell,'p.id').checked = up ? true : false;
+    if (r % 2) apply_cell.className += ' odd';
+
+    var depth_cell = findNodeByName(prow,'pdepth');
+    findNodeByName(depth_cell,'p.depth').disabled = dis;
+    findNodeByName(depth_cell,'p.depth').id = 'perm-depth-' + perm_def.id();
+    if (r % 2) depth_cell.className += ' odd';
+    selectBuilder(
+        'perm-depth-' + perm_def.id(),
+        globalOrgTypes,
+        (up ? up.depth() : findOrgDepth(user.home_ou())),
+        { label_field        : 'name',
+          value_field        : 'depth',
+          empty_label        : $('staff.patron.user_edit.display_perm.select_one').innerHTML,
+          empty_value        : '',
+          clear            : true }
+    );
+    
+    var grant_cell = findNodeByName(prow,'pgrant');
+    findNodeByName(grant_cell,'p.grantable').disabled = dis;
+    findNodeByName(grant_cell,'p.grantable').checked = up ? (up.grantable() ? true : false) : false;
+    if (r % 2) grant_cell.className += ' odd';
+
+}
+
+
+function selectBuilder (id, objects, def, args) {
+    var label_field = args['label_field'];
+    var value_field = args['value_field'];
+    var depth = args['depth'];
+
+    if (!depth) depth = 0;
+
+    args['depth'] = parseInt(depth) + 1;
+
+    var child_field_name = args['child_field_name'];
+
+    var sel = id;
+    if (typeof sel != 'object')
+        sel = document.getElementById(sel);
+
+    if (args['clear']) {
+        for (var o in sel.options) {
+            sel.options[o] = null;
+        }
+        args['clear'] = false;
+        if (args['empty_label']) {
+            sel.options[0] = new Option( args['empty_label'], args['empty_value'] );
+            sel.selectedIndex = 0;
+        }
+    }
+
+    for (var i in objects) {
+        var l = objects[i][label_field];
+        var v = objects[i][value_field];
+
+        if (typeof l == 'function')
+            l = objects[i][label_field]();
+
+        if (typeof v == 'function')
+            v = objects[i][value_field]();
+
+        var opt = new Option( l, v );
+
+        if (depth) {
+            var d = 10 * depth;
+            opt.style.paddingLeft = '' + d + 'px';
+        }
+
+        sel.options[sel.options.length] = opt;
+
+
+        if (typeof def == 'object') {
+            for (var j in def) {
+                if (v == def[j]) {
+                    opt.selected = true;
+                    sel.value = v;
+                }
+            }
+        } else {
+            if (v == def) {
+                opt.selected = true;
+                sel.value = v;
+            }
+        }
+
+        if (child_field_name) {
+            var c = objects[i][child_field_name];
+            if (typeof c == 'function')
+                c = objects[i][child_field_name]();
+
+            selectBuilder(
+                id,
+                c,
+                def,
+                { label_field        : args['label_field'],
+                  value_field        : args['value_field'],
+                  depth            : args['depth'],
+                  child_field_name    : args['child_field_name'] }
+            );
+        }
+
+    }
+}    
+
+function findNodesByClass(root, nodeClass, list) {
+    if(!list) list = [];
+        if( !root || !nodeClass) {
+        return null;
+    }
+        
+        if(root.nodeType != 1) {
+        return null;
+    }
+        
+        if(root.className.match(nodeClass)) list.push( root );
+
+        var children = root.childNodes;
+        
+        for( var i = 0; i != children.length; i++ ) {
+                findNodesByClass(children[i], nodeClass, list);
+        }                       
+                        
+        return list;            
+}                                       
+

commit ac474f077006d4b12527aebedb587039c392ab23
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 14:03:07 2014 -0400

    LP#1350042 make_release -c for building browser client
    
    Adds support for fetching JS dependencies, minification, and
    building of the experimental browser client via new -c option.
    Browser client building is disabled by default.
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/build/tools/make_release b/build/tools/make_release
index 65cef4b..4fb990c 100755
--- a/build/tools/make_release
+++ b/build/tools/make_release
@@ -17,12 +17,13 @@ TAG_ONLY=NO # -t
 BUILD_ONLY=NO # -b
 UPGRADE_PREVIEW=NO # -r
 SKIP_I18N=NO # -i
+BUILD_BROWSER_CLIENT=NO # -c
 
 # path to OpenSRF libraries
 [ "$(which osrf_config)" ] && OSRF_JS_PATH="$(osrf_config --libdir)/javascript";
 
 
-while getopts ":hv:f:F:nptbrij:" opt; do
+while getopts ":hv:f:F:nptbrij:c" opt; do
     case $opt in
         v)
             VERSION=$OPTARG
@@ -55,6 +56,9 @@ while getopts ":hv:f:F:nptbrij:" opt; do
         j)
             OSRF_JS_PATH="$OPTARG"
         ;;
+        c)
+            BUILD_BROWSER_CLIENT=YES
+        ;;
         \?)
             echo "Invalid Option: -$OPTARG"
             exit 1
@@ -75,6 +79,7 @@ while getopts ":hv:f:F:nptbrij:" opt; do
             echo "   -r prompt to preview upgrade SQL in editor before committing."
             echo "   -i skip i18n; primarily useful for (quickly) testing this script."
             echo "   -j opensrf javascript library path.  If osrf_config is found, the value derived from osrf_config --libdir."
+            echo "   -c build the experimental browser client;  requires nodejs/grunt-cli/bower"
             echo "   NOTE: -t and -b override PREV_BRANCH/PREV_VERSION, but -b overrides -t."
             exit -1
         ;;
@@ -320,7 +325,22 @@ XULRUNNER_VERSION=`grep '^XULRUNNER_VERSION' Makefile.am`
 XULRUNNER_VERSION=${XULRUNNER_VERSION##XULRUNNER_VERSION=}
 
 echo "Prepping server download files"
-cd ../../../../
+
+if [ "$BUILD_BROWSER_CLIENT" == "YES" ]; then
+    cd ../../../
+    echo "Building browser staff client"
+    cd Open-ILS/web/js/ui/default/staff/
+    npm install   # fetch build dependencies
+    bower install # fetch JS dependencies
+    grunt build # copy to build dir and minify JS files
+    # bower / node cache is big and unnecessary in the final build.  remove them.
+    rm -r bower_components node_modules 
+    cd ../../../../../../../ # release dir
+else
+    echo "Skipping browser client build"
+    cd ../../../../
+fi
+
 tar czf Evergreen-ILS-$VERSION.tar.gz Evergreen-ILS-$VERSION/
 md5sum Evergreen-ILS-$VERSION.tar.gz > Evergreen-ILS-$VERSION.tar.gz.md5
 if [ $PREV_BRANCH != "PACKAGE" ]; then # We need to have tagged to do this ;)

commit c267ec08ad901efe11926315697b23798a504074
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 14:02:22 2014 -0400

    LP#1350042 link opensrf websockets JS scripts
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/web/Makefile.am b/Open-ILS/web/Makefile.am
index 4b7a76a..d49719d 100644
--- a/Open-ILS/web/Makefile.am
+++ b/Open-ILS/web/Makefile.am
@@ -28,7 +28,9 @@ JSDOJOSRF = $(OPENSRF_LIBS)/javascript/md5.js \
 	     $(OPENSRF_LIBS)/javascript/JSON_v1.js \
 	     $(OPENSRF_LIBS)/javascript/opensrf.js \
 	     $(OPENSRF_LIBS)/javascript/opensrf_xhr.js \
-	     $(OPENSRF_LIBS)/javascript/opensrf_xmpp.js
+	     $(OPENSRF_LIBS)/javascript/opensrf_xmpp.js \
+	     $(OPENSRF_LIBS)/javascript/opensrf_ws.js \
+	     $(OPENSRF_LIBS)/javascript/opensrf_ws_shared.js
 endif
 
 install-exec-local: webcore-install offline-install

commit 48a063c16bcaffdd19628133496f281db18b5f54
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 13:24:37 2014 -0400

    LP#1350042 browser client apache config additions
    
    * Location configuratoin for /eg/staff templates
    * Support for "stop-at-index" in EGWeb, so that navigating to "/eg/foo"
      can result in /eg/index.tt2 getting served.
    * Cache and compress additions for /js files.
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/examples/apache/eg_vhost.conf.in b/Open-ILS/examples/apache/eg_vhost.conf.in
index d1b48f0..2f077ea 100644
--- a/Open-ILS/examples/apache/eg_vhost.conf.in
+++ b/Open-ILS/examples/apache/eg_vhost.conf.in
@@ -794,6 +794,33 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+<LocationMatch /eg/staff/>
+    Options -MultiViews
+    PerlSetVar OILSWebStopAtIndex "true"
+
+    # sample staff-specific translation files
+    #PerlAddVar OILSWebLocale "en_ca"                                           
+    #PerlAddVar OILSWebLocale "/openils/var/data/locale/staff/en-CA.po"         
+    #PerlAddVar OILSWebLocale "fr_ca"                                           
+    #PerlAddVar OILSWebLocale "/openils/var/data/locale/staff/fr-CA.po"   
+</LocationMatch>
+
+<Location /js/>
+    <IfModule mod_headers.c>
+        Header append Cache-Control "public"
+    </IFModule>
+    <IfModule mod_deflate.c>
+        SetOutputFilter DEFLATE
+        BrowserMatch ^Mozilla/4 gzip-only-text/html
+        BrowserMatch ^Mozilla/4\.0[678] no-gzip
+        BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html
+        SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary
+        <IfModule mod_headers.c>
+            Header append Vary User-Agent env=!dont-vary
+        </IfModule>
+    </IfModule>
+</Location>
+
 # Uncomment the following to force SSL for everything. Note that this defeats caching
 # and you will suffer a performance hit.
 #RewriteCond %{HTTPS} off
diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in
index f6e65e7..a44b9ad 100644
--- a/Open-ILS/examples/apache_24/eg_vhost.conf.in
+++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in
@@ -806,6 +806,34 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+<LocationMatch /eg/staff/>
+    Options -MultiViews
+    PerlSetVar OILSWebStopAtIndex "true"
+
+    # sample staff-specific translation files
+    #PerlAddVar OILSWebLocale "en_ca"                                           
+    #PerlAddVar OILSWebLocale "/openils/var/data/locale/staff/en-CA.po"         
+    #PerlAddVar OILSWebLocale "fr_ca"                                           
+    #PerlAddVar OILSWebLocale "/openils/var/data/locale/staff/fr-CA.po"   
+</LocationMatch>
+
+<Location /js/>
+    <IfModule mod_headers.c>
+        Header append Cache-Control "public"
+    </IFModule>
+    <IfModule mod_deflate.c>
+        SetOutputFilter DEFLATE
+        BrowserMatch ^Mozilla/4 gzip-only-text/html
+        BrowserMatch ^Mozilla/4\.0[678] no-gzip
+        BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html
+        SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary
+        <IfModule mod_headers.c>
+            Header append Vary User-Agent env=!dont-vary
+        </IfModule>
+    </IfModule>
+</Location>
+
+
 # Uncomment the following to force SSL for everything. Note that this defeats caching
 # and you will suffer a performance hit.
 #RewriteCond %{HTTPS} off
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm
index 0f9e93e..d111cd2 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm
@@ -239,11 +239,12 @@ sub find_template {
     my $page_args = [];
     my $as_xml = $r->dir_config('OILSWebForceValidXML');
     my $ext = $r->dir_config('OILSWebDefaultTemplateExtension');
+    my $at_index = $r->dir_config('OILSWebStopAtIndex');
 
     my @parts = split('/', $path);
     my $localpath = $path;
 
-    if ($localpath =~ m|opac/css|) {
+    if ($localpath =~ m|/css/|) {
         $r->content_type('text/css; encoding=utf8');
     } else {
         $r->content_type('text/html; encoding=utf8');
@@ -257,9 +258,31 @@ sub find_template {
             if(-r $fpath) {
                 $template = "$localpath.$ext";
                 last;
+            } 
+        }
+        last if $template;
+
+        if ($at_index) {
+            # no matching template was found in the current directory.
+            # stop-at-index requested; see if there is an index.ext 
+            # file in the same directory instead.
+            for my $tpath (@{$ctx->{template_paths}}) {
+                # replace the final path component with 'index'
+                if ($localpath =~ m|/$|) {
+                    $localpath .= 'index';
+                } else {
+                    $localpath =~ s|/[^/]+$|/index|;
+                }
+                my $fpath = "$tpath/$localpath.$ext";
+                $r->log->debug("egweb: looking at possible template $fpath");
+                if (-r $fpath) {
+                    $template = "$localpath.$ext";
+                    last;
+                }
             }
         }
         last if $template;
+
         push(@args, pop @parts);
         $localpath = join('/', @parts);
     } 

commit 9aa9269eddd6e43b10b8ad8245eda0e887422f52
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 13:16:02 2014 -0400

    LP#1350042 detect is-staff mode for browser client
    
    The browser client does not pass an OILS-Wrapper header to the embedded
    catalog (etc.) so consider the presence of a workstation an indication
    that the TPAC should operate in staff mode.
    
    NOTE: this may require some additional thought, since this means all
    TPACs within an authenticated browser will run in staff mode, which may
    not be desired.
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
index 5e8743e..eda0c65 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
@@ -260,10 +260,17 @@ sub load_common {
     $ctx->{full_path} = $ctx->{base_path} . $self->cgi->path_info;
     $ctx->{unparsed_uri} = $self->apache->unparsed_uri;
     $ctx->{opac_root} = $ctx->{base_path} . "/opac"; # absolute base url
-    my $oils_wrapper = $self->apache->headers_in->get('OILS-Wrapper') || '';
-    $ctx->{is_staff} = ($oils_wrapper =~ /true/);
-    $ctx->{proto} = 'oils' if $ctx->{is_staff};
-    $ctx->{hostname} = 'remote' if $ctx->{is_staff};
+
+    my $xul_wrapper = 
+        ($self->apache->headers_in->get('OILS-Wrapper') || '') =~ /true/;
+
+    if ($xul_wrapper) {
+        # XUL client
+        $ctx->{is_staff} = 1;
+        $ctx->{proto} = 'oils';
+        $ctx->{hostname} = 'remote';
+    }
+
     $ctx->{physical_loc} = $self->get_physical_loc;
 
     # capture some commonly accessed pages
@@ -279,6 +286,17 @@ sub load_common {
             $ctx->{user} = $e->requestor;
             $ctx->{place_unfillable} = 1 if $e->requestor->wsid && $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
 
+            # The browser client does not set an OILS-Wrapper header (above).
+            # The presence of a workstation and no header indicates staff mode.
+            # FIXME: this approach leaves un-wrapped TPAC's within the same
+            # browser (and hence same ses cookie) in an unnatural is_staff
+            # state.  Consider alternatives for determining is_staff / 
+            # is_browser_staff when $xul_wrapper is false.
+            if (!$xul_wrapper and $e->requestor->wsid) {
+                $ctx->{is_staff} = 1;
+                $ctx->{is_browser_staff} = 1;
+            }
+
             $ctx->{user_stats} = $U->simplereq(
                 'open-ils.actor', 
                 'open-ils.actor.user.opac.vital_stats', 

commit 7df9dd0aeccdd87e156ab499e2408f550dc0a3d7
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 13:12:56 2014 -0400

    LP#1350042 streaming holds pull list API
    
    Adds an API name-based option to the standard holds pull list API to
    respond with a stream of fleshed users, so the caller is not forced to
    make additional fetch-by-ID calls to collect the holds data.
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
index 8b27917..b308086 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
@@ -1555,6 +1555,25 @@ __PACKAGE__->register_method(
     }
 );
 
+__PACKAGE__->register_method(
+    method    => "hold_pull_list",
+    stream => 1,
+    # TODO: tag with api_level 2 once fully supported
+    api_name  => "open-ils.circ.hold_pull_list.fleshed.stream",
+    signature => {
+        desc   => q/Returns a stream of fleshed holds  that need to be 
+                    "pulled" by a given location.  The location is 
+                    determined by the login session.  
+                    This API calls always run in authoritative mode./,
+        params => [
+            { desc => 'Limit (optional)',  type => 'number'},
+            { desc => 'Offset (optional)', type => 'number'},
+        ],
+        return => {
+            desc => 'Stream of holds holds, or event on failure',
+        }
+    }
+);
 
 sub hold_pull_list {
     my( $self, $conn, $authtoken, $limit, $offset ) = @_;
@@ -1577,12 +1596,24 @@ sub hold_pull_list {
         return $count;
 
     } elsif( $self->api_name =~ /id_list/ ) {
-        return $U->storagereq(
+        $U->storagereq(
+            'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
+            $org, $limit, $offset );
+
+    } elsif ($self->api_name =~ /fleshed/) {
+
+        my $ids = $U->storagereq(
             'open-ils.storage.direct.action.hold_request.pull_list.id_list.current_copy_circ_lib.status_filtered.atomic',
             $org, $limit, $offset );
 
+        my $e = new_editor(xact => 1, requestor => $reqr);
+        $conn->respond(uber_hold_impl($e, $_, {flesh_acpl => 1})) for @$ids;
+        $e->rollback;
+        $conn->respond_complete;
+        return;
+
     } else {
-        return $U->storagereq(
+        $U->storagereq(
             'open-ils.storage.direct.action.hold_request.pull_list.search.current_copy_circ_lib.status_filtered.atomic',
             $org, $limit, $offset );
     }
@@ -3384,6 +3415,10 @@ sub uber_hold_impl {
         %$details
     };
 
+    $resp->{copy}->location(
+        $e->retrieve_asset_copy_location($resp->{copy}->location))
+        if $resp->{copy} and $args->{flesh_acpl};
+
     unless($args->{suppress_patron_details}) {
         my $card = $e->retrieve_actor_card($user->card) or return $e->event;
         $resp->{patron_first}   = $user->first_given_name,

commit f7fc7fa2a1bac404317a7ba549beaf73d56c0ac5
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 13:10:49 2014 -0400

    LP#1350042 streaming patron search API
    
    Adds an API name-based option to the standard patron search API to
    respond with a stream of fleshed users, so the caller is not forced to
    make additional fetch-by-ID calls to collect the user data.
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
index 4dd3c01..be97787 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
@@ -1317,9 +1317,30 @@ __PACKAGE__->register_method(
     method   => "patron_adv_search",
     api_name => "open-ils.actor.patron.search.advanced"
 );
+
+__PACKAGE__->register_method(
+    method   => "patron_adv_search",
+    api_name => "open-ils.actor.patron.search.advanced.fleshed",
+    stream => 1,
+    # TODO: change when opensrf 'bundling' is merged.
+    # set a relatively small bundle size so the caller can start
+    # seeing results fairly quickly
+    max_chunk_size => 4096, # bundling
+
+    # api_level => 2, 
+    # pending opensrf work -- also, not sure if needed since we're not
+    # actaully creating an alternate vesrion, only offering to return a
+    # different format.
+    #
+    signature => {
+        desc => q/Returns a stream of fleshed user objects instead of
+            a pile of identifiers/
+    }
+);
+
 sub patron_adv_search {
-    my( $self, $client, $auth, $search_hash, 
-        $search_limit, $search_sort, $include_inactive, $search_ou ) = @_;
+    my( $self, $client, $auth, $search_hash, $search_limit, 
+        $search_sort, $include_inactive, $search_ou, $flesh_fields, $offset) = @_;
 
     my $e = new_editor(authtoken=>$auth);
     return $e->event unless $e->checkauth;
@@ -1341,9 +1362,17 @@ sub patron_adv_search {
             );
         }
     }
-    return $U->storagereq(
+
+    my $ids = $U->storagereq(
         "open-ils.storage.actor.user.crazy_search", $search_hash, 
-            $search_limit, $search_sort, $include_inactive, $e->requestor->ws_ou, $search_ou, $opt_boundary);
+        $search_limit, $search_sort, $include_inactive, 
+        $e->requestor->ws_ou, $search_ou, $opt_boundary, $offset);
+
+    return $ids unless $self->api_name =~ /fleshed/;
+
+    $client->respond(new_flesh_user($_, $flesh_fields, $e)) for @$ids;
+
+    return;
 }
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/actor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/actor.pm
index 06117ff..f55da8c 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/actor.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/actor.pm
@@ -642,6 +642,7 @@ sub patron_search {
     my $ws_ou = shift;
     my $search_org = shift || $ws_ou;
     my $opt_boundary = shift || 0;
+    my $offset = shift || 0;
 
     my $penalty_sort = 0;
 
@@ -796,6 +797,7 @@ sub patron_search {
           GROUP BY $group_list
           ORDER BY $order_by
           LIMIT $limit
+          OFFSET $offset
     SQL
 
     return actor::user->db_Main->selectcol_arrayref($select, {Columns=>[scalar(@$sort)]}, map {lc($_)} (@usrv, at phonev, at identv, at namev, at addrv));

commit cd4b5c68affdaf7cb915c81f9b252df8688344db
Author: Bill Erickson <berick at esilibrary.com>
Date:   Mon Aug 4 13:08:22 2014 -0400

    LP#1350042 PCRUD access for various IDL classes
    
    These changes are mostly data retrieval and help support
    PCRUD/auto-grid-style retrieval of certain patron data.
    
    Classes affected:
    
    actor::workstation
    money::billable_transaction_summary
    actor::usr_note
    money::credit_card_payment
    money::open_billable_transaction_summary
    money::cash_payment
    money::forgive_payment
    action::hold_notification
    money::credit_payment
    config::non_cataloged_type
    money::billable_transaction
    money::work_payment
    money::goods_payment
    money::check_payment
    money::payment
    money::billing
    
    Signed-off-by: Bill Erickson <berick at esilibrary.com>
    Signed-off-by: Ben Shum <bshum at biblio.org>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 63987ef..c91a9b6 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -1383,7 +1383,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 		</permacrud>
 	</class>
 
-	<class id="aws" controller="open-ils.cstore" oils_obj:fieldmapper="actor::workstation" oils_persist:tablename="actor.workstation" reporter:label="Workstation">
+	<class id="aws" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::workstation" oils_persist:tablename="actor.workstation" reporter:label="Workstation">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.workstation_id_seq">
 			<field reporter:label="Workstation ID" name="id" reporter:datatype="id"/>
 			<field reporter:label="Workstation Name" name="name" reporter:datatype="text"/>
@@ -1396,6 +1396,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="toolbars" reltype="has_many" key="ws" map="" class="atb"/>
 			<link field="circulations" reltype="has_many" key="workstation" map="" class="circ"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve permission="STAFF_LOGIN" context_field="owning_lib" />
+			</actions>
+		</permacrud>
 	</class>
 
 	<class id="ccm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::circ_modifier" oils_persist:tablename="config.circ_modifier" reporter:label="Circulation Modifier">
@@ -1963,7 +1968,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
 	</class>
-	<class id="mbts" controller="open-ils.cstore" oils_obj:fieldmapper="money::billable_transaction_summary" oils_persist:tablename="money.materialized_billable_xact_summary" reporter:label="Billable Transaction Summary" oils_persist:readonly="true">
+	<class id="mbts" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::billable_transaction_summary" oils_persist:tablename="money.materialized_billable_xact_summary" reporter:label="Billable Transaction Summary" oils_persist:readonly="true">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
 			<field reporter:label="Balance Owed" name="balance_owed" reporter:datatype="money"/>
 			<field reporter:label="Transaction ID" name="id" reporter:datatype="id"/>
@@ -1983,6 +1988,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 		<links>
 			<link field="usr" reltype="has_a" key="id" map="" class="au"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve permission="VIEW_USER_TRANSACTIONS">
+					<context link="usr" field="home_ou" />
+				</retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="mbtslv" controller="open-ils.cstore" oils_obj:fieldmapper="money::billable_transaction_summary_location_view" oils_persist:tablename="money.billable_xact_summary_location_view" reporter:label="Billable Transaction Summary with Billing Location" oils_persist:readonly="true" reporter:core="true">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
@@ -2007,7 +2019,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="billing_location" reltype="has_a" key="id" map="" class="aou"/>
 		</links>
 	</class>
-	<class id="aun" controller="open-ils.cstore" oils_obj:fieldmapper="actor::usr_note" oils_persist:tablename="actor.usr_note" reporter:label="User Note">
+	<class id="aun" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::usr_note" oils_persist:tablename="actor.usr_note" reporter:label="User Note">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.usr_note_id_seq">
 			<field reporter:label="Creation Date/Time" name="create_date" reporter:datatype="timestamp"/>
 			<field reporter:label="Creating Staff" name="creator" reporter:datatype="link"/>
@@ -2021,6 +2033,23 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="usr" reltype="has_a" key="id" map="" class="au"/>
 			<link field="creator" reltype="has_a" key="id" map="" class="au"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="UPDATE_USER" context_field="owner">
+                    <context link="usr" field="home_ou"/>
+                </create>
+				<!-- note: public notes are still accessible via API -->
+                <retrieve permission="UPDATE_USER">
+                    <context link="usr" field="home_ou"/>
+				</retrieve>
+                <update permission="UPDATE_USER">
+                    <context link="usr" field="home_ou"/>
+				</update>
+                <delete permission="UPDATE_USER">
+                    <context link="usr" field="home_ou"/>
+				</delete>
+            </actions>
+        </permacrud>
 	</class>
 	<class id="aupr" controller="open-ils.cstore" oils_obj:fieldmapper="actor::usr_password_reset" oils_persist:tablename="actor.usr_password_reset" reporter:label="User password reset requests">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.usr_password_reset_id_seq">
@@ -2427,7 +2456,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
 	</class>
-	<class id="mccp" controller="open-ils.cstore" oils_obj:fieldmapper="money::credit_card_payment" oils_persist:tablename="money.credit_card_payment" reporter:label="Credit Card Payment">
+	<class id="mccp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::credit_card_payment" oils_persist:tablename="money.credit_card_payment" reporter:label="Credit Card Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field name="accepting_usr" />
 			<field name="amount" reporter:datatype="money" />
@@ -2455,6 +2484,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="cxt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::xml_transform" oils_persist:tablename="config.xml_transform" reporter:label="XML/XSLT Transform Definition">
 		<fields oils_persist:primary="name">
@@ -3004,7 +3040,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 		</permacrud>
 	</class>
 
-	<class id="mobts" controller="open-ils.cstore" oils_obj:fieldmapper="money::open_billable_transaction_summary" oils_persist:tablename="money.open_billable_xact_summary" reporter:label="Open Billable Transaction Summary">
+	<class id="mobts" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::open_billable_transaction_summary" oils_persist:tablename="money.open_billable_xact_summary" reporter:label="Open Billable Transaction Summary">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
 			<field name="balance_owed" reporter:datatype="money"/>
 			<field name="id" reporter:datatype="id" />
@@ -3034,6 +3070,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="reservation" reltype="might_have" key="id" map="" class="bresv"/>
 			<link field="billing_location" reltype="has_a" key="id" map="" class="aou"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve permission="VIEW_USER_TRANSACTIONS">
+					<context link="usr" field="home_ou" />
+				</retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="au" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::user" oils_persist:tablename="actor.usr" reporter:core="true" reporter:label="ILS User">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.usr_id_seq">
@@ -3473,7 +3516,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="field" reltype="has_a" key="id" map="" class="cmf"/>
 		</links>
 	</class>
-	<class id="mcp" controller="open-ils.cstore" oils_obj:fieldmapper="money::cash_payment" oils_persist:tablename="money.cash_payment" reporter:label="Cash Payment">
+	<class id="mcp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::cash_payment" oils_persist:tablename="money.cash_payment" reporter:label="Cash Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field name="accepting_usr" reporter:datatype="link"/>
 			<field name="amount" reporter:datatype="money" />
@@ -3492,8 +3535,15 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
-	<class id="mfp" controller="open-ils.cstore" oils_obj:fieldmapper="money::forgive_payment" oils_persist:tablename="money.forgive_payment" reporter:label="Forgive Payment">
+	<class id="mfp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::forgive_payment" oils_persist:tablename="money.forgive_payment" reporter:label="Forgive Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field name="accepting_usr" reporter:datatype="link"/>
 			<field name="amount" reporter:datatype="money" />
@@ -3510,6 +3560,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 			<link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="mrd" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::record_descriptor" oils_persist:tablename="metabib.rec_descriptor" reporter:label="Basic Record Descriptor">
 		<fields oils_persist:primary="id" oils_persist:sequence="metabib.rec_descriptor_id_seq">
@@ -4338,7 +4395,7 @@ SELECT  usr,
 			<link field="target_copy" reltype="has_a" key="id" map="" class="acp"/>
 		</links>
 	</class>
-	<class id="ahn" controller="open-ils.cstore" oils_obj:fieldmapper="action::hold_notification" oils_persist:tablename="action.hold_notification" reporter:label="Hold Notification">
+	<class id="ahn" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="action::hold_notification" oils_persist:tablename="action.hold_notification" reporter:label="Hold Notification">
 		<fields oils_persist:primary="id" oils_persist:sequence="action.hold_notification_id_seq">
 			<field reporter:label="Hold" name="hold" reporter:datatype="link"/>
 			<field reporter:label="Notification ID" name="id" reporter:datatype="id" />
@@ -4351,6 +4408,22 @@ SELECT  usr,
 			<link field="hold" reltype="has_a" key="id" map="" class="ahr"/>
 			<link field="notify_staff" reltype="has_a" key="id" map="" class="au"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="CREATE_HOLD_NOTIFICATION">
+					<context link="hold" jump="usr" field="home_ou" />
+				</create>
+                <retrieve permission="VIEW_HOLD_NOTIFICATION">
+					<context link="hold" jump="usr" field="home_ou" />
+				</retrieve>
+                <update permission="CREATE_HOLD_NOTIFICATION">
+					<context link="hold" jump="usr" field="home_ou" />
+				</update>
+                <delete permission="CREATE_HOLD_NOTIFICATION">
+					<context link="hold" jump="usr" field="home_ou" />
+				</delete>
+            </actions>
+        </permacrud>
 	</class>
 	<class id="acpl" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_location" oils_persist:tablename="asset.copy_location" reporter:label="Copy/Shelving Location"  oils_persist:field_safe="true">
 		<fields oils_persist:primary="id" oils_persist:sequence="asset.copy_location_id_seq">
@@ -5966,7 +6039,7 @@ SELECT  usr,
 			<link field="bucket" reltype="has_a" key="id" map="" class="cub"/>
 		</links>
 	</class>
-	<class id="mcrp" controller="open-ils.cstore" oils_obj:fieldmapper="money::credit_payment" oils_persist:tablename="money.credit_payment" reporter:label="House Credit Payment">
+	<class id="mcrp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::credit_payment" oils_persist:tablename="money.credit_payment" reporter:label="House Credit Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field reporter:label="Accepting Staff Member" name="accepting_usr" reporter:datatype="link"/>
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
@@ -5983,6 +6056,13 @@ SELECT  usr,
 			<link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="afr" controller="open-ils.cstore" oils_obj:fieldmapper="authority::full_rec" oils_persist:tablename="authority.full_rec" reporter:label="Full Authority Record">
 		<fields oils_persist:primary="id" oils_persist:sequence="authority.full_rec_id_seq">
@@ -6011,7 +6091,7 @@ SELECT  usr,
 			<link field="field" reltype="has_a" key="id" map="" class="acsaf"/>
 		</links>
 	</class>
-	<class id="cnct" controller="open-ils.cstore" oils_obj:fieldmapper="config::non_cataloged_type" oils_persist:tablename="config.non_cataloged_type" reporter:label="Non-cataloged Type">
+	<class id="cnct" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::non_cataloged_type" oils_persist:tablename="config.non_cataloged_type" reporter:label="Non-cataloged Type">
 		<fields oils_persist:primary="id" oils_persist:sequence="config.non_cataloged_type_id_seq">
 			<field reporter:label="Circulation Duration" name="circ_duration" reporter:datatype="interval"/>
 			<field reporter:label="Non-cat Type ID" name="id" reporter:selector="name" reporter:datatype="id"/>
@@ -6022,6 +6102,15 @@ SELECT  usr,
 		<links>
 			<link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="CREATE_NON_CAT_TYPE" context_field="owning_lib"/>
+                <retrieve/>
+                <update permission="CREATE_NON_CAT_TYPE" context_field="owning_lib"/>
+                <delete permission="CREATE_NON_CAT_TYPE" context_field="owning_lib"/>
+            </actions>
+        </permacrud>
+
 	</class>
 	<class id="aout" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::org_unit_type" oils_persist:tablename="actor.org_unit_type" reporter:label="Organizational Unit Type" oils_persist:field_safe="true">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.org_unit_type_id_seq">
@@ -6119,7 +6208,7 @@ SELECT  usr,
             </actions>
         </permacrud>
 	</class>
-	<class id="mbt" controller="open-ils.cstore" oils_obj:fieldmapper="money::billable_transaction" oils_persist:tablename="money.billable_xact" reporter:label="Billable Transaction">
+	<class id="mbt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::billable_transaction" oils_persist:tablename="money.billable_xact" reporter:label="Billable Transaction">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.billable_xact_id_seq">
 			<field reporter:label="Transaction ID" name="id" reporter:datatype="id" />
 			<field reporter:label="User" name="usr" reporter:datatype="link"/>
@@ -6144,6 +6233,13 @@ SELECT  usr,
 			<link field="payment_total" reltype="might_have" key="xact" map="" class="rxpt"/>
 			<link field="summary" reltype="might_have" key="id" map="" class="mbts"/>
 		</links>
+		<permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+			<actions>
+				<retrieve permission="VIEW_USER_TRANSACTIONS">
+					<context link="usr" field="home_ou" />
+				</retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="actsce" controller="open-ils.cstore" oils_obj:fieldmapper="actor::stat_cat_entry" oils_persist:tablename="actor.stat_cat_entry" reporter:label="User Stat Cat Entry">
 		<fields oils_persist:primary="id" oils_persist:sequence="actor.stat_cat_entry_id_seq">
@@ -6224,7 +6320,7 @@ SELECT  usr,
             </actions>
         </permacrud>
 	</class>
-	<class id="mwp" controller="open-ils.cstore" oils_obj:fieldmapper="money::work_payment" oils_persist:tablename="money.work_payment" reporter:label="Work Payment">
+	<class id="mwp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::work_payment" oils_persist:tablename="money.work_payment" reporter:label="Work Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field reporter:label="Accepting Staff Member" name="accepting_usr" reporter:datatype="link"/>
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
@@ -6241,8 +6337,15 @@ SELECT  usr,
 			<link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
-	<class id="mgp" controller="open-ils.cstore" oils_obj:fieldmapper="money::goods_payment" oils_persist:tablename="money.goods_payment" reporter:label="Goods Payment">
+	<class id="mgp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::goods_payment" oils_persist:tablename="money.goods_payment" reporter:label="Goods Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field reporter:label="Accepting Staff Member" name="accepting_usr" reporter:datatype="link"/>
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
@@ -6259,6 +6362,13 @@ SELECT  usr,
 			<link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="aoc" controller="open-ils.cstore" oils_obj:fieldmapper="action::open_circulation" oils_persist:tablename="action.open_circulation" reporter:label="Open Circulation">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
@@ -6328,7 +6438,7 @@ SELECT  usr,
             </actions>
         </permacrud>
 	</class>
-	<class id="mckp" controller="open-ils.cstore" oils_obj:fieldmapper="money::check_payment" oils_persist:tablename="money.check_payment" reporter:label="Check Payment">
+	<class id="mckp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::check_payment" oils_persist:tablename="money.check_payment" reporter:label="Check Payment">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
 			<field reporter:label="Accepting Staff Member" name="accepting_usr" reporter:datatype="link"/>
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
@@ -6348,6 +6458,13 @@ SELECT  usr,
 			<link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="acp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy" oils_persist:tablename="asset.copy" reporter:core="true" reporter:label="Item">
 		<fields oils_persist:primary="id" oils_persist:sequence="asset.copy_id_seq">
@@ -6730,7 +6847,7 @@ SELECT  usr,
 			<link field="perm" reltype="has_a" key="id" map="" class="ppl"/>
 		</links>
 	</class>
-	<class id="mp" controller="open-ils.cstore" oils_obj:fieldmapper="money::payment" oils_persist:tablename="money.payment_view" reporter:core="true" reporter:label="Payments: All">
+	<class id="mp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::payment" oils_persist:tablename="money.payment_view" reporter:core="true" reporter:label="Payments: All">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
 			<field reporter:label="Payment ID" name="id" reporter:datatype="id" />
@@ -6757,6 +6874,13 @@ SELECT  usr,
 			<link field="goods_payment" reltype="might_have" key="id" map="" class="mgp"/>
 			<link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="mbp" controller="open-ils.cstore" oils_obj:fieldmapper="money::bnm_payment" oils_persist:tablename="money.bnm_payment_view" reporter:core="true" reporter:label="Payments: Brick-and-mortar">
 		<fields oils_persist:primary="id" oils_persist:sequence="">
@@ -6974,7 +7098,7 @@ SELECT  usr,
             </actions>
         </permacrud>
 	</class>
-	<class id="mb" controller="open-ils.cstore" oils_obj:fieldmapper="money::billing" oils_persist:tablename="money.billing" reporter:label="Billing Line Item">
+	<class id="mb" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::billing" oils_persist:tablename="money.billing" reporter:label="Billing Line Item">
 		<fields oils_persist:primary="id" oils_persist:sequence="money.billing_id_seq">
 			<field reporter:label="Amount" name="amount" reporter:datatype="money" />
 			<field reporter:label="Billing Timestamp" name="billing_ts" reporter:datatype="timestamp"/>
@@ -6992,6 +7116,13 @@ SELECT  usr,
 			<link field="voider" reltype="has_a" key="id" map="" class="au"/>
 			<link field="btype" reltype="has_a" key="id" map="" class="cbt"/>
 		</links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+			</actions>
+		</permacrud>
 	</class>
 	<class id="pugm" controller="open-ils.cstore" oils_obj:fieldmapper="permission::usr_grp_map" oils_persist:tablename="permission.usr_grp_map" reporter:label="User Group Map">
 		<fields oils_persist:primary="id" oils_persist:sequence="permission.usr_grp_map_id_seq">

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

Summary of changes:
 .gitignore                                         |    3 +
 Open-ILS/examples/apache/eg_vhost.conf.in          |   27 +
 Open-ILS/examples/apache_24/eg_vhost.conf.in       |   28 +
 Open-ILS/examples/fm_IDL.xml                       |  163 ++-
 .../src/perlmods/lib/OpenILS/Application/Actor.pm  |   37 +-
 .../perlmods/lib/OpenILS/Application/Circ/Holds.pm |   39 +-
 .../OpenILS/Application/Storage/Publisher/actor.pm |    2 +
 .../src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm    |   26 +-
 Open-ILS/src/perlmods/lib/OpenILS/WWW/EGWeb.pm     |   25 +-
 .../src/templates/opac/parts/bookbag_actions.tt2   |    2 +-
 .../src/templates/opac/parts/record/copy_table.tt2 |   14 +
 Open-ILS/src/templates/staff/README                |    6 +
 .../templates/staff/admin/t_user_perms_lookup.tt2  |   24 +
 Open-ILS/src/templates/staff/admin/user_perms.tt2  |   18 +
 .../templates/staff/admin/workstation/index.tt2    |   24 +
 .../staff/admin/workstation/t_print_config.tt2     |  176 +++
 .../staff/admin/workstation/t_print_templates.tt2  |   59 +
 .../templates/staff/admin/workstation/t_splash.tt2 |  120 ++
 .../staff/admin/workstation/t_stored_prefs.tt2     |   65 +
 Open-ILS/src/templates/staff/base.tt2              |   48 +
 Open-ILS/src/templates/staff/base_js.tt2           |   47 +
 .../templates/staff/cat/bucket/record/index.tt2    |   65 +
 .../staff/cat/bucket/record/t_bucket_create.tt2    |   35 +
 .../staff/cat/bucket/record/t_bucket_delete.tt2    |   16 +
 .../staff/cat/bucket/record/t_bucket_edit.tt2      |   34 +
 .../staff/cat/bucket/record/t_bucket_export.tt2    |   41 +
 .../staff/cat/bucket/record/t_bucket_info.tt2      |   16 +
 .../staff/cat/bucket/record/t_bucket_selector.tt2  |   27 +
 .../staff/cat/bucket/record/t_grid_menu.tt2        |   20 +
 .../staff/cat/bucket/record/t_load_shared.tt2      |   25 +
 .../staff/cat/bucket/record/t_pending.tt2          |   20 +
 .../templates/staff/cat/bucket/record/t_search.tt2 |   46 +
 .../templates/staff/cat/bucket/record/t_view.tt2   |   28 +
 Open-ILS/src/templates/staff/cat/catalog/index.tt2 |   21 +
 .../src/templates/staff/cat/catalog/t_catalog.tt2  |   48 +
 .../src/templates/staff/cat/catalog/t_holds.tt2    |  108 ++
 Open-ILS/src/templates/staff/cat/item/index.tt2    |   83 ++
 .../templates/staff/cat/item/missing_pieces.tt2    |   69 +
 .../staff/cat/item/replace_barcode/index.tt2       |   49 +
 .../src/templates/staff/cat/item/t_cat_pane.tt2    |    3 +
 .../templates/staff/cat/item/t_circ_list_pane.tt2  |   51 +
 .../src/templates/staff/cat/item/t_circs_pane.tt2  |  189 +++
 .../src/templates/staff/cat/item/t_holds_pane.tt2  |  125 ++
 Open-ILS/src/templates/staff/cat/item/t_list.tt2   |   21 +
 .../templates/staff/cat/item/t_summary_pane.tt2    |  178 +++
 .../staff/cat/item/t_triggered_events_pane.tt2     |    2 +
 Open-ILS/src/templates/staff/cat/item/t_view.tt2   |   34 +
 .../templates/staff/cat/share/t_record_summary.tt2 |   53 +
 .../staff/cat/t_triggered_events_pane.tt2          |    2 +
 .../src/templates/staff/circ/checkin/index.tt2     |   19 +
 .../src/templates/staff/circ/checkin/t_checkin.tt2 |  225 +++
 .../staff/circ/checkin/t_checkin_table.tt2         |   89 ++
 Open-ILS/src/templates/staff/circ/holds/index.tt2  |   30 +
 Open-ILS/src/templates/staff/circ/holds/t_pull.tt2 |   26 +
 .../src/templates/staff/circ/holds/t_pull_list.tt2 |   88 ++
 .../src/templates/staff/circ/holds/t_shelf.tt2     |   40 +
 .../templates/staff/circ/holds/t_shelf_list.tt2    |   85 ++
 .../templates/staff/circ/in_house_use/index.tt2    |   81 +
 Open-ILS/src/templates/staff/circ/patron/index.tt2 |  175 +++
 .../src/templates/staff/circ/patron/pending.tt2    |   17 +
 .../src/templates/staff/circ/patron/register.tt2   |   15 +
 .../src/templates/staff/circ/patron/t_alerts.tt2   |   75 +
 .../src/templates/staff/circ/patron/t_bcsearch.tt2 |   22 +
 .../templates/staff/circ/patron/t_bill_history.tt2 |   34 +
 .../staff/circ/patron/t_bill_history_payments.tt2  |   79 +
 .../staff/circ/patron/t_bill_history_xacts.tt2     |   49 +
 .../src/templates/staff/circ/patron/t_bills.tt2    |  109 ++
 .../templates/staff/circ/patron/t_bills_list.tt2   |   87 ++
 .../src/templates/staff/circ/patron/t_checkout.tt2 |  143 ++
 .../templates/staff/circ/patron/t_credentials.tt2  |   65 +
 .../src/templates/staff/circ/patron/t_edit.tt2     |    2 +
 .../staff/circ/patron/t_edit_due_date_dialog.tt2   |   24 +
 .../templates/staff/circ/patron/t_edit_perms.tt2   |    1 +
 .../src/templates/staff/circ/patron/t_group.tt2    |   55 +
 .../src/templates/staff/circ/patron/t_holds.tt2    |   38 +
 .../templates/staff/circ/patron/t_holds_create.tt2 |    4 +
 .../templates/staff/circ/patron/t_holds_list.tt2   |   82 ++
 .../templates/staff/circ/patron/t_items_out.tt2    |   73 +
 .../templates/staff/circ/patron/t_last_patron.tt2  |    9 +
 .../src/templates/staff/circ/patron/t_messages.tt2 |   52 +
 .../staff/circ/patron/t_move_to_group_dialog.tt2   |   28 +
 .../staff/circ/patron/t_new_note_dialog.tt2        |   42 +
 .../src/templates/staff/circ/patron/t_notes.tt2    |   44 +
 .../templates/staff/circ/patron/t_pending_list.tt2 |   34 +
 .../staff/circ/patron/t_renew_with_date_dialog.tt2 |   24 +
 .../src/templates/staff/circ/patron/t_search.tt2   |  158 ++
 .../staff/circ/patron/t_search_results.tt2         |   30 +
 .../templates/staff/circ/patron/t_stat_cats.tt2    |   19 +
 .../src/templates/staff/circ/patron/t_summary.tt2  |  143 ++
 .../staff/circ/patron/t_triggered_events.tt2       |    3 +
 .../templates/staff/circ/patron/t_xact_details.tt2 |  144 ++
 Open-ILS/src/templates/staff/circ/renew/index.tt2  |   20 +
 .../src/templates/staff/circ/renew/t_renew.tt2     |  141 ++
 .../templates/staff/circ/share/circ_strings.tt2    |   39 +
 .../templates/staff/circ/share/hold_strings.tt2    |   30 +
 .../staff/circ/share/t_backdate_dialog.tt2         |   24 +
 .../staff/circ/share/t_bad_barcode_dialog.tt2      |   23 +
 .../staff/circ/share/t_bill_patron_dialog.tt2      |   93 ++
 .../staff/circ/share/t_cancel_hold_dialog.tt2      |   38 +
 .../staff/circ/share/t_circ_exists_dialog.tt2      |   31 +
 .../staff/circ/share/t_copy_in_transit_dialog.tt2  |   32 +
 .../staff/circ/share/t_copy_not_avail_dialog.tt2   |   20 +
 .../staff/circ/share/t_event_override_dialog.tt2   |   27 +
 .../circ/share/t_hold_copy_quality_dialog.tt2      |   24 +
 .../templates/staff/circ/share/t_hold_dates.tt2    |   75 +
 .../templates/staff/circ/share/t_hold_details.tt2  |  149 ++
 .../staff/circ/share/t_hold_edit_pickup_lib.tt2    |   23 +
 .../staff/circ/share/t_hold_note_dialog.tt2        |   51 +
 .../circ/share/t_hold_notification_dialog.tt2      |   33 +
 .../staff/circ/share/t_hold_notification_prefs.tt2 |   78 +
 .../staff/circ/share/t_hold_shelf_dialog.tt2       |   63 +
 .../circ/share/t_mark_claims_returned_dialog.tt2   |   25 +
 .../staff/circ/share/t_new_message_dialog.tt2      |   45 +
 .../templates/staff/circ/share/t_noncat_dialog.tt2 |   25 +
 .../templates/staff/circ/share/t_precat_dialog.tt2 |   44 +
 .../staff/circ/share/t_transit_dialog.tt2          |   65 +
 Open-ILS/src/templates/staff/config.tt2            |   13 +
 Open-ILS/src/templates/staff/css/circ.css.tt2      |   60 +
 Open-ILS/src/templates/staff/css/print.css.tt2     |   13 +
 Open-ILS/src/templates/staff/css/style.css.tt2     |  412 ++++++
 Open-ILS/src/templates/staff/index.tt2             |   17 +
 Open-ILS/src/templates/staff/navbar.tt2            |  244 ++++
 Open-ILS/src/templates/staff/share/README          |    5 +
 .../staff/share/print_templates/index.tt2          |    2 +
 .../staff/share/print_templates/t_bill_payment.tt2 |   69 +
 .../share/print_templates/t_bills_current.tt2      |   49 +
 .../share/print_templates/t_bills_historical.tt2   |   49 +
 .../staff/share/print_templates/t_checkin.tt2      |   17 +
 .../staff/share/print_templates/t_checkout.tt2     |   17 +
 .../share/print_templates/t_hold_pull_list.tt2     |   29 +
 .../share/print_templates/t_hold_shelf_slip.tt2    |   37 +
 .../share/print_templates/t_hold_transit_slip.tt2  |   34 +
 .../share/print_templates/t_holds_for_bib.tt2      |   29 +
 .../share/print_templates/t_holds_for_patron.tt2   |   14 +
 .../staff/share/print_templates/t_items_out.tt2    |   17 +
 .../share/print_templates/t_patron_address.tt2     |   12 +
 .../staff/share/print_templates/t_patron_note.tt2  |   11 +
 .../staff/share/print_templates/t_renew.tt2        |   17 +
 .../staff/share/print_templates/t_transit_slip.tt2 |   21 +
 .../src/templates/staff/share/t_alert_dialog.tt2   |   16 +
 Open-ILS/src/templates/staff/share/t_autogrid.tt2  |  297 ++++
 .../src/templates/staff/share/t_confirm_dialog.tt2 |   18 +
 Open-ILS/src/templates/staff/share/t_eframe.tt2    |    9 +
 .../src/templates/staff/share/t_prompt_dialog.tt2  |   21 +
 Open-ILS/src/templates/staff/statusbar.tt2         |   33 +
 Open-ILS/src/templates/staff/t_login.tt2           |   57 +
 Open-ILS/src/templates/staff/t_splash.tt2          |   69 +
 Open-ILS/web/Makefile.am                           |    4 +-
 Open-ILS/web/js/ui/default/opac/staff.js           |  225 ++--
 Open-ILS/web/js/ui/default/staff/Gruntfile.js      |  166 +++
 Open-ILS/web/js/ui/default/staff/README.install    |   93 ++
 .../web/js/ui/default/staff/admin/user_perms.js    |  100 ++
 .../js/ui/default/staff/admin/workstation/app.js   |  557 +++++++
 Open-ILS/web/js/ui/default/staff/app.js            |  125 ++
 Open-ILS/web/js/ui/default/staff/bower.json        |   30 +
 .../js/ui/default/staff/cat/bucket/record/app.js   |  535 +++++++
 .../web/js/ui/default/staff/cat/catalog/app.js     |  209 +++
 Open-ILS/web/js/ui/default/staff/cat/item/app.js   |  540 +++++++
 .../js/ui/default/staff/cat/item/missing_pieces.js |  123 ++
 .../default/staff/cat/item/replace_barcode/app.js  |   39 +
 .../web/js/ui/default/staff/cat/services/record.js |  106 ++
 .../web/js/ui/default/staff/circ/checkin/app.js    |  316 ++++
 Open-ILS/web/js/ui/default/staff/circ/holds/app.js |  315 ++++
 .../js/ui/default/staff/circ/in_house_use/app.js   |  123 ++
 .../web/js/ui/default/staff/circ/patron/app.js     | 1494 +++++++++++++++++++
 .../web/js/ui/default/staff/circ/patron/bills.js   |  731 ++++++++++
 .../js/ui/default/staff/circ/patron/checkout.js    |  201 +++
 .../web/js/ui/default/staff/circ/patron/holds.js   |  155 ++
 .../js/ui/default/staff/circ/patron/items_out.js   |  406 ++++++
 .../web/js/ui/default/staff/circ/patron/pending.js |   84 ++
 .../js/ui/default/staff/circ/patron/register.js    |   72 +
 Open-ILS/web/js/ui/default/staff/circ/renew/app.js |  213 +++
 .../js/ui/default/staff/circ/services/billing.js   |  175 +++
 .../web/js/ui/default/staff/circ/services/circ.js  | 1485 +++++++++++++++++++
 .../web/js/ui/default/staff/circ/services/holds.js |  605 ++++++++
 Open-ILS/web/js/ui/default/staff/package.json      |   28 +
 Open-ILS/web/js/ui/default/staff/services/auth.js  |  262 ++++
 Open-ILS/web/js/ui/default/staff/services/core.js  |    6 +
 .../web/js/ui/default/staff/services/coresvc.js    |   33 +
 .../web/js/ui/default/staff/services/eframe.js     |  210 +++
 Open-ILS/web/js/ui/default/staff/services/env.js   |  165 +++
 Open-ILS/web/js/ui/default/staff/services/event.js |   56 +
 Open-ILS/web/js/ui/default/staff/services/file.js  |   28 +
 Open-ILS/web/js/ui/default/staff/services/grid.js  | 1522 ++++++++++++++++++++
 Open-ILS/web/js/ui/default/staff/services/hatch.js |  428 ++++++
 Open-ILS/web/js/ui/default/staff/services/idl.js   |  141 ++
 .../web/js/ui/default/staff/services/navbar.js     |   79 +
 Open-ILS/web/js/ui/default/staff/services/net.js   |  100 ++
 Open-ILS/web/js/ui/default/staff/services/org.js   |  115 ++
 Open-ILS/web/js/ui/default/staff/services/pcrud.js |  298 ++++
 Open-ILS/web/js/ui/default/staff/services/print.js |  199 +++
 .../web/js/ui/default/staff/services/startup.js    |   84 ++
 .../web/js/ui/default/staff/services/statusbar.js  |   60 +
 .../web/js/ui/default/staff/services/strings.js    |   22 +
 Open-ILS/web/js/ui/default/staff/services/ui.js    |  297 ++++
 Open-ILS/web/js/ui/default/staff/services/user.js  |   56 +
 .../web/js/ui/default/staff/test/data/eg_mock.js   |   50 +
 .../web/js/ui/default/staff/test/data/idl2js.pl    |   22 +
 .../web/js/ui/default/staff/test/karma.conf.js     |   88 ++
 .../web/js/ui/default/staff/test/unit/egCore.js    |   18 +
 .../web/js/ui/default/staff/test/unit/egEvent.js   |   44 +
 .../web/js/ui/default/staff/test/unit/egHomeApp.js |   21 +
 .../web/js/ui/default/staff/test/unit/egIDL.js     |   25 +
 .../web/js/ui/default/staff/test/unit/egOrg.js     |   37 +
 .../js/ui/default/staff/test/unit/egPatronApp.js   |   31 +
 .../web/js/ui/default/staff/test/unit/egStrings.js |   14 +
 Open-ILS/web/opac/locale/en-US/lang.dtd            |    3 +
 .../xul/staff_client/server/patron/user_edit.xhtml |  176 +++
 .../staff_client/server/patron/user_edit_xhtml.js  |  499 +++++++
 build/tools/make_release                           |   24 +-
 210 files changed, 22194 insertions(+), 124 deletions(-)
 create mode 100644 Open-ILS/src/templates/staff/README
 create mode 100644 Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/user_perms.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/workstation/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2
 create mode 100644 Open-ILS/src/templates/staff/base.tt2
 create mode 100644 Open-ILS/src/templates/staff/base_js.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/catalog/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/item/t_view.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2
 create mode 100644 Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/checkin/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/holds/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/holds/t_pull.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/in_house_use/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/pending.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/register.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_group.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_holds.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_messages.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_notes.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_search.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_summary.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/renew/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/circ_strings.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/hold_strings.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/config.tt2
 create mode 100644 Open-ILS/src/templates/staff/css/circ.css.tt2
 create mode 100644 Open-ILS/src/templates/staff/css/print.css.tt2
 create mode 100644 Open-ILS/src/templates/staff/css/style.css.tt2
 create mode 100644 Open-ILS/src/templates/staff/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/navbar.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/README
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_alert_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_autogrid.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_eframe.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/statusbar.tt2
 create mode 100644 Open-ILS/src/templates/staff/t_login.tt2
 create mode 100644 Open-ILS/src/templates/staff/t_splash.tt2
 create mode 100644 Open-ILS/web/js/ui/default/staff/Gruntfile.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/README.install
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/user_perms.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/bower.json
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/item/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/cat/services/record.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/holds/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/holds.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/pending.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/patron/register.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/renew/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/services/billing.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/services/circ.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/circ/services/holds.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/package.json
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/auth.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/core.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/coresvc.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/eframe.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/env.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/event.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/file.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/grid.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/hatch.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/idl.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/navbar.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/net.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/org.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/pcrud.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/print.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/startup.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/statusbar.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/strings.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/ui.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/user.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/karma.conf.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egCore.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js
 create mode 100644 Open-ILS/xul/staff_client/server/patron/user_edit.xhtml
 create mode 100644 Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list