Angular web staff - initial import
authorBill Erickson <berick@esilibrary.com>
Thu, 24 Oct 2013 18:39:33 +0000 (14:39 -0400)
committerBill Erickson <berick@esilibrary.com>
Mon, 25 Nov 2013 14:05:05 +0000 (09:05 -0500)
Batch import of staff client prototype work in progress.

Signed-off-by: Bill Erickson <berick@esilibrary.com>
59 files changed:
Open-ILS/examples/apache/eg_vhost.conf.in
Open-ILS/examples/apache_24/eg_vhost.conf.in
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/templates/staff/README [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bills.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_messages.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_summary.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/parts/column_picker.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_base.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_base_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_login.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_navbar.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_splash.tt2 [new file with mode: 0644]
Open-ILS/web/css/skin/default/staff/base.css [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/navbar.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/auth.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/core.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/env.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/event.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/flattener.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/idl.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/list.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/net.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/org.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/pcrud.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/startup.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/ui.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/user.js [new file with mode: 0644]
web-staff-log.txt [new file with mode: 0644]

index 1de2212..45487ec 100644 (file)
@@ -787,7 +787,68 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+# TODO: as is, each sub-app will require a new Location entry, which
+# will quickly grow large (and it's unnecessary and annoying). we need a
+# better solution.
+<Location /eg/staff/>
+    Options -MultiViews
+    # any reuest that does not map to a template file
+    # is redirected to the index.  This allows us to 
+    # map multiple routes to the same application.
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/index
+    RewriteCond %{PATH_INFO} !/staff/t_*
+    RewriteRule .* /eg/staff/index [L,DPI]
+
+    # is this redundant?
+    <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>
+<Location /eg/staff/cat/bucket/record/>
+    Options -MultiViews
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/index
+    RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/t_*
+    RewriteRule .* /eg/staff/cat/bucket/record/index [L,DPI]
+</Location>
+<Location /eg/staff/circ/patron/>
+    Options -MultiViews
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/index
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/t_*
+    RewriteRule .* /eg/staff/circ/patron/index [L,DPI]
+</Location>
+
+<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
 #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [NE,R,L]
+
+
index f530f29..3e07b09 100644 (file)
@@ -799,6 +799,66 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT]
     </IfModule>
 </Location>
 
+# TODO: as is, each sub-app will require a new Location entry, which
+# will quickly grow large (and it's unnecessary and annoying). we need a
+# better solution.
+<Location /eg/staff/>
+    Options -MultiViews
+    # any reuest that does not map to a template file
+    # is redirected to the index.  This allows us to 
+    # map multiple routes to the same application.
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/index
+    RewriteCond %{PATH_INFO} !/staff/t_*
+    RewriteRule .* /eg/staff/index [L,DPI]
+
+    # is this redundant?
+    <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>
+<Location /eg/staff/cat/bucket/record/>
+    Options -MultiViews
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/index
+    RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/t_*
+    RewriteRule .* /eg/staff/cat/bucket/record/index [L,DPI]
+</Location>
+<Location /eg/staff/circ/patron/>
+    Options -MultiViews
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/index
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/t_*
+    RewriteRule .* /eg/staff/circ/patron/index [L,DPI]
+</Location>
+
+<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
index 66f365d..1c9c72e 100644 (file)
@@ -1294,7 +1294,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"/>
@@ -1307,6 +1307,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">
@@ -5918,7 +5923,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"/>
@@ -5929,6 +5934,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">
diff --git a/Open-ILS/src/templates/staff/README b/Open-ILS/src/templates/staff/README
new file mode 100644 (file)
index 0000000..9206301
--- /dev/null
@@ -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/cat/bucket/record/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2
new file mode 100644 (file)
index 0000000..27f4c80
--- /dev/null
@@ -0,0 +1,50 @@
+[%
+  WRAPPER "staff/t_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/list.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.searchList.totalCount}})</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.count()}})</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.viewList.totalCount}})</span>
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+    <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 (file)
index 0000000..517c35f
--- /dev/null
@@ -0,0 +1,37 @@
+<!-- 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 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('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 -->
+  </div> <!-- modal-dialog -->
+</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 (file)
index 0000000..0ca9887
--- /dev/null
@@ -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 (file)
index 0000000..f0acbf4
--- /dev/null
@@ -0,0 +1,36 @@
+<!-- edit bucket dialog -->
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <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('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 -->
+  </div> <!-- modal-dialog -->
+</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 (file)
index 0000000..9a36d09
--- /dev/null
@@ -0,0 +1,43 @@
+<!-- export bucket dialog -->
+<form ng-submit="ok(args)">
+  <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('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 -->
+  </div> <!-- modal-dialog -->
+</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 (file)
index 0000000..1adee8c
--- /dev/null
@@ -0,0 +1,16 @@
+
+<div ng-show="bucket()">
+  <strong>[% l('Bucket {{bucket().name()}}') %]</strong> 
+  <span>
+    <ng-pluralize count="bucketSvc.viewList.totalCount"
+      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 (file)
index 0000000..1e34f6a
--- /dev/null
@@ -0,0 +1,27 @@
+<div class="btn-group text-left">
+  <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+    [% 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_load_shared.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2
new file mode 100644 (file)
index 0000000..a547cd9
--- /dev/null
@@ -0,0 +1,27 @@
+<!-- load bucket by id ("shared") -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <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('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 -->
+  </div> <!-- modal-dialog -->
+</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 (file)
index 0000000..6fabd1c
--- /dev/null
@@ -0,0 +1,127 @@
+<br/>
+
+<style>
+    /* TODO: MOVE ME */
+    tr.selected > td {
+        color: #2a6496;
+        background-color: #F5F5F5;
+    }
+</style>
+
+<div class="row">
+  <div class="col-lg-6">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %]
+  </div>
+
+  <div class="col-lg-6 text-right">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %]
+
+    <div class="btn-group text-left">
+      <!-- first page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}" 
+        ng-click="pageList.offset = 0;draw()">[% l('Start') %]</button>
+
+      <!-- previous page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}"
+        ng-click="pageList.decrementPage();draw()">&laquo;</button>
+
+      <!-- next page -->
+      <!-- todo: paging needs a total count value to be fully functional -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : !pageList.hasNextPage()}"
+        ng-click="pageList.incrementPage();draw()">&raquo;</button>
+
+      <div class="btn-group">
+        <button type="button" class="btn btn-default dropdown-toggle" 
+            ng-class="{disabled : action_pending}" data-toggle="dropdown">
+          [% l('Actions') %] <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+
+          <li ng-class="{disabled : !bucket() || !pageList.selectedItems().length}">
+            <a href="javascript:;" ng-click="addToBucket()">[% l('Add Selected To Bucket') %]</a>
+          </li>
+
+          <li ng-class="{disabled : !bucket() || !pageList.count()}">
+            <a href="javascript:;" ng-click="addToBucket(true)">[% l('Add All To Bucket') %]</a>
+          </li>
+
+          <li ng-class="{disabled : !pageList.count()}">
+            <a href="javascript:;" ng-click="pageList.reset()">[% l('Clear List') %]</a>
+          </li>
+        </ul>
+      </div>
+
+      <div class="btn-group col-picker">
+        <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+            <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+          <li ng-repeat="col in pageList.allColumns">
+            <a href='javascript:;' 
+              ng-click="pageList.displayColumns[col.name] = 
+                !pageList.displayColumns[col.name]">
+                <span ng-if="pageList.displayColumns[col.name]" class="label label-success">&#x2713;</span>
+                <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">&#x2717;</span>
+                <span>{{col.label}}</span>
+            </a>
+          </li>
+          <li role="presentation" class="divider"></li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.showAllColumns()">[% l('Show All Columns') %]</a>
+          </li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.hideAllColumns()">[% l('Hide All Columns') %]</a>
+          </li>
+          <li class='disabled'>
+            <a href='javascript:;' ng-click="">[% l('Save Columns') %]</a>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</div>
+
+<br/>
+
+<div class="row">
+  <div class="col-lg-12">
+
+    <table class="table table-hover _table-striped table-condensed"
+      ng-show="pageList.count()"
+      ng-init="pageList.defaultColumns([
+        'id', 'author', 'isbn', 'issn', 'pubdate', 
+        'publisher', 'tcn_value', 'title'
+      ])">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th>
+            <a href='javascript:;' 
+              ng-click="pageList.toggleSelectAll()">&#x2713;</a>
+          </th>
+          <th ng-repeat="field in pageList.allColumns" 
+            ng-show="pageList.displayColumns[field.name]">
+            <a href='javascript:;' 
+              ng-click="sort(field.name);draw()">{{field.label}}</a>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="rec in pageList.items"
+            ng-class="{selected : pageList.selected[rec.id]}"
+            ng-click="applyRowSelection($event, rec.id)">
+          <td>{{$index + 1 + pageList.offset}}</td>
+          <td><span ng-if="pageList.selected[rec.id]">&#x2713;</span>
+          </td>
+          <td ng-repeat="field in pageList.allColumns"
+            ng-show="pageList.displayColumns[field.name]">
+            {{rec[field.name]}}
+        </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
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 (file)
index 0000000..05319d7
--- /dev/null
@@ -0,0 +1,156 @@
+
+<br/>
+
+<style>
+    /* TODO: MOVE ME */
+    tr.selected > td {
+        color: #2a6496;
+        background-color: #F5F5F5;
+    }
+</style>
+
+<div class="row">
+  <div class="col-lg-6">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %]
+  </div>
+
+  <div class="col-lg-6 text-right">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %]
+
+    <div class="btn-group text-left">
+      <!-- first page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}" 
+        ng-click="pageList.offset = 0;draw()">[% l('Start') %]</button>
+
+      <!-- previous page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}"
+        ng-click="pageList.decrementPage();draw()">&laquo;</button>
+
+      <!-- next page -->
+      <!-- todo: paging needs a total count value to be fully functional -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : !pageList.hasNextPage()}"
+        ng-click="pageList.incrementPage();draw()">&raquo;</button>
+
+      <div class="btn-group">
+        <button type="button" class="btn btn-default dropdown-toggle" 
+            ng-class="{disabled : action_pending}" data-toggle="dropdown">
+          [% l('Actions') %] <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+
+          <li ng-class="{disabled : !pageList.selectedItems().length}">
+            <a href="javascript:;" ng-click="addToPending()">
+              [% l('Add Selected To Pending List') %]</a></li>
+
+          <li ng-class="{disabled : !pageList.count()}">
+            <a href="javascript:;" ng-click="addToPending(true)">
+              [% l('Add All To Pending List') %]</a></li>
+
+          <li ng-class="{disabled : !bucket() || !pageList.selectedItems().length}">
+            <a href="javascript:;" ng-click="addToBucket()">
+                [% l('Add Selected To Bucket') %]</a></li>
+
+          <li ng-class="{disabled : !bucket() || !pageList.count()}">
+            <a href="javascript:;" ng-click="addToBucket(true)">
+              [% l('Add All To Bucket') %]</a></li>
+        </ul>
+      </div>
+
+      <div class="btn-group col-picker">
+        <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+            <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+          <li ng-repeat="col in pageList.allColumns">
+            <a href='javascript:;' 
+              ng-click="pageList.displayColumns[col.name] = 
+                !pageList.displayColumns[col.name]">
+                <span ng-if="pageList.displayColumns[col.name]" class="label label-success">&#x2713;</span>
+                <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">&#x2717;</span>
+                <span>{{col.label}}</span>
+            </a>
+          </li>
+          <li role="presentation" class="divider"></li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.showAllColumns()">[% l('Show All Columns') %]</a>
+          </li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.hideAllColumns()">[% l('Hide All Columns') %]</a>
+          </li>
+          <li class='disabled'>
+            <a href='javascript:;' ng-click="">[% l('Save Columns') %]</a>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</div>
+
+<br/>
+<div class="row">
+  <div class="col-lg-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-lg-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>
+
+
+<div class="row">
+  <div class="col-lg-12">
+
+    <table class="table table-hover _table-striped table-condensed"
+      ng-show="pageList.count()"
+      ng-init="pageList.defaultColumns([
+        'id', 'author', 'isbn', 'issn', 'pubdate', 
+        'publisher', 'tcn_value', 'title'
+      ])">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th>
+            <a href='javascript:;' 
+              ng-click="pageList.toggleSelectAll()">&#x2713;</a>
+          </th>
+          <th ng-repeat="field in pageList.allColumns" 
+            ng-show="pageList.displayColumns[field.name]">
+            <a href='javascript:;' 
+              ng-click="sort(field.name);draw()">{{field.label}}</a>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="rec in pageList.items"
+            ng-class="{selected : pageList.selected[rec.id]}"
+            ng-click="applyRowSelection($event, rec.id)">
+          <td>{{$index + 1 + pageList.offset}}</td>
+          <td><span ng-if="pageList.selected[rec.id]">&#x2713;</span>
+          </td>
+          <td ng-repeat="field in pageList.allColumns"
+            ng-show="pageList.displayColumns[field.name]">
+            {{rec[field.name]}}
+        </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
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 (file)
index 0000000..53306e4
--- /dev/null
@@ -0,0 +1,130 @@
+<br/>
+
+<style>
+    /* TODO: MOVE ME */
+    tr.selected > td {
+        color: #2a6496;
+        background-color: #F5F5F5;
+    }
+</style>
+
+<div class="row">
+  <div class="col-lg-6">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %]
+  </div>
+
+  <div class="col-lg-6 text-right">
+    [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %]
+
+    <!-- TODO: paging and actions drop-downs can be extracted out and shared -->
+    <div class="btn-group text-left">
+
+      <!-- first page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}" 
+        ng-click="pageList.offset = 0;draw()">[% l('Start') %]</button>
+
+      <!-- previous page -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : pageList.onFirstPage()}"
+        ng-click="pageList.decrementPage();draw()">&laquo;</button>
+
+      <!-- next page -->
+      <!-- todo: paging needs a total count value to be fully functional -->
+      <button type="button" class="btn btn-default" 
+        ng-class="{disabled : !pageList.hasNextPage()}"
+        ng-click="pageList.incrementPage();draw()">&raquo;</button>
+
+      <div class="btn-group">
+        <button type="button" class="btn btn-default dropdown-toggle" 
+            ng-class="{disabled : action_pending}" data-toggle="dropdown">
+          [% l('Actions') %] <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+          <li ng-class="{disabled : !bucket()}">
+            <a href="javascript:;" ng-click="detachRecords()">
+              [% l('Remove Selected Records') %]</a>
+          </li>
+          <li ng-class="{disabled : !bucket()}">
+            <a href='' ng-click="openExportBucketDialog()">
+                [% l('Export Bucket Records') %]
+            </a>
+          </li>
+        </ul>
+      </div>
+
+      <div class="btn-group col-picker">
+        <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+            <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu pull-right">
+          <li ng-repeat="col in pageList.allColumns">
+            <a href='javascript:;' 
+              ng-click="pageList.displayColumns[col.name] = 
+                !pageList.displayColumns[col.name]">
+                <span ng-if="pageList.displayColumns[col.name]" class="label label-success">&#x2713;</span>
+                <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">&#x2717;</span>
+                <span>{{col.label}}</span>
+            </a>
+          </li>
+          <li role="presentation" class="divider"></li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.showAllColumns()">[% l('Show All Columns') %]</a>
+          </li>
+          <li>
+            <a href='javascript:;' ng-click="pageList.hideAllColumns()">[% l('Hide All Columns') %]</a>
+          </li>
+          <li class='disabled'>
+            <a href='javascript:;' ng-click="">[% l('Save Columns') %]</a>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</div>
+
+<br/>
+<div class="row">
+
+  <div class="col-lg-10 col-lg-offset-1" ng-show="forbidden">
+    <div class="alert alert-warning">
+      [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %]
+    </div>
+  </div>
+
+  <div class="col-lg-12">
+    <table class="table table-hover table-condensed" ng-show="pageList.count()"
+      ng-init="pageList.defaultColumns([
+        'id', 'author', 'isbn', 'issn', 'pubdate', 
+        'publisher', 'tcn_value', 'title'
+      ])">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th>
+            <a href='javascript:;' 
+              ng-click="pageList.toggleSelectAll()">&#x2713;</a>
+          </th>
+          <th ng-repeat="field in pageList.allColumns" 
+            ng-show="pageList.displayColumns[field.name]">
+            <a href='javascript:;' 
+              ng-click="sort(field.name);draw()">{{field.label}}</a>
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="rec in pageList.items"
+            ng-class="{selected : pageList.selected[rec.item_id]}"
+            ng-click="applyRowSelection($event, rec.item_id)">
+          <td>{{$index + 1 + pageList.offset}}</td>
+          <td><span ng-if="pageList.selected[rec.item_id]">&#x2713;</span>
+          </td>
+          <td ng-repeat="field in pageList.allColumns"
+            ng-show="pageList.displayColumns[field.name]">
+            {{rec[field.name]}}
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
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 (file)
index 0000000..1130edf
--- /dev/null
@@ -0,0 +1,51 @@
+[%
+  WRAPPER "staff/t_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/list.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/app.js"></script>
+[% END %]
+
+<div class="row">
+  <div class="col-lg-3">
+    [% INCLUDE 'staff/circ/patron/t_summary.tt2' %]
+  </div>
+  <div class="col-lg-9">
+    <ul class="nav nav-tabs">
+      <li ng-class="{active : tab == 'checkout', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/checkout">[% l('Checkout') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'items_out', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/items_out">[% l('Items Out') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'holds', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/holds">[% l('Holds') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'bills', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/bills">[% l('Bills') %]</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 ng-class="{active : tab == 'search'}" class="pull-right">
+        <a href="./circ/patron/search">[% l('Search') %]</a>
+      </li>
+    </ul>
+    <div class="tab-content">
+      <div class="tab-pane active">
+        <div ng-view></div>
+      </div>
+    </div>
+  </div>
+</div>
+
+[% END %]
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 (file)
index 0000000..0fff7f3
--- /dev/null
@@ -0,0 +1 @@
+BILLS
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 (file)
index 0000000..4a3142d
--- /dev/null
@@ -0,0 +1,36 @@
+<style>
+    /* MOVE ME */
+    .pad-horiz {padding : 0px 10px 0px 10px; }
+    .pad-vert {padding : 20px 0px 10px 0px;}
+    #patron-checkout-barcode { width: 18em; }
+</style>
+
+
+<!-- checkout form -->
+<div class="row pad-vert">
+  <div class="col-lg-10">
+    <div class="pad-horiz">
+      <form ng-submit="checkout(checkoutArgs)">
+        <select ng-model="checkoutArgs.type">
+          <option value='barcode'>[% l('Barcode') %]</option>
+          <option value=''>----</option>
+          <option ng-repeat='type in nonCatTypes' 
+            value='{{type.id()}}'>{{type.name()}}</option>
+        </select>
+        <span class="pad-horiz"></span>
+        <input focus-me="focusMe" ng-model="checkoutArgs.copy_barcode" 
+          ng-disabled="checkoutArgs.type != 'barcode'"
+          id="patron-checkout-barcode" type="text"/> 
+        <span class="pad-horiz"></span>
+        <input type="submit" value="[% l('Submit') %]"/>
+      </form>
+    </div>
+  </div>
+  <div class="col-lg-2 text-right">
+    <div class="btn-group text-left">
+      [% INCLUDE 'staff/parts/column_picker.tt2' listname='checkouts' %]
+    </div>
+  </div>
+</div>
+
+[% INCLUDE 'staff/circ/patron/t_checkout_table.tt2' %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2
new file mode 100644 (file)
index 0000000..13c8480
--- /dev/null
@@ -0,0 +1,50 @@
+
+[%
+# checkout table columns
+COLUMNS = [
+{label => l('Barcode'),    name => 'copy_barcode' display => 1},
+{label => l('Circ ID'),    name => 'payload.circ.id', display => 1},
+{label => l('Due Date'),   name => 'payload.circ.due_date' display => 1},
+# once we are handling all response types, we probably don't need to show 
+# Response.  Or, at least, make it more friendly / localizable
+{label => l('Response'),   name => 'textcode', display => 1},
+{label => l('Title'),      name => 'payload.record.title', display => 1},
+{label => l('Author'),     name => 'payload.record.author', display => 1},
+{label => l('Call Number'),name => 'payload.copy.call_number.label', display => 1},
+{label => l('Alert Msg'),  name => 'payload.copy.alert_message' display => 1},
+]
+%]
+
+<!-- tell JS about our columns so they can be dynamically managed -->
+<div ng-init="
+checkouts.setColumns([
+[%- FOR col IN COLUMNS %]
+{label:'[% col.label %]',name:'[% col.name %]'[% IF col.display %],display:true[% END %]}[% IF !loop.last; ','; END -%]
+[% END %]
+])">
+</div>
+
+<div class="row">
+  <div class="col-lg-12">
+    <table class="table table-hover table-condensed table-striped">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th ng-repeat="col in checkouts.allColumns" 
+            ng-show="checkouts.displayColumns[col.name]">
+            {{col.label}}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="checkout in checkouts.items | reverse track by $index">
+          <td>{{checkouts.count() - $index}}</td>
+          <td ng-repeat="col in checkouts.allColumns" 
+            ng-show="checkouts.displayColumns[col.name]">
+            {{checkouts.fieldValue(checkout, col.name)}}
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</div>
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 (file)
index 0000000..00daeb8
--- /dev/null
@@ -0,0 +1 @@
+EDIT
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2
new file mode 100644 (file)
index 0000000..c2f2569
--- /dev/null
@@ -0,0 +1,29 @@
+<form class="form-validated" novalidate ng-submit="ok()" name="form">
+  <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('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">{{evt.desc}}</div>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <i>[% |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 Checkout?') %]"/>
+        <button class="btn btn-warning" 
+            ng-click="cancel()">[% l('Cancel') %]</button>
+      </div>
+    </div> <!-- modal-content -->
+  </div> <!-- modal-dialog -->
+</form>
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 (file)
index 0000000..61c85b3
--- /dev/null
@@ -0,0 +1,16 @@
+<style>
+    /* MOVE ME */
+    .pad-horiz {padding : 0px 10px 0px 10px; }
+    .pad-vert {padding : 20px 0px 10px 0px;}
+    #patron-checkout-barcode { width: 18em; }
+</style>
+
+
+<!-- checkout form -->
+<div class="row pad-vert">
+  <div class="col-lg-12 text-right">
+    [% INCLUDE 'staff/circ/patron/t_holds_actions.tt2' %]
+  </div>
+</div>
+
+[% INCLUDE 'staff/circ/patron/t_holds_table.tt2' %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2
new file mode 100644 (file)
index 0000000..5d43ea8
--- /dev/null
@@ -0,0 +1,34 @@
+<div class="btn-group text-left">
+
+  <!-- PAGING -->
+
+  <!--
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : holds.onFirstPage()}" 
+    ng-click="holds.offset = 0;draw()">[% l('Start') %]</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : holds.onFirstPage()}"
+    ng-click="holds.decrementPage();draw()">&laquo;</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : !holds.hasNextPage()}"
+    ng-click="holds.incrementPage();draw()">&raquo;</button>
+  -->
+
+  <div class="btn-group">
+    <button type="button" class="btn btn-default dropdown-toggle" 
+        ng-class="{disabled : !holds.count()}" data-toggle="dropdown">
+      [% l('Actions') %] <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu pull-right">
+      <li class="disabled" xxxng-class="{disabled : !holds.count()}">
+        <a href="" ng-click="">
+          [% l('Cancel Hold(s)') %]</a>
+      </li>
+    </ul>
+  </div>
+
+  [% INCLUDE 'staff/parts/column_picker.tt2' listname='holds' %]
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2
new file mode 100644 (file)
index 0000000..749afe8
--- /dev/null
@@ -0,0 +1,59 @@
+<style>
+  /* TODO: MOVE ME */
+  tr.selected > td {
+    color: #2a6496;
+    background-color: #F5F5F5;
+  }
+</style>
+
+[%
+COLUMNS = [
+{label => l('Hold ID'), name => 'hold.id', display => 1},
+{label => l('Current Copy'), name => 'hold.current_copy.barcode' display => 1},
+{label => l('Request Date'), name => 'hold.request_time' display => 1},
+{label => l('Capture Date'), name => 'hold.capture_time' display => 1},
+{label => l('Available Date'), name => 'hold.shelf_time' display => 1},
+{label => l('Type'), name => 'hold.hold_type' display => 1},
+{label => l('Pickup Library'), name => 'hold.pickup_lib.shortname' display => 1},
+{label => l('Title'), name => 'mvr.title', display => 1},
+{label => l('Author'), name => 'mvr.author', display => 1}
+]
+%]
+
+<!-- tell JS about our columns so they can be dynamically managed -->
+<div ng-init="
+holds.setColumns([
+[%- FOR col IN COLUMNS %]
+{label:'[% col.label %]',name:'[% col.name %]'[% IF col.display %],display:true[% END %]}[% IF !loop.last; ','; END -%]
+[% END %]
+])">
+</div>
+
+<div class="row">
+  <div class="col-lg-12">
+    <table class="table table-hover table-condensed">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th><a href='' ng-click="holds.toggleSelectAll()">&#x2713;</a></th>
+          <th ng-repeat="col in holds.allColumns" 
+            ng-show="holds.displayColumns[col.name]">
+            {{col.label}}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="hold_data in holds.items | reverse track by $index"
+          ng-click="onRowClick($event, hold_data)"
+          ng-class="{selected : holds.selected[hold_data.id]}">
+          <td>{{$index + 1}}</td>
+          <td><span ng-if="holds.selected[hold_data.id]">&#x2713;</span> 
+          <td ng-repeat="col in holds.allColumns" 
+            ng-show="holds.displayColumns[col.name]">
+            {{holds.fieldValue(hold_data, col.name)}}
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </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 (file)
index 0000000..395f7ab
--- /dev/null
@@ -0,0 +1,16 @@
+<style>
+    /* MOVE ME */
+    .pad-horiz {padding : 0px 10px 0px 10px; }
+    .pad-vert {padding : 20px 0px 10px 0px;}
+    #patron-checkout-barcode { width: 18em; }
+</style>
+
+
+<!-- checkout form -->
+<div class="row pad-vert">
+  <div class="col-lg-12 text-right">
+    [% INCLUDE 'staff/circ/patron/t_items_out_actions.tt2' %]
+  </div>
+</div>
+
+[% INCLUDE 'staff/circ/patron/t_items_out_table.tt2' %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2
new file mode 100644 (file)
index 0000000..c43fd9a
--- /dev/null
@@ -0,0 +1,34 @@
+<div class="btn-group text-left">
+
+  <!-- PAGING -->
+
+  <!--
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : items_out.onFirstPage()}" 
+    ng-click="items_out.offset = 0;draw()">[% l('Start') %]</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : items_out.onFirstPage()}"
+    ng-click="items_out.decrementPage();draw()">&laquo;</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : !items_out.hasNextPage()}"
+    ng-click="items_out.incrementPage();draw()">&raquo;</button>
+  -->
+
+  <div class="btn-group">
+    <button type="button" class="btn btn-default dropdown-toggle" 
+        ng-class="{disabled : !items_out.count()}" data-toggle="dropdown">
+      [% l('Actions') %] <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu pull-right">
+      <li class="disabled" xxxng-class="{disabled : !items_out.count()}">
+        <a href="" ng-click="openSelecteditems_out()">
+          [% l('Renew Item(s)') %]</a>
+      </li>
+    </ul>
+  </div>
+
+  [% INCLUDE 'staff/parts/column_picker.tt2' listname='items_out' %]
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2
new file mode 100644 (file)
index 0000000..218549c
--- /dev/null
@@ -0,0 +1,57 @@
+<style>
+  /* TODO: MOVE ME */
+  tr.selected > td {
+    color: #2a6496;
+    background-color: #F5F5F5;
+  }
+</style>
+
+[%
+COLUMNS = [
+{label => l('Circ ID'),    name => 'id', display => 1},
+{label => l('Barcode'),    name => 'target_copy.barcode' display => 1},
+{label => l('Due Date'),   name => 'due_date' display => 1},
+{label => l('Checkout/Renewal Library'), name => 'circ_lib.shortname' display => 1},
+{label => l('Renewals Remaining'),   name => 'renewal_remaining' display => 1},
+{label => l('Fines Stopped'), name => 'stop_fines' display => 1},
+{label => l('Title'),      name => 'target_copy.call_number.record.simple_record.title', display => 1},
+]
+%]
+
+<!-- tell JS about our columns so they can be dynamically managed -->
+<div ng-init="
+items_out.setColumns([
+[%- FOR col IN COLUMNS %]
+{label:'[% col.label %]',name:'[% col.name %]'[% IF col.display %],display:true[% END %]}[% IF !loop.last; ','; END -%]
+[% END %]
+])">
+</div>
+
+<div class="row">
+  <div class="col-lg-12">
+    <table class="table table-hover table-condensed">
+      <thead>
+        <tr>
+          <th>#</th>
+          <th><a href='' ng-click="items_out.toggleSelectAll()">&#x2713;</a></th>
+          <th ng-repeat="col in items_out.allColumns" 
+            ng-show="items_out.displayColumns[col.name]">
+            {{col.label}}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr ng-repeat="circ in items_out.items | reverse track by $index"
+          ng-click="onRowClick($event, circ)"
+          ng-class="{selected : items_out.selected[circ.id()]}">
+          <td>{{$index + 1}}</td>
+          <td><span ng-if="items_out.selected[circ.id()]">&#x2713;</span> 
+          <td ng-repeat="col in items_out.allColumns" 
+            ng-show="items_out.displayColumns[col.name]">
+            {{items_out.fieldValue(circ, col.name)}}
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+</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 (file)
index 0000000..b0632b0
--- /dev/null
@@ -0,0 +1 @@
+MESSAGES
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2
new file mode 100644 (file)
index 0000000..793f974
--- /dev/null
@@ -0,0 +1,45 @@
+<!-- edit bucket dialog -->
+<form class="form-validated" novalidate ng-submit="ok(precatArgs)" name="form">
+  <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('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" 
+            ng-disabled="form.$invalid" 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/patron/t_search.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
new file mode 100644 (file)
index 0000000..083d2f9
--- /dev/null
@@ -0,0 +1,40 @@
+
+<br/><br/> <!-- css -->
+<div class="row">
+  <div class="col-lg-10">
+    <form ng-submit="search(args)">
+      <input type="text" ng-model="args.card" 
+        placeholder="[% l('Barcode') %]" focus-me="focusMe"/>
+      <input type="text" ng-model="args.family_name" 
+        placeholder="[% l('Last Name') %]"/>
+      <input type="text" ng-model="args.first_given_name" 
+        placeholder="[% l('First Name') %]"/>
+      <input type="submit" value="[% l('Search') %]"/>
+      <input type="reset" value="[% l('Clear Form') %]"/>
+      <!-- " trick vim -->
+    </form>
+  </div>
+  <div class="col-lg-2 text-right">
+    [% INCLUDE 'staff/circ/patron/t_search_actions.tt2' %]
+  </div>
+</div>
+
+
+<br/>
+<div class="row">
+  <div class="col-lg-12">
+    <div class="alert alert-info alert-dismissable" 
+      ng-hide="tips.dismissed('circ.patron.search')">
+      <button type="button" class="close" data-dismiss="alert" aria-hidden="true" 
+        ng-click="tips.dismiss('circ.patron.search')">&times;</button>
+      <ol>
+        <li>Click a row to select a patron.  This will activate action tabs for the patron.</li>
+        <li>Double-Click a row to focus the checkout tab for a patron.</li>
+        <li>Middle-click to open a patron in a new browser tab.</li>
+      </ol>
+    </div> 
+    [% INCLUDE 'staff/circ/patron/t_search_results.tt2' %]
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2
new file mode 100644 (file)
index 0000000..3cf7724
--- /dev/null
@@ -0,0 +1,40 @@
+<div class="btn-group text-left">
+
+  <!-- 
+    patron search API call does not support paging.  For now, hide paging
+    options and show all retrieved users with a limit (like the xul client).
+  <!--
+
+  <!--
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : patrons.onFirstPage()}" 
+    ng-click="patrons.offset = 0;draw()">[% l('Start') %]</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : patrons.onFirstPage()}"
+    ng-click="patrons.decrementPage();draw()">&laquo;</button>
+
+  <button type="button" class="btn btn-default" 
+    ng-class="{disabled : !patrons.hasNextPage()}"
+    ng-click="patrons.incrementPage();draw()">&raquo;</button>
+  -->
+
+  <!-- no batch actions are currently supported -->
+  <!--
+  <div class="btn-group">
+    <button type="button" class="btn btn-default dropdown-toggle" 
+        ng-class="{disabled : !patrons.count()}" data-toggle="dropdown">
+      [% l('Actions') %] <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu pull-right">
+      <li ng-class="{disabled : !patrons.count()}">
+        <a href="" ng-click="openSelectedPatrons()">
+          [% l('Open Selected in New Tab') %]</a>
+      </li>
+    </ul>
+  </div>
+  -->
+
+  [% INCLUDE 'staff/parts/column_picker.tt2' listname='patrons' %]
+</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 (file)
index 0000000..2d9b2e1
--- /dev/null
@@ -0,0 +1,84 @@
+<style>
+  /* TODO: MOVE ME */
+  tr.selected > td {
+    color: #2a6496;
+    background-color: #F5F5F5;
+  }
+</style>
+
+[% 
+# Default / available display columns
+# Since there will be demand for configurable columns in this UI,
+# experiment with automagic column creation. 
+#
+# We could autogenerate much of this from the IDL. However, since there
+# are special cases to handle (e.g. billing vs mailing address) and
+# because table autogeneration will likely evolve over time, go ahead
+# and list the columns explicitly for now.
+#
+# the 'name' field doubles as the path to the value and as a unique
+# key for the column picker
+COLUMNS = [
+
+{label => l('ID'),          name => 'id',               display => 1},
+{label => l('Card'),        name => 'card.barcode',     display => 1},
+{label => l('Last Name'),   name => 'family_name',      display => 1},
+{label => l('First Name'),  name => 'first_given_name', display => 1},
+{label => l('Middle Name'), name => 'second_given_name',display => 1},
+{label => l('DoB'),         name => 'dob',              display => 1},
+{label => l('Created On'),  name => 'create_date',      display => 1},
+
+{label => l('Mailing:Street 1'), name => 'mailing_address.street1', display => 1},
+{label => l('Mailing:Street 2'), name => 'mailing_address.street2'},
+{label => l('Mailing:City'),     name => 'mailing_address.city'},
+{label => l('Mailing:County'),   name => 'mailing_address.county'},
+{label => l('Mailing:State'),    name => 'mailing_address.state'},
+{label => l('Mailing:Zip'),      name => 'mailing_address.post_code'},
+
+{label => l('Billing:Street 1'), name => 'billing_address.street1'},
+{label => l('Billing:Street 2'), name => 'billing_address.street2'},
+{label => l('Billing:City'),     name => 'billing_address.city'},
+{label => l('Billing:County'),   name => 'billing_address.county'},
+{label => l('Billing:State'),    name => 'billing_address.state'},
+{label => l('Billing:Zip'),      name => 'billing_address.post_code'}
+
+]
+%]
+
+<!-- tell JS about our columns so they can be dynamically managed -->
+<div ng-init="
+patrons.setColumns([
+[%- FOR col IN COLUMNS %]
+{label:'[% col.label %]',name:'[% col.name %]'[% IF col.display %],display:true[% END %]}[% IF !loop.last; ','; END -%]
+[% END %]
+])">
+</div>
+
+<table class="table table-hover table-condensed">
+  <thead>
+    <tr>
+      <th>#</th>
+      <th><a href='' ng-click="patrons.toggleSelectAll()">&#x2713;</a></th>
+      <th ng-repeat="col in patrons.allColumns" 
+        ng-show="patrons.displayColumns[col.name]">
+        {{col.label}}
+      </th>
+    </tr>
+  </thead>
+  <!-- giving tbody a tabindex allows the keyup event to fire.
+    requires outline:none to prevent selected element CSS bordering -->
+  <tbody _tabindex="-1" _style="outline:none" _ng-keyup="navigateResults($event)">
+    <tr ng-repeat="user in patrons.items track by $index"
+        ng-click="onPatronClick($event, user)"
+        ng-dblclick="onPatronDblClick($event, user)"
+        ng-class="{selected : patrons.selected[user.id()]}">
+      <td>{{$index + 1}}</td>
+      <td><span ng-if="patrons.selected[user.id()]">&#x2713;</span> 
+      <td ng-repeat="col in patrons.allColumns" 
+        ng-show="patrons.displayColumns[col.name]">
+        {{patrons.fieldValue(user, col.name)}}
+      </td>
+    </tr>
+  </tbody>
+</table>
+
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 (file)
index 0000000..9edd66a
--- /dev/null
@@ -0,0 +1,150 @@
+
+<style>
+  /* TODO: move me */
+  /** style to make a grid look like a striped table */
+  #patron-summary-grid div.row {padding: 3px;}
+  #patron-summary-grid div.row:nth-child(odd) {background-color: rgb(249, 249, 249);}
+  /* there are bootstrap tyles for error, warning, etc., 
+    but the ones I'm finding aren't quite cutting it..*/
+  .summary-alert {color: red; font-weight:bold}
+  .summary-divider { border-top: 1px solid #DDD}
+</style>
+
+<div ng-cloak ng-controller="PatronSummaryCtrl">
+  <div class="row" ng-show="!patron()">
+    <div class="col-lg-12">
+      <i>[% l('Patron Summary') %]</i>
+    </div>
+  </div>
+  <div ng-show="patron()" id="patron-summary-grid">
+    <div class="row">
+      <div class="col-lg-12">
+        <h4 title="{{patron().id()}}">
+[% l('{{patron().first_given_name()}} {{patron().second_given_name()}} {{patron().family_name()}}') %]
+        </h4>
+      </div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-divider' : !$index}"
+      ng-repeat="penalty in patron().standing_penalties()">
+      <div class="col-lg-9 summary-alert"
+        title="{{penalty.standing_penalty().name()}}">
+        {{penalty.note() || penalty.standing_penalty().label()}}
+      </div>
+      <div class="col-lg-3">
+        {{penalty.set_date() | date:'shortDate'}}
+      </div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-divider' : patron().standing_penalties().length}">
+      <div class="col-lg-5">[% l('Profile') %]</div>
+      <div class="col-lg-7">{{patron().profile().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Home Library') %]</div>
+      <div class="col-lg-7">{{patron().home_ou().shortname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Net Access') %]</div>
+      <div class="col-lg-7">{{patron().net_access_level().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Last Activity') %]</div>
+      <div class="col-lg-7">{{patron().usr_activity()[0].event_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Last Updated') %]</div>
+      <div class="col-lg-7">{{patron().last_update_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Create Date') %]</div>
+      <div class="col-lg-7">{{patron().create_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Expire Date') %]</div>
+      <div class="col-lg-7">{{patron().expire_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row summary-divider" 
+      ng-class="{'summary-alert' : patron_stats().fines.balance_owed}">
+      <div class="col-lg-5">[% l('Fines Owed') %]</div>
+      <div class="col-lg-7">
+        {{patron_stats().fines.balance_owed | currency}}
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Items Out') %]</div>
+      <div class="col-lg-7">{{patron_stats().checkouts.out}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-alert' : patron_stats().checkouts.overdue}">
+      <div class="col-lg-5">[% l('Overdue') %]</div>
+      <div class="col-lg-7">{{patron_stats().checkouts.overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-alert' : patron_stats().checkouts.long_overdue}">
+      <div class="col-lg-5">[% l('Long Overdue') %]</div>
+      <div class="col-lg-7">{{patron_stats().checkouts.long_overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-alert' : patron_stats().checkouts.claims_returned}">
+      <div class="col-lg-5">[% l('Claimed Returned') %]</div>
+      <div class="col-lg-7">{{patron_stats().checkouts.claims_returned}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'summary-alert' : patron_stats().checkouts.lost}">
+      <div class="col-lg-5">[% l('Lost') %]</div>
+      <div class="col-lg-7">{{patron_stats().checkouts.lost}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Holds') %]</div>
+      <div class="col-lg-7">
+        {{patron_stats().holds.total}} / {{patron_stats().holds.ready}}
+      </div>
+    </div>
+    <div class="row summary-divider">
+      <div class="col-lg-5">[% l('Card') %]</div>
+      <div class="col-lg-7">{{patron().card().barcode()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Username') %]</div>
+      <div class="col-lg-7">{{patron().usrname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Day Phone') %]</div>
+      <div class="col-lg-7">{{patron().day_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Evening Phone') %]</div>
+      <div class="col-lg-7">{{patron().evening_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Other Phone') %]</div>
+      <div class="col-lg-7">{{patron().other_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('ID1') %]</div>
+      <div class="col-lg-7">{{patron().ident_type().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('ID2') %]</div>
+      <div class="col-lg-7">{{patron().ident_type2().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5">[% l('Email') %]</div>
+      <div class="col-lg-7">{{patron().email()}}</div>
+    </div>
+
+  </div>
+
+  <div class="row" ng-repeat="addr in patron().addresses()">
+    <div class="panel">
+      <div class="panel-body">
+        <fieldset>
+          <legend>{{addr.address_type()}}</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/index.tt2 b/Open-ILS/src/templates/staff/index.tt2
new file mode 100644 (file)
index 0000000..478ff2f
--- /dev/null
@@ -0,0 +1,17 @@
+[%
+  WRAPPER "staff/t_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/parts/column_picker.tt2 b/Open-ILS/src/templates/staff/parts/column_picker.tt2
new file mode 100644 (file)
index 0000000..c690de6
--- /dev/null
@@ -0,0 +1,43 @@
+
+[%#
+Must be wrapped in a btn-group/text-left div for correct display.
+It's not done here since the caller may wish to add other buttons/
+dropdowns, etc. to the btn-group
+
+<div class="btn-group text-left">
+  [ INCLUDE 'staff/parts/column_picker.tt2' listname=somelist ]
+<div>
+%]
+
+<div class="btn-group column-picker">
+  <button type="button" class="btn btn-default dropdown-toggle" 
+    data-toggle="dropdown"> <span class="caret"></span>
+  </button>
+  <ul class="dropdown-menu pull-right">
+    <li ng-repeat="col in [% listname %].allColumns">
+      <a href='' ng-click="[% listname %].displayColumns[col.name] = 
+          ![% listname %].displayColumns[col.name]">
+          <span ng-if="[% listname %].displayColumns[col.name]" 
+            class="label label-success">&#x2713;</span>
+          <span ng-if="![% listname %].displayColumns[col.name]" 
+            class="label label-warning">&#x2717;</span>
+          <span>{{col.label}}</span>
+      </a>
+    </li>
+    <li role="presentation" class="divider"></li>
+    <li>
+      <a href='' ng-click="[% listname %].showAllColumns()">
+        [% l('Show All Columns') %]
+      </a>
+    </li>
+    <li>
+      <a href='' ng-click="[% listname %].hideAllColumns()">
+        [% l('Hide All Columns') %]
+      </a>
+    </li>
+    <li class='disabled'>
+      <a href='' ng-click="">[% l('Save Columns') %]</a>
+    </li>
+  </ul>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/t_base.tt2 b/Open-ILS/src/templates/staff/t_base.tt2
new file mode 100644 (file)
index 0000000..325f1d9
--- /dev/null
@@ -0,0 +1,24 @@
+<!doctype html>
+<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">
+    <!-- TODO: remote hosted CSS should be hosted locally instead -->
+    <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.1/css/bootstrap.min.css" />
+    <link rel="stylesheet" href="[% ctx.media_prefix %]/css/skin/default/staff/base.css" />
+  </head>
+  <body>
+    [% INCLUDE "staff/t_navbar.tt2" %]
+    <div id="top-content-container" class="container">[% content %]</div>
+  </body>
+  [% 
+    INCLUDE "staff/t_base_js.tt2";
+
+    # App-specific JS load commands go into an APP_JS block.
+    PROCESS APP_JS;
+  %]
+</html>
diff --git a/Open-ILS/src/templates/staff/t_base_js.tt2 b/Open-ILS/src/templates/staff/t_base_js.tt2
new file mode 100644 (file)
index 0000000..2f39eca
--- /dev/null
@@ -0,0 +1,29 @@
+<!-- TODO: remotely hosted JS should be hosted locally -->
+<!-- TODO: combine and minify JS -->
+
+<!-- hosted angular libs -->
+<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-route.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-cookies.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/angular-ui-bootstrap/0.6.0/ui-bootstrap-tpls.min.js"></script>
+
+<!-- IDL / opensrf (network) -->
+<script src="/IDL2js"></script>
+<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_xhr.js"></script>
+
+<!-- angular-driven shared services -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/core.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/ui.js"></script>
+
+<!-- navbar driver -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/navbar.js"></script>
diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2
new file mode 100644 (file)
index 0000000..506e3eb
--- /dev/null
@@ -0,0 +1,52 @@
+<div class="container">
+  <div class="row">
+    <div class="col-lg-3"></div><!-- offset? -->
+      <div class="col-lg-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)">
+            <div class="form-group row">
+              <label class="col-lg-4 control-label" for="login-username">[% l('Username') %]</label>
+              <div class="col-lg-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 row">
+              <label class="col-lg-4 control-label" for="login-password">[% l('Password') %]</label>
+              <div class="col-lg-8">
+                <input type="password" id="login-password" class="form-control"
+                  placeholder="Password" ng-model="args.password"/>
+              </div>
+            </div>
+
+            <div class="form-group row">
+              <label class="col-lg-4 control-label" for="login-workstation">[% l('Workstation') %]</label>
+              <div class="col-lg-8">
+                <input type="text" id="login-workstation" class="form-control"
+                  placeHolder="Optional.  Also try ?ws=<name>"
+                  ng-model="args.workstation"/>
+              </div>
+            </div>
+
+            <div class="form-group row">
+              <div class="col-lg-12">
+                <button type="submit" class="btn">[% l('Sign in') %]</button>
+                <span ng-show="loginFailed">[% l('Login Failed') %]</span>
+              </div>
+            </div>
+          </form>
+        </fieldset>
+      </div>
+    <div class="col-lg-3"></div><!-- offset? -->
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_navbar.tt2 b/Open-ILS/src/templates/staff/t_navbar.tt2
new file mode 100644 (file)
index 0000000..c894220
--- /dev/null
@@ -0,0 +1,89 @@
+<!-- 
+  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 class="navbar navbar-default navbar-static-top" 
+    role="navigation" ng-controller="NavCtrl">
+
+  <!-- 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">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>
+        <!-- " vim -->
+
+      <!-- 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/search" target="_self">
+              <span class="glyphicon glyphicon-search"></span>
+              [% l('Patron Search') %]
+            </a>
+          </li>
+          <li class="divider"></li>
+          <li class="dropdown-header">Sub Menu Test</li>
+          <li><a href="javascript:;">Test Item</a></li>
+        </ul>
+      </li>
+
+      <!-- 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/bucket/record/view" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('Record Buckets') %]
+            </a>
+          </li>
+       </ul>
+      </li>
+    </ul>
+
+    <!-- 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>
+      <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">
+            [% l('Change Operator') %]</a></li>
+          <li><a href="./login" ng-click="logout()" 
+            target="_self">[% l('Log Out') %]</a></li>
+        </ul>
+      </li>
+    </ul>
+  </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 (file)
index 0000000..b1e3e19
--- /dev/null
@@ -0,0 +1,53 @@
+<div class="container">
+  <div class="row">
+    <div class="col-lg-12 text-center">
+      <img src="/xul/server/skin/media/images/portal/logo.png"/>
+    </div>
+  </div>
+  <br/>
+  <div class="row">
+
+    <div class="col-lg-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/search">[% l('Check Out') %]</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-lg-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-lg-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="_self" href="./">All the Things</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</div>
diff --git a/Open-ILS/web/css/skin/default/staff/base.css b/Open-ILS/web/css/skin/default/staff/base.css
new file mode 100644 (file)
index 0000000..f464560
--- /dev/null
@@ -0,0 +1,71 @@
+/* --------------------------------------------------------------------------
+ * Simple default navbar style adjustements to apply the Evergreen color.
+ * TODO: style other components to match EG color scheme
+ */
+.navbar-default {
+    background: -webkit-linear-gradient(#00593d, #007a54);
+    background-color: #007a54;
+    color: #fff;
+}
+
+.navbar-default .navbar-nav>li>a {
+    color: #fff;
+}
+
+.navbar-default .navbar-nav>li>a:hover {
+    color: #ddd;
+}
+
+.navbar-default .navbar-nav>.dropdown>a .caret {
+    border-top-color: #fff;
+    border-bottom-color: #fff;
+}
+.navbar-default .navbar-nav>.dropdown>a:hover .caret {
+    border-top-color: #ddd;
+    border-bottom-color: #ddd;
+}
+
+/* --------------------------------------------------------------------------
+ * 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;
+}
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 (file)
index 0000000..2b4fba5
--- /dev/null
@@ -0,0 +1,87 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(function($routeProvider, $locationProvider) {
+
+    /**
+     * Route resolvers allow us to run async commands
+     * before the page controller is instantiated.
+     */
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/login', {
+        templateUrl: './t_login',
+        controller: 'LoginCtrl',
+        resolve : {delay : function(egStartup, egAuth) {
+            // hack for now to kill the base ses cookie where sub-path
+            // apps were unable to remove it.  See note at the top of 
+            // services/auth.js about angular cookies and paths.
+            egAuth.logout();
+            return egStartup.go();
+        }}
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './t_splash',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+
+    // HTML5 pushstate support
+    $locationProvider.html5Mode(true);
+})
+
+/**
+ * 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', 'egAuth',
+    function($scope, $location, $window, egAuth) {
+        $scope.focusMe = true;
+
+        // for now, workstations may be passed in via URL param
+        $scope.args = {workstation : $location.search().ws};
+
+        $scope.login = function(args) {
+            args.type = 'staff';
+            $scope.loginFailed = false;
+
+            egAuth.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/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
new file mode 100644 (file)
index 0000000..1497c7f
--- /dev/null
@@ -0,0 +1,632 @@
+/**
+ * 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', 'egListMod'])
+
+.config(function($routeProvider, $locationProvider) {
+    $locationProvider.html5Mode(true);
+
+    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','egList','egNet','egAuth','egIDL','egEvent',
+function($q,  egList,  egNet,  egAuth,  egIDL,  egEvent) {
+
+    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  : egList.create(),
+        pendingList : egList.create(),
+        viewList  : egList.create({indexField : 'item_id'}),
+
+        // 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 egNet.request(
+                'open-ils.actor',
+                'open-ils.actor.container.retrieve_by_class.authoritative',
+                egAuth.token(), egAuth.user().id(), 
+                'biblio', 'staff_client'
+            ).then(function(buckets) { self.allBuckets = buckets });
+        },
+
+        createBucket : function(name, desc) {
+            var deferred = $q.defer();
+            var bucket = new egIDL.cbreb();
+            bucket.owner(egAuth.user().id());
+            bucket.name(name);
+            bucket.description(desc || '');
+            bucket.btype('staff_client');
+
+            egNet.request(
+                'open-ils.actor',
+                'open-ils.actor.container.create',
+                egAuth.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 egNet.request(
+                'open-ils.actor',
+                'open-ils.actor.container.update',
+                egAuth.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();
+
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.container.flesh.authoritative',
+            egAuth.token(), 'biblio', id
+        ).then(function(bucket) {
+            var evt = egEvent.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();
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.container.item.delete',
+            egAuth.token(), 'biblio', itemId
+        ).then(function(resp) { 
+            var evt = egEvent.parse(resp);
+            if (evt) {
+                console.error(evt);
+                deferred.reject(evt);
+                return;
+            }
+            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();
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.container.full_delete',
+            egAuth.token(), 'biblio', id
+        ).then(function(resp) {
+            var evt = egEvent.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','egAuth','bucketSvc','egNet','egIDL',
+function($scope,  $location,  $q,  $timeout,  $modal,  
+         $window,  egAuth,  bucketSvc,  egNet,  egIDL) {
+
+    $scope.bucketSvc = bucketSvc;
+    $scope.bucket = function() { return bucketSvc.currentBucket }
+
+    // tabs: search, pending, view
+    $scope.setTab = function(tab) { 
+        $scope.tab = tab;
+        $scope.pageList = bucketSvc[tab + 'List'];
+
+        // for bucket selector; must be called after route resolve
+        bucketSvc.fetchUserBuckets(); 
+    };
+
+    $scope.loadBucket = function(id) {
+        $location.path(
+            '/cat/bucket/record/' + 
+                $scope.tab + '/' + encodeURIComponent(id));
+    }
+
+    $scope.addToBucket = function(all) {
+        /** TODO: open-ils.actor.container.item.create almost works
+         * with batches, but not quite ... */
+
+        var items = all ? $scope.pageList.items : 
+            $scope.pageList.selectedItems();
+        if (items.length == 0) return;
+
+        bucketSvc.bucketNeedsRefresh = true;
+
+        angular.forEach(items,
+            function(rec) {
+                var item = new egIDL.cbrebi();
+                item.bucket(bucketSvc.currentBucket.id());
+                item.target_biblio_record_entry(rec.id);
+                egNet.request(
+                    'open-ils.actor',
+                    'open-ils.actor.container.item.create', 
+                    egAuth.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.viewList.items.push(resp);
+                    bucketSvc.viewList.totalCount++;
+                });
+            }
+        );
+    }
+
+
+    // same for all controllers
+    $scope.applyRowSelection = function($event, index) {
+        if ($event.ctrlKey || $event.metaKey) { // metaKey == mac command
+            $scope.pageList.toggleOneSelection(index);
+        } else {
+            $scope.pageList.selectOne(index);
+        }
+    }
+
+
+    /** ----------------
+     * this will all change when we stop using rmsr's
+     * TODO: stop using rmsr's
+     */
+    $scope.sort = function(field) {
+        $scope.pageList.offset = 0;
+        if (typeof $scope.pageList.sort == 'string' &&
+            $scope.pageList.sort == field) {
+            // already sorting on 'field', now sort descending
+            $scope.pageList.sort = {};
+            $scope.pageList.sort[field] = 'desc';
+        } else {
+            $scope.pageList.sort = field;
+        }
+    }
+
+    $scope.setupColumns = function() {
+        $scope.sortedFields = egIDL.classes.rmsr.fields.sort(
+            function(a, b) { return a.label < b.label ? -1 : 1 });
+
+        $scope.fields = {};
+        $scope.queryFields = {};
+        var cols = [];
+        angular.forEach($scope.sortedFields, function(field) {
+            if (field.virtual) return;
+            cols.push(field);
+            $scope.fields[field.name] = field;
+            $scope.queryFields[field.name] = field.name;
+        });
+
+        $scope.pageList.setColumns(cols);
+    }
+
+    $scope.getRecords = function(ids) {
+        if (ids.length == 0) return $q.when();
+
+        $scope.pageList.totalCount = ids.length;
+
+        // grab the lot in one go
+        return egNet.request(
+            'open-ils.fielder',
+            'open-ils.fielder.flattened_search',
+            egAuth.token(), "rmsr", $scope.queryFields, 
+            {id : ids},
+            {   sort : [$scope.pageList.sort || 'id'],
+                limit : $scope.pageList.limit,
+                offset : $scope.pageList.offset
+            }
+        ).then(
+            null, // success
+            null, // error
+            function(record) { // notify handler
+
+                // apply some data munging to make the list values 
+                // of 'rmsr' more human friendly.
+                record.isbn = record.isbn.replace(/\{NULL\}/,'');
+                record.issn = record.issn.replace(/\{NULL\}/,'');
+                record.isbn = record.isbn.replace(/\{(.*)\}/,'$1');
+                record.issn = record.issn.replace(/\{(.*)\}/,'$1');
+                $scope.pageList.items.push(record);
+            }
+        );
+    }
+
+    $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.reset();
+                    bucketSvc.allBuckets = []; // reset
+                    $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=' + egAuth.token(); 
+
+            $timeout(function() { $window.open(url) });
+        });
+    }
+}])
+
+.controller('SearchCtrl',
+       ['$scope','$routeParams','egAuth','egNet','egIDL','bucketSvc',
+function($scope,  $routeParams,  egAuth,  egNet,  egIDL,  bucketSvc) {
+    $scope.setTab('search');
+    $scope.setupColumns();
+    $scope.focusMe = true;
+
+    // add selected items directly to the pending list
+    $scope.addToPending = function(all) {
+        var recs = all ? $scope.pageList.items : $scope.pageList.selectedItems();
+        angular.forEach(recs, function(rec) {
+            if (bucketSvc.pendingList.items.filter( // remove dupes
+                function(r) {return r.id == rec.id}).length) return;
+            bucketSvc.pendingList.items.push(rec);
+        });
+    }
+
+    $scope.search = function() {
+        $scope.pageList.resetPageData();
+        $scope.searchInProgress = true;
+        bucketSvc.queryRecords = [];
+
+        egNet.request(
+            'open-ils.search',
+            'open-ils.search.biblio.multiclass.query', {   
+                // full search limit needs to be larger than page list limit
+                limit : $scope.pageList.limit * 10,
+            }, bucketSvc.queryString, true
+        ).then(function(resp) {
+            $scope.searchInProgress = false;
+            bucketSvc.queryRecords = resp.ids.map(function(id){return id[0]});
+            $scope.pageList.totalCount = bucketSvc.queryRecords.length;
+            $scope.getRecords(bucketSvc.queryRecords);
+        });
+    }
+
+    $scope.draw = function() { 
+        $scope.pageList.resetPageData();
+        $scope.getRecords(bucketSvc.queryRecords);
+    }
+
+    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)
+        .then(function(bucket) {
+            bucketSvc.viewList.totalCount = bucket.items().length;
+        });
+    }
+}])
+
+.controller('PendingCtrl',
+       ['$scope','$routeParams','egAuth','egNet','egIDL','bucketSvc',
+function($scope,  $routeParams,  egAuth,  egNet,  egIDL,  bucketSvc) {
+    $scope.setTab('pending');
+    $scope.setupColumns();
+
+    $scope.draw = function() { 
+        // only called when sorting an existing list of records
+        var ids = $scope.pageList.items.map(function(r) {return r.id});
+        $scope.pageList.resetPageData();
+        $scope.getRecords(ids);
+    }
+
+    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)
+        .then(function(bucket) {
+            bucketSvc.viewList.totalCount = bucket.items().length;
+        });
+    }
+}])
+
+.controller('ViewCtrl',
+       ['$scope','$window','$timeout','$location','$routeParams','egAuth','egNet','egIDL','bucketSvc','egEvent',
+function($scope,  $window,  $timeout,  $location,  $routeParams,  egAuth,  egNet,  egIDL,  bucketSvc, egEvent) {
+
+    $scope.setTab('view');
+    $scope.setupColumns();
+
+    $scope.bucketId = $routeParams.id;
+
+    // no bucket selected, clear out any cached data 
+    if (!$scope.bucketId) {
+        bucketSvc.currentBucket = null;
+        bucketSvc.viewList.reset();
+        return;
+    }
+    
+    $scope.detachRecords = function() {
+        var records = $scope.pageList.selectedItems();
+        angular.forEach(records, function(rec) {
+            bucketSvc.detachRecord(rec.item_id).then(function(resp) {
+                $scope.pageList.removeItem(rec.item_id);
+                $scope.pageList.totalCount--;
+            });
+        });
+    }
+
+    function getBucketRecords(recordIds) {
+        $scope.getRecords(recordIds).then(function() {
+            // link the bucket item to the record.
+            var matched = {};
+            angular.forEach($scope.bucket().items(), function(item) {
+                var rec;
+                var rid = item.target_biblio_record_entry();
+                if (matched[rid]) { 
+                    // dupe bib record.  clone it into the list
+                    rec = angular.copy(matched[rid]);
+                    $scope.pageList.items.push(rec);
+                } else {
+                    // find the record in the data we just fetched
+                    // note: don't use getItem, since our index field is 'item_id'
+                    rec = $scope.pageList.items.filter(
+                        function(r) {return r.id == rid})[0];
+                    matched[rid] = rec;
+                }
+                // rec will be unset if the record in question is not 
+                // visible in this page of data.
+                if (rec) rec.item_id = item.id();
+            });
+        });
+    }
+
+    // fetch the bucket and linked records as needed to 
+    // populate the page list.
+    $scope.draw = function() {
+        $scope.pageList.resetPageData();
+        bucketSvc.fetchBucket($scope.bucketId).then(
+            function(bucket) {
+                ids = bucketSvc.currentBucket.items().map(
+                    function(i){return i.target_biblio_record_entry()}
+                );
+                getBucketRecords(ids);
+            },
+            function(evt) { $scope.forbidden = true }
+        );
+    };
+
+    // avoid re-fetching the records for a bucket if the bucket
+    // is already loaded and we are navigating back to the 
+    // view tab.
+    if (bucketSvc.bucketRefreshLevel($scope.bucketId) == 1 ||
+        bucketSvc.viewList.count() == 0 ) {
+        $scope.draw();
+    }
+}])
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 (file)
index 0000000..3a68131
--- /dev/null
@@ -0,0 +1,768 @@
+/*
+ * TODO:
+ * when this file starts getting too large and we want to add code for 
+ * UIs that are not typically rendered (i.e. we don't necessarily want 
+ * to fetch the code on every page load), we can create tab-specific
+ * controllers which live in separate JS files which are only fetched
+ * when the related tab template is fetched.
+ */
+
+angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egListMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider) {
+    $locationProvider.html5Mode(true);
+
+    // 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 : 
+        // TODO: $inject array
+        function(egAuth, egUser, egNet, egEnv, egPCRUD, egStartup, egOrg) {
+
+        // load needed org unit settings and munge the data into 
+        // key/value pairs for ease of use.
+        egEnv.classLoaders.aous = function() {
+            return egNet.request(
+                'open-ils.actor',
+                'open-ils.actor.ou_setting.ancestor_default.batch',
+                egAuth.user().ws_ou(),
+                ['circ.obscure_dob'], 
+                egAuth.token()
+            ).then(function(blob) {
+                var settings = {};
+                angular.forEach(blob, function(val, key) {
+                    if (val) { settings[key] = val.value }
+                });
+                egEnv.aous = settings;
+            });
+        }
+
+        egEnv.loadClasses.push('aous');
+
+        // app-globally modify the default flesh fields for 
+        // fleshed user retrieval
+        egUser.defaultFleshFields.push('profile');
+        egUser.defaultFleshFields.push('net_access_level');
+        egUser.defaultFleshFields.push('ident_type');
+        egUser.defaultFleshFields.push('ident_type2');
+
+        return egStartup.go()
+    }};
+
+    $routeProvider.when('/circ/patron/search', {
+        templateUrl: './circ/patron/t_search',
+        controller: 'PatronSearchCtrl',
+        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/bills', {
+        templateUrl: './circ/patron/t_bills',
+        controller: 'PatronBillsCtrl',
+        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
+    });
+
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
+})
+
+/**
+ * Patron service
+ */
+.factory('patronSvc',
+       ['$q','egList','egNet','egAuth','egUser','egEnv','egOrg','egList',
+function($q,  egList,  egNet,  egAuth,  egUser,  egEnv,  egOrg,  egList) {
+
+    var service = {
+        // 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.
+        checkout_overrides : {},
+
+        // keep a cache of the patron search results
+        patrons : egList.create({indexFieldAsFunction : true}), // patron.id()
+        checkouts : egList.create(),
+        items_out : egList.create({indexFieldAsFunction : true}), // circ.id()
+        holds : egList.create(),
+        bills : egList.create(),
+        messages : egList.create()
+    };
+
+    // when we change the default patron, we need to clear out any
+    // data collected on that patron
+    service.resetPatronLists = function() {
+        service.checkouts.reset();
+        service.items_out.reset();
+        service.holds.reset();
+        service.bills.reset();
+        service.messages.reset();
+        service.checkout_overrides = {};
+    }
+
+    // sets the default user, fetching as necessary
+    service.setDefault = function(id, user, force) {
+        if (user) {
+            if (!force && service.current && 
+                service.current.id() == user.id()) return;
+
+            service.resetPatronLists();
+            service.current = user;
+            service.localFlesh(user);
+            service.fetchUserStats();
+
+        } else if (id) {
+            if (!force && service.current && 
+                service.current.id() == id) return;
+            service.resetPatronLists();
+
+            egUser.get(id).then(
+                function(user) {
+                    service.current = user;
+                    service.localFlesh(user);
+                    service.fetchUserStats();
+                },
+                function(err) {
+                    console.error(
+                        "unable to fetch user "+id+': '+js2JSON(err))
+                }
+            );
+        }
+    }
+
+    // flesh some additional user fields locally
+    service.localFlesh = function(user) {
+        if (typeof user.home_ou() != 'object')
+            user.home_ou(egOrg.get(user.home_ou()));
+        angular.forEach(
+            user.standing_penalties(),
+            function(penalty) {
+                if (typeof penalty.org_unit() != 'object')
+                    penalty.org_unit(egOrg.get(penalty.org_unit()));
+            }
+        );
+    }
+
+    // grab additional circ info
+    service.fetchUserStats = function() {
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.opac.vital_stats', 
+            egAuth.token(), service.current.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);
+                service.patron_stats = stats
+            }
+        )
+    }
+
+    return service;
+}])
+
+/**
+ * Manages tabbed patron view
+ * */
+.controller('PatronCtrl',
+       ['$scope','$q','$filter','egNet','egAuth','egUser','patronSvc','egEnv','egIDL',
+function($scope,  $q,  $filter,  egNet,  egAuth,  egUser,  patronSvc,  egEnv,  egIDL) {
+
+    // 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 = egEnv.aous;
+        if (patron_id) {
+            $scope.patron_id = patron_id
+            patronSvc.setDefault($scope.patron_id);
+        }
+    }
+
+    $scope.patron = function() { return patronSvc.current }
+    $scope.patron_stats = function() { return patronSvc.patron_stats }
+}])
+
+
+/**
+ * Manages patron search
+ */
+.controller('PatronSearchCtrl',
+       ['$scope','$q','$routeParams','$timeout','$window','$location',
+       '$filter','egIDL','egNet','egAuth','egEvent','egList','egUser','patronSvc',
+function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  
+        $filter,  egIDL,  egNet,  egAuth,  egEvent,  egList,  egUser,  patronSvc) {
+
+    $scope.initTab('search');
+    $scope.focusMe = true;
+    $scope.patrons = patronSvc.patrons;
+
+
+    // TODO: experiment
+    // if this is useful, it should be moved into a service.
+    $scope.tips = {
+        dismiss : function(tip) {
+            $window.localStorage.setItem('eg.tips.' + tip, 1);
+        },
+        dismissed : function(tip) {
+            return $window.localStorage.getItem('eg.tips.' + tip);
+        }
+        // TODO: function to reset all tips
+    };
+
+    // map form arguments into search params
+    function compileSearch(args) {
+        var search = {};
+        angular.forEach(args, function(val, key) {
+            if (!val) return;
+            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;
+    }
+
+    // send compiled search; get user IDs
+    function sendSearch(search) {
+        search = compileSearch(search);
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced',
+            egAuth.token(), search, 100 /* limit */,
+            [   /* sort */
+                "family_name ASC",
+                "first_given_name ASC",
+                "second_given_name ASC",
+                "dob DESC"
+            ],
+            null, /* TODO: OU filter */
+            search.inactive
+
+        ).then(function(ids) {
+            retrieveUsers(ids);
+        });
+    };
+
+    // fetch users by id and add them to the patrons list
+    function retrieveUsers(ids) {
+        angular.forEach(ids, function(id, idx) {
+            // capture idx to maintain search results order
+            egUser.get(id).then(function(user) {
+                $scope.patrons.items[idx] = user;
+            });
+        });
+    }
+
+    // collect form args fire patron search
+    $scope.search = function(args) {
+        if (args && Object.keys(args).length) {
+            $scope.patrons.reset();
+            if (args.id) {
+                retrieveUsers([args.id]);
+            } else {
+                sendSearch(args);
+            }
+        }
+    }
+
+    // manage table row selection
+    $scope.onPatronClick = function($event, user) {
+        $scope.lastSelected = user;
+
+        // control-click / command-click (mac) selects 
+        // or deselects a row without altering other rows
+        if ($event.ctrlKey || $event.metaKey) { 
+            $scope.patrons.toggleOneSelection(user.id());                         
+
+        // middle-click opens new tab for the patron
+        } else if ($event.which == 2) {
+
+            var url = $location.absUrl().replace(
+                /patron\/search.*$/, 
+                'patron/' + user.id() + '/checkout'
+            );
+            $window.open(url);
+
+        } else {                                                               
+            // vanilla click selects the patron as the current default
+            $scope.patrons.selectOne(user.id());                                  
+            patronSvc.setDefault(null, user);
+        }                                                                      
+    }
+
+    $scope.onPatronDblClick = function($event, user) {
+        $location.path('/circ/patron/' + user.id() + '/checkout');
+    }
+
+    // opens a new tab for each selected user at /checkout
+    // TODO: Chrome will only open one tab per user action (click,
+    // etc.). subsequent tabs open new windows (blocked by default).
+    // The only way around this I'm seeing is to use a chrome extension
+    // http://stackoverflow.com/questions/16749907/window-open-behaviour-in-chrome-tabs-windows
+    // for now, skip this feature and support control-click to open 
+    // multiple patrons instead.
+    $scope.openSelectedPatrons = function() {
+        angular.forEach(
+            $scope.patrons.selectedItems(),
+            function(patron) {
+                var url = $location.absUrl();
+                url = url.replace(/patron\/search.*$/, 
+                    'patron/' + patron.id() + '/checkout');
+                $window.open(url);
+            }
+        );
+    }
+
+    // handled up/down arrow events while the patrons results table is focused.
+    // disabled for now, since there are some UI issues to work out first:
+    // 1. up/down while a browser scroll bar is visible causes the browser to 
+    // scroll, which makes sense, but is a little jarring.  An overflow/scroll
+    // container would be better -- requires a non-table solution.. TODO
+    // 2. if table hover Bootstrap css is used, even though the currently
+    // selected row changes with arrow up/down, the mouse continues to 
+    // hover in its original position, making the hovered row appear to be
+    // selected (style-wise) even when it's not.  Disabling table-hover
+    // CSS works, but table-hover is useful, so...
+    $scope.navigateResults = function($event) {
+        // we can't select the next/previous user if we don't know 
+        // which user was selected last.  this should never happen, though.
+        if (!$scope.lastSelected) return;
+
+        var user;
+        if ($event.which == 40) { // down arrow
+            angular.forEach(
+                $scope.patrons.items,
+                function(item, idx) {
+                    if (item.id() == $scope.lastSelected.id())
+                        user = $scope.patrons.items[idx+1];
+                }
+            )
+        } else if ($event.which == 38) { // up arrow
+            angular.forEach(
+                $scope.patrons.items,
+                function(item, idx) {
+                    if (item.id() == $scope.lastSelected.id())
+                        user = $scope.patrons.items[idx-1];
+                }
+            )
+        }
+        
+        if (user) $scope.onPatronClick($event, user);
+    }
+
+}])
+
+/** * Manages patron summary view
+ */
+.controller('PatronSummaryCtrl',
+       ['$scope','$q','egNet','egAuth','egEvent','patronSvc',
+function($scope,  $q,  egNet,  egAuth,  egEvent,  patronSvc) {
+    // may not need this ctrl at all, since all data 
+    // come directly from the scope
+}])
+
+/**
+ * Manages checkout
+ */
+.controller('PatronCheckoutCtrl',
+       ['$scope','$q','$modal','$routeParams','egNet','egAuth','egUser','patronSvc','egEnv','egPCRUD','egOrg',
+function($scope,  $q,  $modal,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc,  egEnv,  egPCRUD,  egOrg) {
+    $scope.initTab('checkout', $routeParams.id);
+
+    $scope.focusMe = true;
+    $scope.checkouts = patronSvc.checkouts;
+    $scope.checkoutArgs = {type : 'barcode'};
+
+    if (egEnv.cnct) {
+        $scope.nonCatTypes = egEnv.cnct.list;
+    } else {
+        egPCRUD.search('cnct', 
+            {owning_lib : egOrg.fullPath(egAuth.user().ws_ou(), true)},
+            null, {atomic : true}
+        ).then(function(list) { 
+            egEnv.absorbList(list, 'cnct');
+            $scope.nonCatTypes = list 
+        });
+    }
+
+    egPCRUD.retrieveAll('ccm', null, {atomic : true}).then(
+        function(list) { $scope.circModifiers = list });
+
+    // TODO: apply correct response order
+    $scope.checkout = function(args) {
+        var type = args.type;
+        var coArgs = angular.copy(args);
+
+        args.copy_barcode = ''; // reset for UI
+        delete coArgs.type; // not a valid API arg
+
+        if (type == 'barcode') {
+            performCheckout(coArgs);
+        } else {
+            // noncat checkout
+        }
+
+        $scope.focusMe; // return focus to barcode input
+    }
+
+    var index = 0;
+    function performCheckout(args, override) {
+        console.debug('checkout: ' + js2JSON(args));
+
+        var method = 'open-ils.circ.checkout.full';
+        if (override) method += '.override';
+
+        args.patron_id = $scope.patron_id;
+
+        egNet.request(
+            'open-ils.circ', method, egAuth.token(), args
+        ).then(function(evt) {
+
+            if (!evt) { 
+                console.error('no checkout response received');
+                return;
+            }
+
+            // TODO: how best to handle multiple response events?
+            if (angular.isArray(evt)) evt = evt[0];
+            evt.id = index++;
+            evt.copy_barcode = args.copy_barcode;
+            handleCheckoutResponse(evt, args, override)
+        });
+    }
+
+    function handleCheckoutResponse(evt, args, override) {
+
+        if (args.precat && evt.payload) {
+            evt.payload.record = {
+                title  : args.dummy_title,
+                author : args.dummy_author,
+                isbn   : args.dummy_isbn
+            }
+        }
+
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                // keep the global patron object in sync with reality
+                $scope.checkouts.items.push(evt);
+                patronSvc.patron_stats.checkouts.out++;
+                patronSvc.refreshItemsOut = true;
+                break;
+
+            case 'ITEM_NOT_CATALOGED':
+                openPrecatDialog(evt.copy_barcode);
+                break;
+
+            case 'PATRON_EXCEEDS_FINES':
+                if (!override) {
+                    if (patronSvc.checkout_overrides[evt.textcode]) {
+                        performCheckout(args, true);
+                    } else {
+                        openOverrideConfirmDialog(evt, args);
+                    }
+                }
+                break;
+
+            /* stuff to consider 
+            PERM_FAILURE
+            PATRON_EXCEEDS_OVERDUE_COUNT
+            PATRON_BARRED
+            CIRC_EXCEEDS_COPY_RANGE
+            PATRON_ACCOUNT_EXPIRED
+            ITEM_DEPOSIT_REQUIRED
+            ITEM_RENTAL_FEE_REQUIRED
+            ITEM_DEPOSIT_PAID
+            PATRON_EXCEEDS_LOST_COUNT
+            ACTION_CIRCULATION_NOT_FOUND
+            PATRON_EXCEEDS_CHECKOUT_COUNT
+            COPY_CIRC_NOT_ALLOWED
+            COPY_NOT_AVAILABLE
+            COPY_IS_REFERENCE
+            COPY_NEEDED_FOR_HOLD
+            MAX_RENEWALS_REACHED
+            CIRC_CLAIMS_RETURNED
+            COPY_ALERT_MESSAGE
+            PATRON_EXCEEDS_FINES
+            */
+
+            default:
+                console.warn('unhandled circ response : ' + evt.textcode);
+                // push it on the list so the user can at least see 
+                // something happened.
+                $scope.checkouts.items.push(evt);
+
+        }
+    }
+
+    // define our modal dialogs
+
+    function openPrecatDialog(copy_barcode) {
+        $modal.open({
+            templateUrl: './circ/patron/t_precat_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 'circMods',
+                function($scope, $modalInstance, circMods) {
+                $scope.focusMe = true;
+                $scope.precatArgs = {
+                    copy_barcode : copy_barcode,
+                    circ_modifier : circMods.length ? circMods[0].code() : ''
+                };
+                $scope.circModifiers = circMods;
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }],
+            // pass the circ mod list into the modal environment
+            // the angular way. 
+            resolve : {
+                circMods : function() { return $scope.circModifiers }
+            }
+        }).result.then(
+            function(args) {
+                $scope.focusMe = true; // main barcode input
+                if (!args || !args.dummy_title) return;
+                args.precat = true;
+                performCheckout(args);
+            },
+            function() {
+                // dialog was closed without action
+                $scope.focusMe = true;
+            }
+        );
+    }
+
+    function openOverrideConfirmDialog(evt, args) {
+        $modal.open({
+            templateUrl: './circ/patron/t_event_override_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 
+                function($scope, $modalInstance) {
+                $scope.evt = evt;
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }]
+        }).result.then(
+            function() {
+                $scope.focusMe = true; // main barcode input
+                patronSvc.checkout_overrides[evt.textcode] = true;
+                performCheckout(args, true);
+            },
+            function() {
+                // dialog was closed without action
+                $scope.focusMe = true;
+            }
+        );
+    }
+
+
+}])
+
+/**
+ * Manages checkout
+ */
+.controller('PatronItemsOutCtrl',
+       ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc','egPCRUD','egOrg',
+function($scope,  $q,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc,  egPCRUD,  egOrg) {
+    $scope.initTab('items_out', $routeParams.id);
+    $scope.items_out = patronSvc.items_out;
+
+    $scope.onRowClick = function($event, circ) {
+        $scope.lastSelected = circ;
+        // control-click / command-click (mac) selects 
+        // or deselects a row without altering other rows
+        if ($event.ctrlKey || $event.metaKey) { 
+            $scope.items_out.toggleOneSelection(circ.id());                         
+        } else {
+            $scope.items_out.selectOne(circ.id());                                  
+        }                                                                      
+    }
+
+    function fetchItemsOut() {
+        var newlist = [];
+        egNet.request('open-ils.actor',
+            'open-ils.actor.user.checked_out.authoritative',
+            egAuth.token(), $scope.patron_id)
+        .then(function(outs) {
+
+            // put them into a list so we can keep track of the 
+            // default display order
+            newlist = newlist.concat(outs.out)
+                .concat(outs.overdue)
+                .concat(outs.long_overdue)
+                .concat(outs.lost)
+                .concat(outs.claims_returned)
+
+            // TODO: Websockets means 1 streaming request instead of
+            // multiple singles.  As is, one response may be too large
+            // to wait on.
+            angular.forEach(newlist, function(id) {
+
+                egPCRUD.retrieve('circ', id, {
+                    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'] }
+                }).then(function(circ) {
+
+                    // local fleshing
+                    circ.circ_lib(egOrg.get(circ.circ_lib()));
+
+                    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()}
+                        })
+                    }
+
+                    angular.forEach(newlist, function(id, idx) {
+                        // insert into the result list in the same 
+                        // order as above
+                        if (id == circ.id())
+                            patronSvc.items_out.items[idx] = circ;
+                    });
+                });
+            })
+        })
+    }
+
+    fetchItemsOut(); // TODO: only when necessary
+}])
+
+/**
+ * Manages holds
+ */
+.controller('PatronHoldsCtrl',
+       ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc','egOrg',
+function($scope,  $q,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc,  egOrg) {
+    $scope.initTab('holds', $routeParams.id);
+
+    $scope.holds = patronSvc.holds;
+
+    $scope.onRowClick = function($event, hold) {
+        $scope.lastSelected = hold;
+        if ($event.ctrlKey || $event.metaKey) { 
+            $scope.holds.toggleOneSelection(hold.id);                         
+        } else {
+            $scope.holds.selectOne(hold.id);                                  
+        }                                                                      
+    }
+
+    function fetchPatronHolds() {
+
+        egNet.request(
+            'open-ils.circ',
+            'open-ils.circ.holds.id_list.retrieve.authoritative',
+            egAuth.token(), $scope.patron_id
+
+        ).then(function(hold_ids) {
+            angular.forEach(hold_ids, function(id) {
+
+                egNet.request(
+                    'open-ils.circ',
+                    'open-ils.circ.hold.details.retrieve.authoritative',
+                    egAuth.token(), id
+
+                ).then(function(hold_data) {
+                    var hold = hold_data.hold;
+                    hold_data.id = hold.id();
+                    
+                    // flesh
+                    hold.pickup_lib(egOrg.get(hold.pickup_lib())); 
+
+                    angular.forEach(hold_ids, function(id, idx) {
+                        if (id == hold.id()) // maintain order
+                            patronSvc.holds.items[idx] = hold_data;
+                    });
+                });
+            })
+        })
+    }
+
+    fetchPatronHolds(); // TODO: only when necessary
+
+}])
+
+/**
+ * Manages bills
+ */
+.controller('PatronBillsCtrl',
+       ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc',
+function($scope,  $q,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc) {
+    $scope.initTab('bills', $routeParams.id);
+}])
+
+/**
+ * Manages messages
+ */
+.controller('PatronMessagesCtrl',
+       ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc',
+function($scope,  $q,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc) {
+    $scope.initTab('messages', $routeParams.id);
+}])
+
+/**
+ * Manages edit
+ */
+.controller('PatronEditCtrl',
+       ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc',
+function($scope,  $q,  $routeParams,  egNet,  egAuth,  egUser,  patronSvc) {
+    $scope.initTab('edit', $routeParams.id);
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/navbar.js b/Open-ILS/web/js/ui/default/staff/navbar.js
new file mode 100644 (file)
index 0000000..7e53529
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * Free-floating controller which can be used by any app.
+ */
+function NavCtrl($scope, egStartup, egAuth, egEnv) {
+
+    // tied to logout link
+    $scope.logout = function() {
+        egAuth.logout();
+        return true;
+    };
+
+    /**
+     * Two important things happening here.
+     *
+     * 1. Since this is a standalone controller, which may execute at
+     * any time during page load, we have no gaurantee that needed
+     * startup actions, session retrieval being the main one, have taken
+     * place yet. So we kick off the startup chain ourselves and run
+     * actions when it's done. Note this does not mean startup runs
+     * multiple times. If it's already started, we just pick up the
+     * existing startup promise.
+     *
+     * 2. We are updating the $scope asynchronously, but since it's
+     * done inside a promise resolver, another $digest() loop will
+     * run and pick up our changes.  No $scope.$apply() needed.
+     */
+    egStartup.go().then(
+        function() {
+
+            // login page will not have a cached user
+            if (!egAuth.user()) return;
+
+            $scope.username = egAuth.user().usrname();
+
+            // TODO: move workstation into egAuth
+            if (egEnv.aws) {
+                $scope.workstation = 
+                    egEnv.aws.map[egAuth.user().wsid()].name();
+            }
+        }
+    );
+}
+
+// minify-safe dependency injection
+NavCtrl.$inject = ['$scope', 'egStartup', 'egAuth', 'egEnv'];
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 (file)
index 0000000..3c4ad88
--- /dev/null
@@ -0,0 +1,104 @@
+/* Core Sevice - egAuth
+ *
+ * Manages login and auth session retrieval
+ *
+ * Angular cookies are still fairly primitive.  
+ * In particular, you can't set the path.
+ * https://github.com/angular/angular.js/issues/1786
+ */
+
+angular.module('egCoreMod')
+
+.constant('EG_AUTH_COOKIE', 'ses')
+
+.factory('egAuth', 
+       ['$q','$cookies','egNet','EG_AUTH_COOKIE',
+function($q,  $cookies,  egNet,  EG_AUTH_COOKIE) {
+
+    var service = {
+        // expose user and token via function, since we will eventually
+        // want to support multiple active logins, in which case user()
+        // and token() will return data for the currently active login.
+        user : function() {
+            return this._user;
+        },
+        token : function() {
+            return $cookies[EG_AUTH_COOKIE];
+        }
+    };
+
+    /* 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) {
+                        service._user = user;
+                        deferred.resolve();
+                    } else {
+                        delete $cookies[EG_AUTH_COOKIE]; 
+                        deferred.reject();
+                    }
+                }
+            );
+
+        } else {
+            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') {
+                            $cookies[EG_AUTH_COOKIE] = evt.payload.authtoken;
+                            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;
+    };
+
+    service.logout = function() {
+        if (service.token()) {
+            egNet.request(
+                'open-ils.auth', 
+                'open-ils.auth.session.delete', 
+                service.token()); // fire and forget
+            delete $cookies[EG_AUTH_COOKIE];
+        }
+        service._user = null;
+    };
+
+    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 (file)
index 0000000..d862450
--- /dev/null
@@ -0,0 +1,6 @@
+
+/**
+ * egCoreMod houses all of the services, etc. required by all pages
+ * for basic functionality.
+ */
+angular.module('egCoreMod', ['ngCookies']);
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 (file)
index 0000000..0e1f2b7
--- /dev/null
@@ -0,0 +1,124 @@
+/**
+ * Core Service - egEnv
+ *
+ * Manages startup data loading.  All registered loaders run 
+ * simultaneously.  When all promises are resolved, the promise
+ * returned by egEnv.load() is resolved.
+ *
+ * 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','egAuth','egPCRUD','egIDL',
+function($q,  egAuth,  egPCRUD,  egIDL) { 
+
+    var service = {
+        // collection of custom loader functions
+        loaders : []
+    };
+
+    /* 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 = {list : list, map : {}};
+        var pkey = egIDL.classes[class_].pkey;
+        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', 'aws'];
+
+    /*
+     * 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() {
+            return egPCRUD.search('aou', {parent_ou : null}, 
+                {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+            ).then(
+                function(tree) {service.absorbTree(tree, 'aou')}
+            );
+        },
+        aws : function() {
+            // by default, load only the workstation for the authenticated 
+            // user.  to load all workstations, override this loader.
+            // TODO: auth.session.retrieve should be capable of returning
+            // the session with the workstation fleshed.
+            if (!egAuth.user().wsid()) { 
+                // nothing to fetch.  
+                return $q.when();
+            }
+            return egPCRUD.retrieve('aws', egAuth.user().wsid())
+            .then(function(ws) {service.absorbList([ws], 'aws')});
+        }
+    };
+
+    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 (file)
index 0000000..ccec585
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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.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/flattener.js b/Open-ILS/web/js/ui/default/staff/services/flattener.js
new file mode 100644 (file)
index 0000000..2be9140
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Service for communicating with the Evergreen flattener
+ * web service.
+ *
+ * egFlattener.load({
+ *  hint : aou, 
+ *  map : {shortname : shortname, parent_ou : parent_ou.shortname},
+ *  where : {id : {'<>' : null}}
+ *  slo : {offset : 0, limit : 20, order_by : ['shortname']}
+ * }).then( function(data) { console.log(data) } );
+ */
+angular.module('egFlattenerMod', ['egCoreMod'])
+
+.factory('egFlattener', 
+       ['$q','$http','egAuth', 
+function($q,  $http,  egAuth) { 
+
+    var url = '/opac/extras/flattener';
+
+    return {
+        load : function(args) {
+            args.ses = egAuth.token();
+            args.format = args.format || 'application/json';
+            
+            angular.forEach(['map', 'where', 'slo'], function(key) {
+                args[key] = js2JSON(args[key]);
+            });
+
+            /** angular $http uses content type application/json natively.  
+              * flattener / mod_perl (?) does not extract that data, so
+              * we have to encode it ourselves as x-www-form-urlencoded 
+              * http://victorblog.com/2012/12/20/make-angularjs-http-service-behave-like-jquery-ajax/
+              */
+            var query = 'ses=' + args.ses;
+            angular.forEach(args, function(val, key) {
+                if (key == 'ses' || !val) return;
+                query += '&' + key + '=' + encodeURIComponent(val);
+            });
+
+            return $http({
+                url : url,
+                data : query,
+                method : 'POST',
+                headers : {
+                    'Content-Type':
+                    'application/x-www-form-urlencoded; charset=UTF-8'
+                }
+            });
+        }
+    };
+}])
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 (file)
index 0000000..3d88924
--- /dev/null
@@ -0,0 +1,62 @@
+/**
+ * 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;
+
+        // original, global reference no longer needed
+        $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);
+    };
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/list.js b/Open-ILS/web/js/ui/default/staff/services/list.js
new file mode 100644 (file)
index 0000000..9f28fb9
--- /dev/null
@@ -0,0 +1,300 @@
+/** 
+ * Service for generating list management objects.
+ * Each object tracks common list attributes like limit, offset, etc.,
+ * A ListManager is not responsible for collecting data, it's only
+ * there to allow controllers to have a known consistent API
+ * for manage list-related information.
+ *
+ * The service exports a single attribute, which instantiates
+ * a new ListManager object.  Controllers using ListManagers
+ * are responsible for providing their own route persistence.
+ *
+ * var list = egList.create();
+ * if (list.hasNextPage()) { ... }
+ *
+ */
+
+angular.module('egListMod', ['egCoreMod'])
+
+.factory('egList', ['$filter', 'egIDL', function($filter, egIDL) {
+
+    function ListManager(args) {
+        var self = this;
+        this.limit = 25;
+        this.offset = 0;
+        this.sort = null;
+        this.totalCount = 0;
+
+        // attribute on each item in our items list which
+        // refers to its unique identifier value
+        this.indexField = 'id';
+
+        // true if the index field name refers to a 
+        // function instead of an object attribute
+        this.indexFieldAsFunction = false;
+
+        // per-page list of items
+        this.items = [];
+
+        // collect any defaults passed in
+        if (args) angular.forEach(args, 
+            function(val, key) {self[key] = val});
+
+        // sorted list of all available display columns
+        // a column takes form of (at minimum) {name : name, label : label}
+        this.allColumns = [];
+
+        // {name => true} map of visible columns
+        this.displayColumns = {}; 
+
+        // {index => true} map of selected rows
+        this.selected = {};
+
+        this.indexValue = function(item) {
+            if (this.indexFieldAsFunction) {
+                return item[this.indexField]();
+            } else {
+                return item[this.indexField];
+            }
+        }
+
+        // returns item objects
+        this.selectedItems = function() {
+            var items = [];
+            angular.forEach(
+                this.items,
+                function(item) {
+                    if (self.selected[self.indexValue(item)])
+                        items.push(item);
+                }
+            );
+            return items;
+        }
+
+        // remove an item from the items list and return the deleted item
+        this.removeItem = function(index) {
+            var deleted;
+            angular.forEach(this.items, function(item, idx) {
+                if (self.indexValue(item) == index) {
+                    self.items.splice(idx, 1);
+                    deleted = item;
+                }
+            });
+            delete this.selected[index];
+            return deleted;
+        }
+
+        // get item by index value
+        this.getItem = function(index) {
+            return this.items.filter(
+                function(item) { return self.indexValue(item) == index }
+            )[0];
+        }
+
+        this.count = function() { return this.items.length }
+
+        this.reset = function() {
+            this.offset = 0;
+            this.totalCount = 0;
+            this.items = [];
+            this.selected = {};
+        }
+
+        // prepare to draw a new page of data
+        this.resetPageData = function() {
+            this.items = [];
+            this.selected = {};
+        }
+
+        this.showAllColumns = function() {
+            angular.forEach(this.allColumns, function(field) {
+                self.displayColumns[field.name] = true;
+            });
+        }
+
+        this.hideAllColumns = function() {
+            angular.forEach(this.allColumns, function(field) {
+                delete self.displayColumns[field.name]
+            });
+        }
+
+        // selects one row after deselecting all of the others
+        this.selectOne = function(index) {
+            this.deselectAll();
+            this.selected[index] = true;
+        }
+
+        // selects or deselects a row, without affecting the others
+        this.toggleOneSelection = function(index) {
+            if (this.selected[index]) {
+                delete this.selected[index];
+            } else {
+                this.selected[index] = true;
+            }
+        }
+
+        // selects all visible rows
+        this.selectAll = function() {
+            angular.forEach(this.items, function(item) {
+                self.selected[self.indexValue(item)] = true
+            });
+        }
+
+        // if all are selected, deselect all, otherwise select all
+        this.toggleSelectAll = function() {
+            if (Object.keys(this.selected).length == this.items.length) {
+                this.deselectAll();
+            } else {
+                this.selectAll();
+            }
+        }
+
+        // deselects all visible rows
+        this.deselectAll = function() {
+            this.selected = {};
+        }
+
+        this.addColumn = function(col) {
+            this.allColumns.push(col);
+            if (col.display) 
+                this.displayColumns[col.name] = true;
+        }
+
+        this.defaultColumns = function(list) {
+            // set the display=true value for the selected columns
+            angular.forEach(list, function(name) {
+                self.displayColumns[name] = true
+            });
+
+            // default columns may be provided before we 
+            // know what our columns are.  Save them for later.
+            this._defaultColumns = list;
+
+            // setColumns we rearrange the allCollums 
+            // list based on the content of this._defaultColums
+            if (this.allColumns.length) 
+                this.setColumns(this.allColumns);
+        }
+
+        this.setColumns = function(list) {
+            if (this._defaultColumns) {
+                this.allColumns = [];
+
+                // append the default columns to the front of
+                // our allColumnst list.  Any remaining columns
+                // are plopped onto the end.
+                angular.forEach(
+                    this._defaultColumns,
+                    function(name) {
+                        var foundIndex;
+                        angular.forEach(list, function(f, idx) {
+                            if (f.name == name) {
+                                self.allColumns.push(f);
+                                foundIndex = idx;
+                            }
+                        });
+                        list.splice(foundIndex, 1);
+                    }
+                );
+                this.allColumns = this.allColumns.concat(list);
+                delete this._defaultColumns;
+
+            } else {
+                this.allColumns = list;
+                angular.forEach(this.allColumns, function(col) {
+                    if (col.display) 
+                        self.displayColumns[col.name] = true;
+                });
+            }
+        }
+
+        this.onFirstPage = function() { 
+            return this.offset == 0;
+        }
+
+        this.hasNextPage = function() {
+            // we have less data than requested, there must
+            // not be any more pages
+            if (this.items.length < this.limit) return false;
+
+            // if the total count is not known, assume that a full
+            // page of data implies more pages are available.
+            if (!this.totalCount) return true;
+
+            // we have a full page of data, but is there more?
+            return this.totalCount > (this.offset + this.items.length);
+        }
+
+        this.incrementPage = function() {
+            this.offset += this.limit;
+        }
+
+        this.decrementPage = function() {
+            if (this.offset < this.limit) {
+                this.offset = 0;
+            } else {
+                this.offset -= this.limit;
+            }
+        }
+
+        // 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.
+        // TODO: support modifying field values -- 
+        // useful for inline table editing
+        this.fieldValue = function(obj, dotpath) {
+            if (!obj) return '';
+            if (!dotpath) return obj;
+
+            var idlField;
+            var parts = dotpath.split('.');
+            var cls, clsobj;
+
+            angular.forEach(parts, function(step, idx) {
+
+                if (!obj || typeof obj != 'object') {
+                    // there are valid reasons for paths to be cut 
+                    // short, e.g. when data sets have varying contents.
+                    return obj;
+                }
+
+                cls = obj.classname;
+                if (cls && (clsobj = egIDL.classes[cls])) {
+                    idlField = clsobj.fields.filter(
+                        function(f) { return f.name == step })[0];
+                    obj = obj[step]();
+                } else {
+                    if (typeof obj[step] == 'function') {
+                        obj = obj[step]();
+                    } else {
+                        obj = obj[step];
+                    }
+                }
+            });
+
+            if (obj === null || obj === undefined || obj === '') 
+                return '';
+
+            if (!idlField) return obj;
+
+            switch(idlField.datatype) {
+                case 'timestamp':
+                    return $filter('date')(obj, 'shortDate');
+                case 'bool':
+                    // let the browser translate true / false for us
+                    return Boolean(value == 't');
+                default:
+                    return obj;
+            }
+        }
+    }
+
+    return {
+        create : function(args) { 
+            return new ListManager(args) 
+        }
+    };
+}]);
+
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 (file)
index 0000000..e78cab2
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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') {
+            console.log('BROADCASTING');
+            $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);
+            }
+        }).send();
+
+        return deferred.promise;
+    }
+
+    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 (file)
index 0000000..c4071fc
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * Core Service - egOrg
+ *
+ * TODO: more docs
+ */
+angular.module('egCoreMod')
+
+.factory('egOrg', ['egEnv', 'egAuth', 'egPCRUD',
+function(egEnv, egAuth, egPCRUD) { 
+
+    var service = {};
+
+    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;
+    };
+
+    // 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;
+    }
+
+    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 (file)
index 0000000..7caec9a
--- /dev/null
@@ -0,0 +1,290 @@
+/**
+ * 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) {
+ *     ctx.retrieve('aou', 1).then(
+ *       function(org) {
+ *         console.log(org.id());
+ *         ctx.disconnect();
+ *       }
+ *     )
+ *   }
+ * );
+ */
+angular.module('egCoreMod')
+
+// env fetcher
+.factory('egPCRUD', ['$q', 'egAuth', 'egIDL', function($q, 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);
+                },
+                onerror : function(e) {
+                    self.err(method + " failed " + e);
+                    deferred.reject(e);
+                }
+            }).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/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js
new file mode 100644 (file)
index 0000000..644598c
--- /dev/null
@@ -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 (this.promise) {
+            // startup already started, return our existing promise
+            return this.promise;
+        } 
+
+        // create a new promise and fire off startup
+        var deferred = $q.defer();
+        this.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 this.promise;
+    }
+    
+    return service;
+}]);
+
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 (file)
index 0000000..786750f
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+  * UI tools and directives.
+  */
+angular.module('egUiMod', [])
+
+
+/**
+ * <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 select-me="iWantToBeSelected"/>
+// $scope.iWantToBeSelected = true;
+.directive('selectMe', 
+['$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].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();
+    };
+})
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 (file)
index 0000000..515b81c
--- /dev/null
@@ -0,0 +1,93 @@
+/** 
+ * Service for fetching fleshed user objects.
+ * The last user retrieved is kept until replaced by a new user.
+ */
+
+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;
+    };
+
+    /*
+     * 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) {
+        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));
+                });
+                answer[perm] = all;
+            });
+            if (!isArray) answer = answer[permList[0]];
+            deferred.resolve(answer);
+        });
+        return deferred.promise;
+    };
+
+    return service;
+}]);
+
diff --git a/web-staff-log.txt b/web-staff-log.txt
new file mode 100644 (file)
index 0000000..dc8fb49
--- /dev/null
@@ -0,0 +1,260 @@
+Browser-Based Staff Client Development Log
+==========================================
+
+2013-11-20 Templates and Apache
+-------------------------------
+
+When a path is requested from the server, e.g.
+/eg/staff/circ/patron/search, there are 2 different aspects of the file
+that are of intest to us: the content of the HTML file and the path used
+to retrieve the file.
+
+Page Content
+~~~~~~~~~~~~
+
+For Angular apps, the HTML page only needs to provide enough information
+for Angular to load the correct application -- a seed document.  A seed
+document might look something like this:
+
+[source,html]
+-----------------------------------------------------------------------------
+<!doctype html>
+<html lang="en_us" ng-app="egPatronApp" ng-controller="PatronCtrl">
+  <head>
+    <title>Evergreen Staff Patron</title>
+    <base href="/eg/staff/">
+    <meta charset="utf-8">
+    <!-- css... -->
+  </head>
+  <body>
+    <div ng-view></div>
+  </body>
+  <!-- scripts... -->
+</html>
+-----------------------------------------------------------------------------
+
+Note the body is a single div with an 'ng-view' tag.
+
+Building Pages from Seeds
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+With the above document we know the Angular application (egPatronApp),
+we have an Angular Controller (PatronCtrl -- not strictly required
+here).  If we assume this page was fetched using the path
+'/eg/staff/circ/patron/search', then we have all we need to build the 
+real page.
+
+The Angular App will contain a series of app-specific routes based on
+the URL path.  Here, our (relative) path will be '/circ/patron/search',
+since the base path of the application is '/eg/staff/'.  For our route
+configuration we have a chunk of code like this:
+
+[source,js]
+-----------------------------------------------------------------------------
+$routeProvider.when('/circ/patron/search', {
+    templateUrl: './circ/patron/t_search',
+    controller: 'PatronSearchCtrl',
+    resolve : resolver // more on resolvers later...
+});
+-----------------------------------------------------------------------------
+
+When the browser lands on '/eg/staff/circ/patron/search', Angular will
+locate the template file at './circ/patron/t_search' (the app-relative
+path to a Template Toolkit template), by performing an HTTP request and,
+once fetched, will drive the display of the Angular template within with
+the JS controller called PatronSearchCtrl and insert the content of the
+template into the body of the page at the location of the <div ng-view>
+div.
+
+****
+'Note': For speed, it's sometimes better to include Angular templates
+directly in the delivered document so that one less HTTP request is
+needed.  More on that later.
+****
+
+Fetching the Same Page at Different URLs
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The egPatronApp might support a variety of different route-specific 
+interfaces that are all driven by the same seed document.  This means
+we have to tell Apache to always deliver the same file when we access
+files within a given range of URL paths.  The secret to this is in 
+Apache Rewrite configuration.
+
+For example:
+
+[source,conf]
+-----------------------------------------------------------------------------
+<Location /eg/staff/circ/patron/>
+    Options -MultiViews
+    RewriteEngine On
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/index
+    RewriteCond %{PATH_INFO} !/staff/circ/patron/t_*
+    RewriteRule .* /eg/staff/index [L,DPI]
+</Location>
+-----------------------------------------------------------------------------
+
+In short, any URL path that does not map to the index file or to a file
+whose name starts with "t_" (more on 't_' below) will result in Apache
+rewriting the request to deliver the index file (i.e. our seed 
+document).
+
+So, in our example, a request for '/eg/staff/circ/patron/search', will return
+the index file found at '/eg/staff/circ/patron/index', which maps on the
+server side to the Template Toolkit file at 
+'/path/to/templates/staff/circ/patron/index.tt2'.
+
+Two complications arise from this approach.  Help appreciated!
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Simpler rewrite rules exist in the wild...
+++++++++++++++++++++++++++++++++++++++++++
+
+But, they do not take into account that we are fetching Template
+Toolkit-generated files instead of vanilla HTML.  The rules I found
+online take the form of "if it's not a real file, return the index", but
+none of the files we fetch are real files, since they are internally
+mapped to Template Toolkit files.  This is why I'm using the 't_'
+prefix.  It makes the mapping trivial.  I'm all ears for better solution.
+
+Configuration Explosion
++++++++++++++++++++++++
+
+This I see as a real problem, but one that certainly has a solution.
+The configuration chunk above is such that we need a new chunk for each
+top-level Angular app.  This will quickly get out of hand.  A single,
+dynamic configuration that can map elemenents of arbitrarily-nested
+paths (or possibly a small set with predefined path depths) to the 
+correct index file would be ideal.
+
+
+2013-11-21 Angular $scope inheritance
+-------------------------------------
+
+Consider the following document:
+
+[source,html]
+-----------------------------------------------------------------------------
+<div ng-controller="TopCtrl">
+  <div>Top: {{attr1}}</div>
+  <div ng-controller="SubCtrl">
+    <div>Sub: {{attr1}}</div>
+  </div>
+</div>
+-----------------------------------------------------------------------------
+
+And the following code:
+
+[source,js]
+-----------------------------------------------------------------------------
+.controller('TopCtrl', function($scope) { $scope.attr1 = 'top-attr' })
+.controller('SubCtrl', function($scope) { })
+-----------------------------------------------------------------------------
+
+The output:
+
+[source,sh]
+-----------------------------------------------------------------------------
+Top: top-attr
+Sub: top-attr
+-----------------------------------------------------------------------------
+
+Now, if we apply a value in the child:
+
+[source,js]
+-----------------------------------------------------------------------------
+.controller('SubCtrl', function($scope) { $scope.attr1 = 'sub-attr' })
+-----------------------------------------------------------------------------
+[source,sh]
+-----------------------------------------------------------------------------
+Top: top-attr
+Sub: sub-attr
+-----------------------------------------------------------------------------
+
+Setting a value in the child does not change the value in the parent.
+Scopes are inherited prototypically, which means attributes from a
+parent scope are copied into the child scope and the child's version of
+the attribute masks that of the parent.
+
+For both scopes to share a single value, either the parent needs to 
+provide a setter function on the value:
+
+[source,js]
+-----------------------------------------------------------------------------
+.controller('TopCtrl', function($scope) {
+  $scope.attr1 = 'top-attr';
+  $scope.setAttr1 = function(val) {
+    $scope.attr1 = val;
+  }
+})
+.controller('SubCtrl', function($scope) {
+    $scope.setAttr1('sub-attr');
+})
+-----------------------------------------------------------------------------
+
+Produces..
+
+[source,sh]
+-----------------------------------------------------------------------------
+Top: sub-attr
+Sub: sub-attr
+-----------------------------------------------------------------------------
+
+Or the value in question needs to be stored within a structure.
+
+[source,html]
+-----------------------------------------------------------------------------
+<div ng-controller="TopCtrl">
+  <div>Top: {{attrs.attr1}}</div>
+  <div ng-controller="SubCtrl">
+    <div>Sub: {{attrs.attr1}}</div>
+  </div>
+</div>
+-----------------------------------------------------------------------------
+
+[source,js]
+-----------------------------------------------------------------------------
+.controller('TopCtrl', function($scope) { $scope.attrs = {attr1 : 'top-attr'} })
+.controller('SubCtrl', function($scope) { $scope.attrs.attr1 = 'sub-attr' })
+-----------------------------------------------------------------------------
+
+Also produces..
+
+[source,sh]
+-----------------------------------------------------------------------------
+Top: sub-attr
+Sub: sub-attr
+-----------------------------------------------------------------------------
+
+Since the child scope is not clobbering the 'attrs' attribute, both
+scopes share the value, which is a reference to a single object.
+
+This last is approach is the best for providing two-way binding across
+both scopes.  For example:
+
+[source,html]
+-----------------------------------------------------------------------------
+<div ng-controller="TopCtrl">
+  <div>Top: <input ng-model="attrs.attr1" type="text"/></div>
+  <div ng-controller="SubCtrl">
+    <div>Sub: <input ng-model="attrs.attr1" type="text"/></div>
+  </div>
+</div>
+-----------------------------------------------------------------------------
+
+With this, typing a value into the first input, will set the value
+for both scopes.
+
+For more, see 
+https://github.com/angular/angular.js/wiki/Understanding-Scopes[Understanding-Scopes].
+
+
+Future Topics...
+----------------
+
+ * Routing vs Loading
+ * Angular Services / _Route Persistence_
+ * Deep Linking / Managing the _first load_ problem
+ * When to use Angular Templates vs Template Toolkit Templates
+ * Displaying bib records in the prototype
+