Batch import of staff client prototype work in progress.
Signed-off-by: Bill Erickson <berick@esilibrary.com>
</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]
+
+
</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
</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"/>
<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">
<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"/>
<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">
--- /dev/null
+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.
--- /dev/null
+[%
+ 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 %]
+
+
--- /dev/null
+<!-- 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">×</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>
--- /dev/null
+<div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</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 -->
--- /dev/null
+<!-- 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">×</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>
--- /dev/null
+<!-- 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">×</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>
--- /dev/null
+
+<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>
+
--- /dev/null
+<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>
+
--- /dev/null
+<!-- 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">×</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>
+
--- /dev/null
+<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()">«</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()">»</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">✓</span>
+ <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">✗</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()">✓</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]">✓</span>
+ </td>
+ <td ng-repeat="field in pageList.allColumns"
+ ng-show="pageList.displayColumns[field.name]">
+ {{rec[field.name]}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
--- /dev/null
+
+<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()">«</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()">»</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">✓</span>
+ <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">✗</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()">✓</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]">✓</span>
+ </td>
+ <td ng-repeat="field in pageList.allColumns"
+ ng-show="pageList.displayColumns[field.name]">
+ {{rec[field.name]}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
--- /dev/null
+<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()">«</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()">»</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">✓</span>
+ <span ng-if="!pageList.displayColumns[col.name]" class="label label-warning">✗</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()">✓</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]">✓</span>
+ </td>
+ <td ng-repeat="field in pageList.allColumns"
+ ng-show="pageList.displayColumns[field.name]">
+ {{rec[field.name]}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
--- /dev/null
+[%
+ 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 %]
--- /dev/null
+<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' %]
--- /dev/null
+
+[%
+# 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>
--- /dev/null
+<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">×</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>
--- /dev/null
+<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' %]
--- /dev/null
+<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()">«</button>
+
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : !holds.hasNextPage()}"
+ ng-click="holds.incrementPage();draw()">»</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>
+
--- /dev/null
+<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()">✓</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]">✓</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>
--- /dev/null
+<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' %]
--- /dev/null
+<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()">«</button>
+
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : !items_out.hasNextPage()}"
+ ng-click="items_out.incrementPage();draw()">»</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>
+
--- /dev/null
+<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()">✓</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()]">✓</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>
--- /dev/null
+<!-- 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">×</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>
--- /dev/null
+
+<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')">×</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>
+
+
--- /dev/null
+<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()">«</button>
+
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : !patrons.hasNextPage()}"
+ ng-click="patrons.incrementPage();draw()">»</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>
+
--- /dev/null
+<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()">✓</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()]">✓</span>
+ <td ng-repeat="col in patrons.allColumns"
+ ng-show="patrons.displayColumns[col.name]">
+ {{patrons.fieldValue(user, col.name)}}
+ </td>
+ </tr>
+ </tbody>
+</table>
+
--- /dev/null
+
+<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>
--- /dev/null
+[%
+ 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 %]
+
--- /dev/null
+
+[%#
+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">✓</span>
+ <span ng-if="![% listname %].displayColumns[col.name]"
+ class="label label-warning">✗</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>
+
--- /dev/null
+<!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>
--- /dev/null
+<!-- 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>
--- /dev/null
+<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>
--- /dev/null
+<!--
+ 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>
+
+
--- /dev/null
+<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>
--- /dev/null
+/* --------------------------------------------------------------------------
+ * 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;
+}
--- /dev/null
+/**
+ * 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');
+ }
+]);
+
--- /dev/null
+/**
+ * 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();
+ }
+}])
--- /dev/null
+/*
+ * 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);
+}])
+
--- /dev/null
+/**
+ * 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'];
--- /dev/null
+/* 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;
+}]);
+
--- /dev/null
+
+/**
+ * egCoreMod houses all of the services, etc. required by all pages
+ * for basic functionality.
+ */
+angular.module('egCoreMod', ['ngCookies']);
--- /dev/null
+/**
+ * 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;
+}]);
+
+
+
--- /dev/null
+/**
+ * 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;
+ }
+ }
+});
+
--- /dev/null
+/**
+ * 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'
+ }
+ });
+ }
+ };
+}])
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+/**
+ * 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)
+ }
+ };
+}]);
+
--- /dev/null
+/**
+ * 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;
+}]);
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+/**
+ * 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();
+ };
+})
--- /dev/null
+/**
+ * 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;
+}]);
+
--- /dev/null
+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
+