Thumbs.db
/js*
/JavaScript*
+Open-ILS/web/js/ui/default/staff/build/
+Open-ILS/web/js/ui/default/staff/node_modules/
+Open-ILS/web/js/ui/default/staff/bower_components/
# Wrap a url to open in a new tab in staff client.
MACRO opac_wrap(url) BLOCK;
- IF ctx.is_staff;
+ IF ctx.is_staff AND NOT ctx.is_browser_staff;
# void(0) to return false and not go to new page in current tab.
"javascript:xulG.new_tab(xulG.urls.XUL_OPAC_WRAPPER, {}, {'opac_url' : 'oils://remote" _ url _ "'});void(0);";
ELSE;
<td headers='copy_header_barcode' property="serialNumber">
[% copy_info.barcode | html -%]
[% IF ctx.is_staff %]
+ [%- IF ctx.is_browser_staff %]
+ <a target="_top" href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]">[% l('view') %]</a>
+ [% IF ctx.has_perm('UPDATE_COPY', copy_info.circ_lib)
+ OR ctx.has_perm('UPDATE_COPY', copy_info.call_number_owning_lib) %]
+ <span> | </span>
+ <!-- XXX: copy edit is not yet supported in browser client.
+ Enable this link when available
+ -->
+ <!--
+ <a href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]/edit">[% l('edit') %]</a>
+ -->
+ [% END %]
+ [% ELSE %]
<a onclick="xulG.new_tab(xulG.urls.XUL_COPY_STATUS, {}, {'from_item_details_new': true, 'barcodes': ['[%- copy_info.barcode | html | replace('\'', '\\\'') -%]']})"
href="javascript:;">[% l('view') %]</a>
[%# if the user can edit copies, show the copy edit link %]
[% l(' edit') %]
</a>
[% END %]
+ [% END %]
[% END %]
[%- IF attrs.gtin13;
'<meta property="gtin13" content="' _ attrs.gtin13 _ '" />';
--- /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
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <label class="input-group-addon"
+ for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+ <input
+ focus-me="selectMe"
+ select-me="selectMe"
+ class="form-control barcode"
+ ng-model="args.barcode"
+ placeholder="[% l('Patron Barcode') %]"
+ id="patron-lookup-barcode" type="text"/>
+
+ <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+ </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+ [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("User Permission Editor");
+ ctx.page_app = "egUserPermsEditor";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/user_perms.js"></script>
+[% END %]
+
+<script type="text/ng-template" id="user-perms-template">
+ <eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
+</script>
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Workstation Administration");
+ ctx.page_app = "egWorkstationAdmin";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/workstation/app.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+ s.PREFS_REMOVE_KEY_CONFIRM =
+ '[% l('Delete content for key "[_1]"?', '{{deleteKey}}') %]';
+ s.DEFAULT_WS_LABEL = '[% l('[_1] (Default)', '{{ws}}') %]';
+ s.WS_EXISTS = '[% l("Workstation name already exists. Use it anyway?") %]';
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+<div class="container" id="admin-workstation-printing">
+
+ <style>
+ /* TODO: more context and move me */
+ textarea {
+ height: 400px;
+ width: 100%;
+ }
+ .tab-pane .row {
+ padding-top: 20px;
+ }
+ h2 { margin-bottom: 15px }
+
+ </style>
+
+ <div class="row">
+ <div class="col-md-12">
+ <h2>[% l('Printer Settings for Remote Printing') %]</h2>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-12">
+ <ul class="nav nav-tabs">
+ <li ng-class="{active : context == 'default'}">
+ <a href='' ng-click="setContext('default')">[% l('Default') %]</a>
+ </li>
+ <li ng-class="{active : context == 'receipt'}">
+ <a href='' ng-click="setContext('receipt')">[% l('Receipt') %]</a>
+ </li>
+ <li ng-class="{active : context == 'label'}">
+ <a href='' ng-click="setContext('label')">[% l('Label') %]</a>
+ </li>
+ <li ng-class="{active : context == 'mail'}">
+ <a href='' ng-click="setContext('mail')">[% l('Mail') %]</a>
+ </li>
+ <li ng-class="{active : context == 'offline'}">
+ <a href='' ng-click="setContext('offline')">[% l('Offline') %]</a>
+ </li>
+ <li ng-class="{active : isTestView}" class="pull-right">
+ <a href='' ng-click="isTestView=true">[% l('Test Printing') %]</a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane active">
+
+ <!-- printer config UI -->
+ <div class="row" ng-hide="isTestView">
+ <div class="col-md-6">
+ <div class="input-group">
+ <div class="input-group-btn" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ [% l('Select Printer') %]
+ <span class="caret"></span></button>
+ <ul class="dropdown-menu">
+ <li ng-repeat="printer in printers">
+ <a href='' ng-click="setPrinter(printer.name)">
+ {{printer.name}}
+ </a>
+ </li>
+ </ul>
+ </div><!-- /btn-group -->
+ <input ng-if="!printers[0]" type="text"
+ class="form-control" disabled="disabled"
+ value="[% l('No Printers Found') %]">
+ <input ng-if="printers[0] && !printConfig[context]" type="text"
+ class="form-control" disabled="disabled"
+ value="[% l('No Printer Selected') %]">
+ <input ng-if="printConfig[context].printer" type="text"
+ class="form-control" disabled="disabled"
+ value="{{printConfig[context].printer}}">
+ </div><!-- /input-group -->
+ </div><!-- col -->
+ <div class="col-md-6">
+ <div class="input-group">
+ <div class="input-group-btn">
+ <button type="button"
+ ng-click="configurePrinter()"
+ ng-class="{disabled : actionPending || !printers[0]}"
+ class="btn btn-default btn-success">
+ [% l('Configure Printer') %]
+ </button>
+ <button type="button"
+ ng-click="resetConfig()"
+ ng-class="{disabled : actionPending}"
+ class="btn btn-default btn-warning">
+ [% l('Reset Configuration') %]
+ </button>
+ </div>
+ </div>
+ </div>
+ </div><!-- row -->
+ <div class="row" ng-hide="isTestView">
+ <div class="col-md-12">
+ <h2>[% l('Compiled Printer Settings') %]</h2>
+ <pre>{{printerConfString()}}</pre>
+ </div><!-- col -->
+ </div><!-- row -->
+
+ <!-- printer test UI -->
+ <div class="row" ng-show="isTestView">
+ <div class="col-md-10">
+ <div class="btn-group">
+ <button type="button"
+ class="btn btn-default btn-lg"
+ ng-class="{active : contentType=='text/plain'}"
+ ng-click="setContentType('text/plain')">[% l('Plain Text') %]</button>
+ <button type="button"
+ class="btn btn-default btn-lg"
+ ng-class="{active : contentType=='text/html'}"
+ ng-click="setContentType('text/html')">[% l('HTML') %]</button>
+ </div>
+ </div>
+ <div class="col-md-2">
+ <div class="input-group pull-right">
+ <div class="input-group-btn">
+ <button type="button"
+ ng-click="testPrint()"
+ class="btn btn-default btn-success">
+ [% l('Print') %]</button>
+ <button type="button"
+ ng-click="testPrint(true)"
+ class="btn btn-default btn-info">
+ [% l('Print with Dialog') %]</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row" ng-show="isTestView">
+ <div class="col-md-12">
+ <div ng-show="contentType=='text/plain'"
+ng-init="textPrintContent='
+[% l('Test Print') %]
+
+1234567890
+
+12345678901234567890
+
+123456789012345678901234567890
+
+1234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890123456790
+
+[% l('Test Print') %]
+'">
+ <pre><textarea>{{textPrintContent}}</textarea></pre>
+ </div>
+
+ <div ng-show="contentType=='text/html'">
+ <textarea ng-model="htmlPrintContent"
+ng-init="htmlPrintContent='
+<div>
+ <style>p { color: blue }</style>
+ <h2>[% l('Test HTML Print') %]</h2>
+ <br/>
+ <img src=\'https://[% ctx.hostname %]/opac/images/main_logo.png\' width=\'140\' height=\'24\'/>
+ <p>[% l('Welcome, Stranger!') %]</p>
+ <p>{{value1}}</p>
+ <p>{{value2}}</p>
+ <p>{{date_value | date}}</p>
+</div>
+'">
+ </textarea>
+ </div><!-- html content -->
+ </div><!-- col -->
+ </div><!-- row -->
+ </div><!-- tab pane -->
+ </div><!-- tab content -->
+ </div><!-- col -->
+ </div><!-- row -->
+</div><!-- container -->
+
--- /dev/null
+<style>
+ /* TODO: move me */
+ .print-template-text {
+ height: 36em;
+ width: 100%;
+ }
+</style>
+
+<h2>[% l('Print Templates') %]</h2>
+
+<div class="row">
+ <div class="col-md-2">[% l('Template Name') %]</div>
+ <div class="col-md-3">
+ <select class="form-control" ng-model="print.template_name" ng-change="template_changed()">
+ <option value="bills_current">[% l('Bills, Current') %]</option>
+ <option value="bills_historical">[% l('Bills, Historical') %]</option>
+ <option value="bill_payment">[% l('Bills, Payment') %]</option>
+ <option value="checkout">[% l('Checkout') %]</option>
+ <option value="hold_transit_slip">[% l('Hold Transit Slip') %]</option>
+ <option value="hold_shelf_slip">[% l('Hold Shelf Slip') %]</option>
+ <option value="holds_for_bibs">[% l('Holds for Bib Record') %]</option>
+ <option value="holds_for_patron">[% l('Holds for Patron') %]</option>
+ <option value="patron_address">[% l('Patron Address') %]</option>
+ <option value="patron_note">[% l('Patron Note') %]</option>
+ <option value="transit_slip">[% l('Transit Slip') %]</option>
+ </select>
+ </div>
+ <div class="col-md-7">
+ <div class="pull-right">
+ <button class="btn btn-default" ng-click="save_locally()">[% l('Save Locally') %]</button>
+ </div>
+ </div>
+ <!-- other stuff -->
+</div>
+
+<hr/>
+
+<div class="row">
+ <div class="col-md-5">
+ <h3>[% l('Preview') %]</h3>
+ <div eg-print-template-output
+ content="print.template_content"
+ context="preview_scope"></div>
+ </div>
+ <div class="col-md-7">
+ <h3>[% l('Template') %]</h3>
+ <div ng-if="print.load_failed" class="alert alert-danger">
+ [% l(
+ "Unable to load template '[_1]'. The web server returned an error.",
+ '{{print.template_name}}')
+ %]
+ </div>
+ <div>
+ <textarea ng-model="print.template_content" class="print-template-text">
+ </textarea>
+ </div>
+ </div> <!-- col -->
+</div>
+
--- /dev/null
+<br/>
+<style>
+ #admin-workstation-container .row {
+ margin-top: 5px;
+ }
+ #admin-workstation-container .new-entry {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 2px solid #F5F5F5;
+ }
+</style>
+
+<div class="container" id="admin-workstation-container">
+
+ <div class="row">
+ <div class="col-md-6">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" ng-class="{disabled : !userHasAdminPerm}"
+ ng-model="hatchRequired" ng-change="updateHatchRequired()">
+[% l('This workstation uses a remote print / storage service ("Hatch")?') %]
+ </label>
+ </div>
+ </div><!-- row -->
+ </div>
+ <div class="row">
+ <div class="col-md-6">
+ <input type='text' class='form-control'
+ ng-disabled="!hatchRequired || !userHasRegPerm"
+ title="[% l('Hatch URL') %]"
+ placeholder="[% l('Hatch URL') %]"
+ ng-change='updateHatchURL()' ng-model='hatchURL'/>
+ </div>
+ </div>
+
+ <div class="row new-entry">
+ <div class="col-md-6">
+ [% l('Workstations Registered With This Computer') %]
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-6">
+ <select class="form-control" ng-model="selectedWS">
+ <option ng-repeat="ws in workstations" value="{{ws}}"
+ ng-selected="ws == selectedWS">
+ {{getWSLabel(ws)}}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-6">
+ <button class="btn btn-default" ng-click="useWS()">
+ [% l('Use Now') %]
+ </button>
+ <button class="btn btn-default" ng-click="setDefaultWS()">
+ [% l('Mark As Default') %]
+ </button>
+ <button class="btn btn-default btn-danger disabled">
+ [% l('Delete') %]
+ </button>
+ </div>
+ </div>
+
+ <div class="row new-entry">
+ <div class="col-md-6">
+ [% l('Register a New Workstation For This Computer') %]
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-6">
+ <div class="input-group">
+ <div class="input-group-btn">
+ <eg-org-selector
+ selected="contextOrg"
+ hidden-test="wsOrgHidden">
+ </eg-org-selector>
+ </div>
+ <input type='text' class='form-control'
+ title="[% l('Workstation Name') %]"
+ placeholder="[% l('Workstation Name') %]"
+ ng-model='newWSName'/>
+ <div class="input-group-btn">
+ <button class="btn btn-default" ng-click="registerWS()">
+ [% l('Register') %]
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row new-entry">
+ <div class="col-md-6">
+ <span class="glyphicon glyphicon-print"></span>
+ <a target="_self" href="./admin/workstation/print/config">
+ [% l('Printer Settings') %]
+ </a>
+ </div>
+ </div>
+
+ <div class="row new-entry">
+ <div class="col-md-6">
+ <span class="glyphicon glyphicon-film"></span>
+ <a target="_self" href="./admin/workstation/print/templates">
+ [% l('Print Templates') %]
+ </a>
+ </div>
+ </div>
+
+ <div class="row new-entry">
+ <div class="col-md-6">
+ <span class="glyphicon glyphicon-info-sign"></span>
+ <a target="_self" href="./admin/workstation/stored_prefs">
+ [% l('Stored Preferences') %]
+ </a>
+ </div>
+ </div>
+
+</div>
--- /dev/null
+<style>
+ /* TODO */
+ #stored-prefs-container .selected {
+ background-color: #F5F5F5;
+ }
+ #stored-prefs-container .row {
+ padding-top: 10px;
+ }
+</style>
+<div class="container" id="stored-prefs-container">
+ <div class="row">
+ <div class="col-md-12">
+ <h2>[% l('Stored User Preferences') %]</h2>
+ <div class="well">
+[% |l %]
+Preference values are stored as JSON strings.
+Click on a preference to view the stored value.
+Click on the delete (X) button to remove a preference's value.
+[% END %]
+ </div>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-4">
+
+ <ul class="nav nav-tabs">
+ <li ng-class="{active : context == 'local'}">
+ <a href='' ng-click="setContext('local')">[% l('Local Prefs') %]</a>
+ </li>
+ <li ng-class="{active : context == 'remote'}">
+ <a href='' ng-click="setContext('remote')">[% l('Remote Prefs') %]</a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane active">
+
+ <div class="row" ng-repeat="key in keys[context]">
+ <div class="col-md-1">{{$index + 1}}.</div>
+ <div class="col-md-8 stored-prefs-key"
+ ng-class="{selected : currentKey == key}">
+ <a href='' ng-click="selectKey(key)">{{key}}</a>
+ </div>
+ <div class="col-md-1">
+ <!-- padding to give the buttom some overflow space -->
+ </div>
+ <div class="col-md-1" class="stored-prefs-remove-button">
+ <button class="btn btn-default btn-danger"
+ ng-class="{disabled : !userHasDeletePerm}"
+ ng-click="removeKey(key)" title="[% l('Remove Item') %]">
+ <span class="glyphicon glyphicon-remove"></span>
+ </button>
+ </div>
+ </div><!-- row -->
+
+ </div><!-- tab pane -->
+ </div><!-- tab content -->
+ </div><!-- col -->
+
+ <div class="col-md-8">
+ <pre>{{getCurrentKeyContent()}}</pre>
+ </div><!-- col -->
+
+ </div><!-- row -->
+</div><!-- container -->
--- /dev/null
+<!doctype html>
+[%- PROCESS 'staff/config.tt2' %]
+<html lang="[% ctx.locale %]"
+ [%- IF ctx.page_app %] ng-app="[% ctx.page_app %]"[% END -%]
+ [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]>
+ <head>
+ <title>[% l('Evergreen Staff [_1]', ctx.page_title) %]</title>
+ <base href="/eg/staff/">
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ [% IF EXPAND_WEB_IMPORTS %]
+ <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/bootstrap.min.css" />
+ <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/hotkeys.min.css" />
+ [% ELSE %]
+ <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/evergreen-staff-client-deps.[% EVERGREEN_VERSION %].min.css" />
+ [% END %]
+ <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/style.css" />
+ <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/print.css" type="text/css" media="print" />
+ </head>
+ <body>
+
+ <!-- load the navbar template inline since it's used on every page -->
+ <script type="text/ng-template" id="eg-navbar-template">
+ [% INCLUDE "staff/navbar.tt2" %]
+ </script>
+
+ <!-- instantiate the navbar by invoking it's name -->
+ <eg-navbar></eg-navbar>
+
+ <!-- main page content goes here -->
+ <div id="top-content-container" class="container">[% content %]</div>
+
+ [%
+ # status bar along bottom of page
+ INCLUDE "staff/statusbar.tt2";
+
+ # script imports
+ INCLUDE "staff/base_js.tt2";
+
+ # App-specific JS load commands go into an APP_JS block.
+ PROCESS APP_JS;
+ %]
+
+ <!-- content printed via the browser is inserted here for
+ DOM-ification prior to delivery to the printer -->
+ <div id="print-div" eg-print-container></div>
+ </body>
+</html>
--- /dev/null
+<script src="/IDL2js"></script>
+
+[% IF EXPAND_WEB_IMPORTS %]
+
+<!-- angular -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-route.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/ui-bootstrap-tpls.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/hotkeys.min.js"></script>
+
+<!-- IDL / opensrf (network) -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_ws.js"></script>
+
+<!-- evergreen core services -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/strings.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/event.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/net.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/env.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/org.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/hatch.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/print.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/coresvc.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/statusbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+
+[% ELSE %]
+
+<!-- concatenated, minified version of all of the above -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/evergreen-staff-client.[% EVERGREEN_VERSION %].min.js"></script>
+
+[% END %]
+
+<script>
+ // Configure OpenSRF
+ // pending api_level thunking in C
+ // OpenSRF.api_level = 2;
+ OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+</script>
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Record Buckets");
+ ctx.page_app = "egCatRecordBuckets";
+ ctx.page_ctrl = "RecordBucketCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/bucket/record/app.js"></script>
+[% END %]
+
+<!-- using native Bootstrap taps because of limitations
+with angular-ui tabsets. it always defaults to making the
+first tab active, so it can't be driven from the route
+https://github.com/angular-ui/bootstrap/issues/910
+No JS is needed to drive the native tabs, since we're
+changing routes with each tab selection anyway.
+-->
+
+<ul class="nav nav-tabs">
+ <li ng-class="{active : tab == 'search'}">
+ <a href="./cat/bucket/record/search/{{bucketSvc.currentBucket.id()}}">
+ [% l('Record Query') %]
+ <span ng-cloak>({{bucketSvc.queryRecords.length}})</span>
+ </a>
+ </li>
+ <li ng-class="{active : tab == 'pending'}">
+ <a href="./cat/bucket/record/pending/{{bucketSvc.currentBucket.id()}}">
+ [% l('Pending Records') %]
+ <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+ </a>
+ </li>
+ <li ng-class="{active : tab == 'view'}">
+ <a href="./cat/bucket/record/view/{{bucketSvc.currentBucket.id()}}">
+ [% l('Bucket View') %]
+ <span ng-cloak>({{bucketSvc.currentBucket.items().length}})</span>
+ </a>
+ </li>
+</ul>
+<div class="tab-content">
+ <div class="tab-pane active">
+
+ <!-- bucket info header -->
+ <div class="row">
+ <div class="col-md-6">
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %]
+ </div>
+ </div>
+
+ <!-- bucket not accessible warning -->
+ <div class="col-md-10 col-md-offset-1" ng-show="forbidden">
+ <div class="alert alert-warning">
+ [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %]
+ </div>
+ </div>
+
+ <div ng-view></div>
+ </div>
+</div>
+
+[% END %]
+
+
--- /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>
+ <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 -->
+</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>
+ <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 -->
+</form>
--- /dev/null
+<!-- export bucket dialog -->
+<form ng-submit="ok(args)">
+ <div>
+ <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 -->
+</form>
--- /dev/null
+
+<div ng-show="bucket()">
+ <strong>[% l('Bucket: {{bucket().name()}}') %]</strong>
+ <span>
+ <ng-pluralize count="bucketSvc.currentBucket.items().length"
+ when="{'one': '[% l("1 item") %]', 'other': '[% l("{} items") %]'}">
+ </ng-pluralize>
+ </span>
+ <span> / [% l('Created {{bucket().create_time() | date}}') %]</span>
+ <span ng-show="bucket().description()"> / {{bucket().description()}}</span>
+</div>
+
+<div ng-show="!bucket()">
+ <strong>[% l('No Bucket Selected') %]</strong>
+</div>
+
--- /dev/null
+<div class="btn-group text-left" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ [% l('Buckets') %]<span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li>
+ <a href='' ng-click="openCreateBucketDialog()">[% l('New Bucket') %]</a>
+ </li>
+ <li ng-class="{disabled : !bucket()}">
+ <a href='' ng-click="openEditBucketDialog()">[% l('Edit Bucket') %]</a>
+ </li>
+ <li ng-class="{disabled : !bucket()}">
+ <a href='' ng-click="openDeleteBucketDialog()">[% l('Delete Bucket') %]</a>
+ </li>
+ <li>
+ <a href='' ng-click="openSharedBucketDialog()">[% l('Load Shared Bucket') %]</a>
+ </li>
+ <li role="presentation" class="divider"></li>
+
+ <!-- list all of this user's buckets -->
+ <li ng-repeat="bkt in bucketSvc.allBuckets"
+ ng-class="{disabled : bkt.id() == bucket().id()}">
+ <a href='' ng-click="loadBucket(bkt.id())">{{bkt.name()}}</a>
+ </li>
+ </ul>
+</div>
+
--- /dev/null
+
+<!-- global grid menu displayed on every Bucket page -->
+<eg-grid-menu-item label="[% l('New Bucket') %]"
+ handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Edit Bucket') %]"
+ handler="openEditBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Delete Bucket') %]"
+ handler="openDeleteBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Shared Bucket') %]"
+ handler="openSharedBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item divider="true"></eg-grid-menu-item>
+
+<eg-grid-menu-item ng-repeat="bkt in bucketSvc.allBuckets"
+ label="{{bkt.name()}}" handler-data="bkt"
+ handler="loadBucketFromMenu"></eg-grid-menu-item>
+
--- /dev/null
+<!-- load bucket by id ("shared") -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+ <div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Load Shared Bucket Bucket by ID') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="load-bucket-id">[% l('Bucket ID') %]</label>
+ <!-- NOTE: type='number' / required -->
+ <input type="number" class="form-control" focus-me='focusMe' required
+ id="load-bucket-id" ng-model="args.id" placeholder="[% l('Bucket ID...') %]"/>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" ng-disabled="form.$invalid"
+ class="btn btn-primary" value="[% l('Load Bucket') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+</form>
+
--- /dev/null
+<eg-grid
+ ng-hide="forbidden"
+ features="-sort,-multisort"
+ id-field="id"
+ idl-class="rmsr"
+ auto-fields="true"
+ items-provider="gridDataProvider"
+ menu-label="[% l('Buckets') %]"
+ persist-key="cat.bucket.record.pending">
+
+ [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+ <!-- actions drop-down -->
+ <eg-grid-action label="[% l('Add To Bucket') %]"
+ handler="addToBucket"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Clear List') %]"
+ handler="clearPendingList"></eg-grid-action>
+
+</eg-grid>
--- /dev/null
+<br/>
+
+<!-- search bar -->
+<div class="row">
+ <div class="col-md-6">
+ <form ng-submit="search()">
+ <div class="input-group">
+ <span class="input-group-addon">[% l('Record Query') %]</span>
+ <input type="text" class="form-control" focus-me="focusMe"
+ ng-model="bucketSvc.queryString" placeholder="[% l('Query...') %]">
+ </div>
+ </form>
+ </div>
+</div>
+<br/>
+<div class="row" ng-show="searchInProgress">
+ <div class="col-md-6">
+ <div class="progress progress-striped active">
+ <div class="progress-bar" role="progressbar" aria-valuenow="100"
+ aria-valuemin="0" aria-valuemax="100" style="width: 100%">
+ <span class="sr-only">[% l('Searching...') %]</span>
+ </div>
+ </div>
+ </div>
+</div>
+
+
+<eg-grid
+ ng-hide="forbidden"
+ id-field="id"
+ idl-class="rmsr"
+ auto-fields="true"
+ grid-controls="gridControls"
+ menu-label="[% l('Buckets') %]"
+ persist-key="cat.bucket.record.search">
+
+ [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+ <!-- actions drop-down -->
+ <eg-grid-action label="[% l('Add To Pending') %]"
+ handler="addToPending"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Add To Bucket') %]"
+ handler="addToBucket"></eg-grid-action>
+
+</eg-grid>
--- /dev/null
+<eg-grid
+ ng-hide="forbidden"
+ id-field="id"
+ idl-class="rmsr"
+ auto-fields="true"
+ grid-controls="gridControls"
+ menu-label="[% l('Buckets') %]"
+ persist-key="cat.bucket.record.view">
+
+ [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+ <!-- actions drop-down -->
+ <eg-grid-action label="[% l('Remove Selected Records') %]"
+ handler="detachRecords"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Export Records') %]"
+ handler="openExportBucketDialog"></eg-grid-action>
+
+ <eg-grid-field path="id" required hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path="title">
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.id}}">
+ {{item.title}}
+ </a>
+ </eg-grid-field>
+
+
+</eg-grid>
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Catalog");
+ ctx.page_app = "egCatalogApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/catalog/app.js"></script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
--- /dev/null
+
+<div class="row pad-vert">
+ <div class="col-md-9">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span ng-if="record_tab == 'catalog'">[% l('Catalog') %]</span>
+ <span ng-if="record_tab == 'marc_html'">[% l('MARC HTML') %]</span>
+ <span ng-if="record_tab == 'holds'">[% l('Holds for Record') %]</span>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <!-- actions for this record menu -->
+ <div class="btn-group pull-right" dropdown>
+ <button type="button"
+ class="btn btn-default dropdown-toggle" ng-disabled="!record_id">
+ [% l('Actions for This Record') %]
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right" role="menu">
+ <li><a href dropdown-toggle ng-click="set_record_tab('catalog')">
+ [% l('OPAC View') %]</a></li>
+ <li><a href dropdown-toggle ng-click="set_record_tab('marc_html')">
+ [% l('MARC View') %]</a></li>
+ <li class="divider"></li>
+ <li><a href dropdown-toggle ng-click="set_record_tab('holds')">
+ [% l('View Holds') %]</a></li>
+ <li><a href dropdown-toggle ng-click="mark_hold_transfer_dest()">
+ [% l('Mark as Title Hold Transfer Destination') %]</a></li>
+ <li><a href dropdown-toggle ng-click="transfer_holds_to_marked()">
+ [% l('Transfer All Title Holds') %]</a></li>
+ </ul>
+ </div>
+ </div>
+</div>
+
+<div>
+ <!-- ng-show allows the catalog iframe to stay loaded (unlike ng-if) -->
+ <div ng-show="record_tab == 'catalog'">
+ <eg-embed-frame url="catalog_url" handlers="handlers" onchange="handle_page"></eg-embed-frame>
+ </div>
+ <!-- ng-if the remaining tabs so they can be instantiated on demand -->
+ <div ng-if="record_tab == 'marc_html'">
+ <eg-record-html record-id="record_id"></eg-record-html>
+ </div>
+ <div ng-if="record_tab == 'holds'">
+ [% INCLUDE 'staff/cat/catalog/t_holds.tt2' %]
+ </div>
+</div>
+
--- /dev/null
+
+<div ng-if="!detail_hold_id">
+ <div class="row">
+ <div class="col-md-3">
+ <div class="input-group">
+ <span class="input-group-addon">[% l('Pickup Library') %]</span>
+ <eg-org-selector selected="pickup_ou" onchange="pickup_ou_changed"></eg-org-selector>
+ </div>
+ </div>
+ </div>
+ <div class="pad-vert"></div>
+
+ <eg-grid
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="hold_grid_data_provider"
+ grid-controls="hold_grid_controls"
+ persist-key="cat.catalog.holds">
+
+ <eg-grid-menu-item handler="detail_view"
+ label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+ <eg-grid-action handler="grid_actions.show_recent_circs"
+ label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_copy_quality"
+ label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_pickup_lib"
+ label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_notify_prefs"
+ label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_dates"
+ label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.activate"
+ label="[% l('Activate') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.suspend"
+ label="[% l('Suspend') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_top_of_queue"
+ label="[% l('Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.clear_top_of_queue"
+ label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+ label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_damaged"
+ label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_missing"
+ label="[% l('Mark Item Missing') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.retarget"
+ label="[% l('Find Another Target') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.cancel_hold"
+ label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+ <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+ <eg-grid-field label="[% l('Current Copy') %]"
+ path='hold.current_copy.barcode'>
+ <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+ {{item.hold.current_copy().barcode()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+ <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+ {{item.mvr.title()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+ <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+ <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+ <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+ <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+ </eg-grid>
+
+ <div class="flex-row pad-vert">
+ <div class="flex-cell"></div>
+ <div>
+ <button class="btn btn-default" ng-click="print_holds()">
+ [% l('Print') %]
+ </button>
+ </div>
+ </div>
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+ <div class="row">
+ <div class="col-md-2">
+ <button class="btn btn-default" ng-click="list_view()">
+ [% l('List View') %]
+ </button>
+ </div>
+ </div>
+ <div class="pad-vert"></div>
+ <eg-record-summary record='detail_hold_record'
+ record-id="detail_hold_record_id"></eg-record-summary>
+ <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Item Status");
+ ctx.page_app = "egItemStatus";
+ ctx.page_ctrl = "SearchCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.js"></script>
+[% END %]
+
+<style>
+ /* FIXME: MOVE ME */
+ #item-status-barcode {width: 16em;}
+ #item-status-form {
+ margin-bottom: 20px;
+ }
+</style>
+
+<h1 class="sr-only">[% l('Item Status Display') %]</h1>
+
+<h2>[% l('Scan Item') %]</h2>
+
+<form id="item-status-form" ng-submit="context.search(args)" role="form">
+ <!-- the upload button drops down to the line below when it sits in the
+ same col-md-x as the text input and submit. avoid by using a flex-row -->
+ <div class="flex-row">
+ <div class="input-group">
+ <input type="text" id="item-status-barcode" class="form-control"
+ select-me="context.selectBarcode" ng-model="args.barcode">
+ <input class="btn btn-default"
+ type="submit" value="[% l('Submit') %]"/>
+ </div>
+ <!-- give the upload container div some padding to prevent force the
+ upload widget into the vertical middle of the row -->
+ <div class="btn-pad" style="padding:4px;">
+ <div class="flex-row">
+ <div class="strong-text">[% l('OR') %]</div>
+ <div class="btn-pad">
+ <input type="file" eg-file-reader
+ container="barcodesFromFile" value="[% l('Upload from File') %]">
+ </div>
+ </div>
+ </div>
+ <div class="flex-cell"></div><!-- force the final divs to the right -->
+ <div>
+ <button class="btn btn-default" ng-click="toggleView($event)">
+ <span ng-show="context.page == 'list'">[% l('Detail View') %]</span>
+ <span ng-show="context.page == 'detail'">[% l('List View') %]</span>
+ </button>
+ </div>
+ <!--
+ <div class="btn-group btn-pad" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ [% l('Actions for Catalogers') %]<span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu" role="menu">
+ </ul>
+ </div>
+ -->
+ </div><!-- flex row -->
+</form>
+
+
+<div class="row">
+ <div class="col-md-6">
+ <div ng-show="context.itemNotFound" class="alert alert-danger">
+ [% l('Item Not Found') %]
+ </div>
+ </div>
+</div>
+
+<div ng-view></div>
+
+[% END %]
+
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Scan Item as Missing Pieces");
+ ctx.page_app = "egItemMissingPieces";
+ ctx.page_ctrl = "MissingPiecesCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/missing_pieces.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.CONFIRM_MARK_MISSING_TITLE = "[% l('Mark item as missing pieces?') %]";
+s.CONFIRM_MARK_MISSING_BODY =
+ "[% l('[_1] / [_2]', '{{barcode}}', '{{title}}') %]";
+s.CIRC_NOT_FOUND =
+ "[% l('No circulation found for item with barcode [_1]. Item not modified.', '{{barcode}}') %]"
+}])
+</script>
+
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span>[% l('Scan Item as Missing Pieces') %]</span>
+ </div>
+</div>
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <label class="input-group-addon"
+ for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+ <input
+ focus-me="selectMe"
+ select-me="selectMe"
+ class="form-control barcode"
+ ng-model="args.barcode"
+ placeholder="[% l('Patron Barcode') %]"
+ id="patron-lookup-barcode" type="text"/>
+
+ <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+ </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+ [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+<hr/>
+
+<div ng-show="letter">
+ <div class="row">
+ <div class="col-md-2">
+ <button ng-click="print_letter()" class="btn btn-default">[% l('Print Letter') %]</button>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-6">
+ <textarea ng-model="letter" rows="25" style="width:100%"></textarea>
+ </div>
+ </div>
+</div>
+
+[% END %]
+
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Replace Item Barcode");
+ ctx.page_app = "egItemReplaceBarcode";
+ ctx.page_ctrl = "ReplaceItemBarcodeCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/replace_barcode/app.js"></script>
+[% END %]
+
+<h2>[% l('Replace Item Barcode') %]</h2>
+
+<div class="row">
+ <div class="col-md-6 pad-vert">
+ <form role="form" ng-submit="updateBarcode()">
+ <div class="form-group">
+ <label for="barcode1">[% l('Enter Original Barcode for Item') %]</label>
+ <input type="text" class="form-control" id="barcode1" required
+ ng-model="barcode1"
+ placeholder="[% l('Original Barcode...') %]" select-me="focusBarcode">
+ </div>
+ <div class="form-group">
+ <label for="barcode2">[% l('Enter New Barcode for Item') %]</label>
+ <input type="text" class="form-control" id="barcode2"
+ ng-model="barcode2"
+ required placeholder="[% l('New Barcode...') %]">
+ </div>
+ <button type="submit" class="btn btn-default">[% l('Submit') %]</button>
+ </form>
+ </div>
+</div>
+
+<div class="row pad-vert">
+ <div class="col-md-6">
+ <div class="alert alert-danger" ng-if="copyNotFound">
+ [% l('Copy Not Found') %]
+ </div>
+ <div class="alert alert-success" ng-if="updateOK">
+ <span>[% l('Copy Updated') %]</span>
+ <span class="horiz-pad" ng-if="copyId">
+ <a href="./cat/item/{{copyId}}/summary" target="_self">
+ [% l('View Item Details') %]
+ </a>
+ </div>
+ </div>
+</div>
+[% END %]
--- /dev/null
+<h3>[% l('MARC Record') %]</h3>
+
+<eg-record-html record-id="recordId"></eg-record-html>
--- /dev/null
+<div class="col-md-12" ng-show="!circ_list.length">
+ <div class="alert alert-info">
+ [% l('Item has not circulated.') %]
+ </div>
+</div>
+
+<div class="row" ng-repeat="circ in circ_list">
+ <div class="flex-row">
+ <div class="flex-cell well">
+ <a href="./circ/patron/{{circ.usr().id()}}/checkout" target="_self">
+ [% l('[_1], [_2] [_3] : [_4]',
+ '{{circ.usr().family_name()}}'
+ '{{circ.usr().first_given_name()}}'
+ '{{circ.usr().second_given_name()}}'
+ '{{circ.usr().card().barcode()}}') %]
+ </a>
+ <span class="pad-horiz">[% l('Circulation ID: [_1]', '{{circ.id()}}') %]</span>
+ </div>
+ <div>
+ <button class="btn btn-default" ng-click="addBilling(circ)">
+ [% l('Add Billing') %]
+ </button>
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Check Out Date') %]</div>
+ <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Due Date') %]</div>
+ <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+ <div class="flex-cell well">{{circ.stop_fines_time() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Checkin Time') %]</div>
+ <div class="flex-cell well">{{circ.checkin_time() | date:'short'}}</div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Check Out Library') %]</div>
+ <div class="flex-cell well">{{circ.circ_lib().shortname()}}</div>
+ <div class="flex-cell">[% l('Renewal?') %]</div>
+ <div class="flex-cell well">{{
+ circ.phone_renewal() == 't' ||
+ circ.desk_renewal() == 't' ||
+ circ.opac_renewal() == 't'
+ }}</div>
+ <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+ <div class="flex-cell well">{{circ.stop_fines()}}</div>
+ <div class="flex-cell">[% l('Check In Library') %]</div>
+ <div class="flex-cell well">{{circ.checkin_lib().shortname()}}</div>
+ </div>
+ <hr/>
+</div>
+
--- /dev/null
+<div class="col-md-6" ng-show="!prev_circ_summary">
+ <div class="alert alert-info">
+ [% l('No Previous Circ Group') %]
+ </div>
+</div>
+<div class="col-md-6" ng-show="prev_circ_summary">
+ <div class="flex-row">
+ <div class="flex-cell flex-2 strong-text-2">
+ [% l('Previous Circ Group') %]
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Patron') %]</div>
+ <div class="flex-cell well">
+ <a href="./circ/patron/{{prev_circ_usr.id()}}/checkout"
+ ng-if="prev_circ_summary" target="_self">
+ [% l('[_1], [_2] [_3] : [_4]',
+ '{{prev_circ_usr.family_name()}}'
+ '{{prev_circ_usr.first_given_name()}}'
+ '{{prev_circ_usr.second_given_name()}}'
+ '{{prev_circ_usr.card().barcode()}}') %]
+ </a>
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Total Circs') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.num_circs()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkout Date') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.start_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.checkout_workstation()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Last Renewed On') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_renewal_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_renewal_workstation()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_stop_fines()}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_stop_fines_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Time') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_checkin_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_checkin_scan_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+ <div class="flex-cell well">
+ {{prev_circ_summary.last_checkin_workstation()}}
+ </div>
+ </div>
+</div>
+
+<div class="col-md-6" ng-show="!circ">
+ <div class="alert alert-info">
+ [% l('No Recent Circ Group') %]
+ </div>
+</div>
+<div class="col-md-6" ng-show="circ">
+ <div class="flex-row">
+ <div class="flex-cell flex-2 strong-text-2">
+ [% l('Most Recent Circ Group') %]
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Patron') %]</div>
+ <div class="flex-cell well">
+ <a href="./circ/patron/{{circ.usr().id()}}/checkout"
+ ng-if="circ" target="_self">
+ [% l('[_1], [_2] [_3] : [_4]',
+ '{{circ.usr().family_name()}}'
+ '{{circ.usr().first_given_name()}}'
+ '{{circ.usr().second_given_name()}}'
+ '{{circ.usr().card().barcode()}}') %]
+ </a>
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Total Circs') %]</div>
+ <div class="flex-cell well">
+ {{circ_summary.num_circs()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkout Date') %]</div>
+ <div class="flex-cell well">
+ {{circ.xact_start() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+ <div class="flex-cell well">
+ {{circ.workstation().name()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Last Renewed On') %]</div>
+ <div class="flex-cell well">
+ {{circ_summary.last_renewal_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+ <div class="flex-cell well">
+ {{circ_summary.last_renewal_workstation()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+ <div class="flex-cell well">
+ {{circ.stop_fines()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+ <div class="flex-cell well">
+ {{circ.stop_fines_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Time') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_scan_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_workstation.name()}}
+ </div>
+ </div>
+</div>
+
--- /dev/null
+<div class="col-md-6" ng-show="!hold">
+ <div class="alert alert-info">
+ [% l('Item is not captured for a hold') %]
+ </div>
+</div>
+<div class="col-md-6" ng-show="hold">
+ <div class="flex-row">
+ <div class="flex-cell flex-2 strong-text-2">
+ [% l('Captured Hold Info') %]
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Patron') %]</div>
+ <div class="flex-cell well">
+ <a href="./circ/patron/{{hold.usr().id()}}/checkout"
+ ng-if="hold" target="_self">
+ [% l('[_1], [_2] [_3] : [_4]',
+ '{{hold.usr().family_name()}}'
+ '{{hold.usr().first_given_name()}}'
+ '{{hold.usr().second_given_name()}}'
+ '{{hold.usr().card().barcode()}}') %]
+ </a>
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Requestor') %]</div>
+ <div class="flex-cell well">
+ <a href="./circ/patron/{{hold.requestor().id()}}/checkout"
+ ng-if="hold" target="_self">
+ [% l('[_1], [_2] [_3] : [_4]',
+ '{{hold.requestor().family_name()}}'
+ '{{hold.requestor().first_given_name()}}'
+ '{{hold.requestor().second_given_name()}}'
+ '{{hold.requestor().card().barcode()}}') %]
+ </a>
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Pickup Lib') %]</div>
+ <div class="flex-cell well">
+ {{hold.pickup_lib().shortname()}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+ <div class="flex-cell well">
+ {{hold.current_shelf_lib().shortname()}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Request Date') %]</div>
+ <div class="flex-cell well">
+ {{hold.request_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Capture Date') %]</div>
+ <div class="flex-cell well">
+ {{hold.capture_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Shelf Time') %]</div>
+ <div class="flex-cell well">
+ {{hold.shelf_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Shelf Expire Time') %]</div>
+ <div class="flex-cell well">
+ {{hold.shelf_expire_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Hold Expire Time') %]</div>
+ <div class="flex-cell well">
+ {{hold.expire_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Behind Desk') %]</div>
+ <div class="flex-cell well">
+ {{hold.behind_desk()}}
+ </div>
+ </div>
+</div>
+
+<div class="col-md-6" ng-show="!transit">
+ <div class="alert alert-info">
+ [% l('Item has not transited') %]
+ </div>
+</div>
+
+<div class="col-md-6" ng-show="transit">
+ <div class="flex-row">
+ <div class="flex-cell flex-2 strong-text-2">
+ [% l('Most Recent Transit') %]
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Transit Source') %]</div>
+ <div class="flex-cell well">
+ {{transit.source().shortname()}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Transit Destination') %]</div>
+ <div class="flex-cell well">
+ {{transit.dest().shortname()}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Transit Send Time') %]</div>
+ <div class="flex-cell well">
+ {{transit.source_send_time() | date:'short'}}
+ </div>
+ </div>
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Transit Receive Time') %]</div>
+ <div class="flex-cell well">
+ {{transit.source_recv_time() | date:'short'}}
+ </div>
+ </div>
+</div>
+
--- /dev/null
+<eg-grid
+ id-field="index"
+ idl-class="acp"
+ features="-display,-sort,-multisort"
+ main-label="[% l('Item Status') %]"
+ items-provider="gridDataProvider"
+ grid-controls="gridControls"
+ persist-key="cat.items">
+
+ <eg-grid-field label="[% l('Barcode') %]" path='barcode' visible></eg-grid-field>
+ <eg-grid-field label="[% l('Call Number') %]" path="call_number.label" visible></eg-grid-field>
+ <eg-grid-field label="[% l('Location') %]" path="location.name" visible></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]"
+ path="call_number.record.simple_record.title" visible>
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['call_number.record.id']}}">
+ {{item['call_number.record.simple_record.title']}}
+ </a>
+ </eg-grid-field>
+</eg-grid>
+
--- /dev/null
+<style>
+/* FIXME: move me */
+#item-status-alert-msg {
+ flex:7; /* fill the remaining horizontal space */
+}
+</style>
+
+<div class="">
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Barcode') %]</div>
+ <div class="flex-cell well">{{copy.barcode()}}</div>
+
+ <div class="flex-cell">[% l('Circ Library') %]</div>
+ <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+ <div class="flex-cell">[% l('Call # Prefix') %]</div>
+ <div class="flex-cell well">
+ {{copy.call_number().prefix().label()}}
+ </div>
+
+ <div class="flex-cell">[% l('Status') %]</div>
+ <div class="flex-cell well">{{copy.status().name()}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Price') %]</div>
+ <div class="flex-cell well">{{copy.price()}}</div>
+
+ <div class="flex-cell">[% l('Owning Library') %]</div>
+ <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+ <div class="flex-cell">[% l('Call #') %]</div>
+ <div class="flex-cell well">{{copy.call_number().label()}}</div>
+
+ <div class="flex-cell">[% l('Due Date') %]</div>
+ <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('ISBN') %]</div>
+ <div class="flex-cell well">
+ {{copy.call_number().record().simple_record().isbn()}}
+ </div>
+
+ <div class="flex-cell">[% l('Copy Location') %]</div>
+ <div class="flex-cell well">{{copy.location().name()}}</div>
+
+ <div class="flex-cell">[% l('Call # Suffix') %]</div>
+ <div class="flex-cell well">
+ {{copy.call_number().suffix().label()}}
+ </div>
+
+ <div class="flex-cell">[% l('Checkout Date') %]</div>
+ <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Date Created') %]</div>
+ <div class="flex-cell well">{{copy.create_date() | date:'short'}}</div>
+
+ <div class="flex-cell">[% l('Loan Duration') %]</div>
+ <div class="flex-cell well">{{circ.duration()}}</div>
+
+ <div class="flex-cell">[% l('Renewal Type') %]</div>
+ <div class="flex-cell well">
+ <div ng-if="circ.opac_renewal() == 't'">[% l('OPAC') %]</div>
+ <div ng-if="circ.desk_renewal() == 't'">[% l('Desk') %]</div>
+ <div ng-if="circ.phone_renewal() == 't'">[% l('Phone') %]</div>
+ </div>
+
+ <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+ <div class="flex-cell well">{{circ.workstation().name()}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Date Active') %]</div>
+ <div class="flex-cell well">{{copy.active_date() | date:'short'}}</div>
+
+ <div class="flex-cell">[% l('Fine Level') %]</div>
+ <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+
+ <div class="flex-cell">[% l('Total Circs') %]</div>
+ <div class="flex-cell well">{{total_circs}}</div>
+
+ <div class="flex-cell">[% l('Duration Rule') %]</div>
+ <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Status Changed') %]</div>
+ <div class="flex-cell well">{{copy.status_changed_time() | date:'short'}}</div>
+
+ <div class="flex-cell">[% l('Reference') %]</div>
+ <div class="flex-cell well">{{copy.ref()}}</div>
+
+ <div class="flex-cell">[% l('Total Circs - Current Year') %]</div>
+ <div class="flex-cell well">{{total_circs_this_year}}</div>
+
+ <div class="flex-cell">[% l('Recurring Fine Rule') %]</div>
+ <div class="flex-cell well">{{circ.recurring_fine_rule().name()}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Copy ID') %]</div>
+ <div class="flex-cell well">{{copy.id()}}</div>
+
+ <div class="flex-cell">[% l('OPAC Visible') %]</div>
+ <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+ <div class="flex-cell">[% l('Total Circs - Prev Year') %]</div>
+ <div class="flex-cell well">{{total_circs_prev_year}}</div>
+
+ <div class="flex-cell">[% l('Max Fine Rule') %]</div>
+ <div class="flex-cell well">{{circ.max_fine_rule().name()}}</div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('TCN') %]</div>
+ <div class="flex-cell well">{{copy.call_number().record().tcn_value()}}</div>
+
+ <div class="flex-cell">[% l('Holdable') %]</div>
+ <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+ <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+ <div class="flex-cell well">{{circ_summary.last_renewal_workstation()}}</div>
+
+ <div class="flex-cell">[% l('Checkin Time') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_time() ||
+ circ_summary.last_checkin_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Floating') %]</div>
+ <div class="flex-cell well">{{copy.floating()}}</div>
+
+ <div class="flex-cell">[% l('Circulate') %]</div>
+ <div class="flex-cell well">{{copy.circulate()}}</div>
+
+ <div class="flex-cell">[% l('Remaining Renewals') %]</div>
+ <div class="flex-cell well">{{circ.renewal_remaining()}}</div>
+
+ <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_scan_time() ||
+ circ_summary.last_checkin_scan_time() | date:'short'}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <!-- empty -->
+ <div class="flex-cell"></div>
+ <div class="flex-cell"></div>
+
+ <div class="flex-cell">[% l('Circ Modifier') %]</div>
+ <div class="flex-cell well">{{copy.circ_modifier().name()}}</div>
+
+ <!-- empty -->
+ <div class="flex-cell"></div>
+ <div class="flex-cell"></div>
+
+ <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+ <div class="flex-cell well">
+ {{circ.checkin_workstation().name() ||
+ circ_summary.last_checkin_workstation().name()}}
+ </div>
+ </div>
+
+ <div class="flex-row">
+ <div class="flex-cell">[% l('Alert Message') %]</div>
+ <div id="item-status-alert-msg" class="well">
+ {{copy.alert_message()}}
+ </div>
+ </div>
+
+</div>
--- /dev/null
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
--- /dev/null
+<eg-record-summary record="summaryRecord"></eg-record-summary>
+
+<!-- tabbed copy data view -->
+
+<div class="pad-vert"></div>
+
+<ul class="nav nav-tabs">
+ <li ng-class="{active : tab == 'summary'}">
+ <a href="./cat/item/{{copy.id()}}">[% l('Quick Summary') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'circs'}">
+ <a href="./cat/item/{{copy.id()}}/circs">[% l('Recent Circ History') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'circ_list'}">
+ <a href="./cat/item/{{copy.id()}}/circ_list">[% l('Circ History List') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'holds'}">
+ <a href="./cat/item/{{copy.id()}}/holds">[% l('Holds / Transit') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'cat'}">
+ <a href="./cat/item/{{copy.id()}}/cat">[% l('Cataloging Info') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'triggered_events'}">
+ <a href="./cat/item/{{copy.id()}}/triggered_events">[% l('Triggered Events') %]</a>
+ </li>
+</ul>
+<div class="tab-content">
+ <div class="tab-pane active">
+ <div ng-if="tab.length">
+ <div ng-include="'[% ctx.base_path %]/staff/cat/item/t_'+tab+'_pane'"></div>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+<div class="strong-text-2">[% l('Record Summary') %]</div>
+
+<div class="flex-container-striped flex-container-bordered">
+ <div class="flex-row">
+ <div class="flex-cell strong-text">[% l('Title:') %]</div>
+ <div class="flex-cell flex-2">
+ <a target="_self"
+ href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.id()}}">
+ {{record.simple_record().title()}}
+ </a>
+ </div>
+
+ <div class="flex-cell strong-text">[% l('Edition:') %]</div>
+ <div class="flex-cell"><!-- FIXME: no edition field on simple record --></div>
+
+ <div class="flex-cell strong-text">[% l('TCN:') %]</div>
+ <div class="flex-cell">{{record.tcn_value()}}</div>
+
+ <div class="flex-cell strong-text">[% l('Created By:') %]</div>
+ <div class="flex-cell">{{record.creator().usrname()}}</div>
+ </div><!-- flex-row -->
+
+ <div class="flex-row">
+ <div class="flex-cell strong-text">[% l('Author:') %]</div>
+ <div class="flex-cell flex-2">{{record.simple_record().author()}}</div>
+
+ <div class="flex-cell strong-text">[% l('Pub Date:') %]</div>
+ <div class="flex-cell">
+ {{record.simple_record().pubdate()}}
+ </div>
+
+ <div class="flex-cell strong-text">[% l('Databse ID:') %]</div>
+ <div class="flex-cell">{{record.id()}}</div>
+
+ <div class="flex-cell strong-text">[% l('Last Edited By:') %]</div>
+ <div class="flex-cell">{{record.editor().usrname()}}</div>
+ </div><!-- flex-row -->
+
+ <div class="flex-row">
+ <div class="flex-cell strong-text">[% l('Bib Call #:') %]</div>
+ <div class="flex-cell flex-2"><!-- FIXME: no bib call no on simple rec --></div>
+
+ <div class="flex-cell strong-text"></div>
+ <div class="flex-cell"></div>
+
+ <div class="flex-cell strong-text">[% l('Record Owner:') %]</div>
+ <div class="flex-cell">{{record.owner().shortname()}}</div>
+
+ <div class="flex-cell strong-text">[% l('Last Edited On:') %]</div>
+ <div class="flex-cell">{{record.edit_date() | date:'short'}}</div>
+ </div><!-- flex-row -->
+</div>
+
--- /dev/null
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Check In");
+ ctx.page_app = "egCheckinApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/checkin/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span ng-if="!is_capture">[% l('Checkin Items') %]</span>
+ <span ng-if="is_capture">[% l('Capture Holds') %]</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <div class="flex-row left-anchored">
+ <div ng-if="is_backdate()" class="alert-danger pad-all-min">
+ [% l('Backdated Check In [_1]',
+ '{{checkinArgs.backdate | date:"shortDate"}}') %]
+ </div>
+ <div ng-if="modifiers.no_precat_alert" class="alert-danger pad-all-min">
+ [% l('Ignore Pre-Cataloged Items') %]
+ </div>
+ <div ng-if="modifiers.noop" class="alert-danger pad-all-min">
+ [% l('Suppress Holds and Transits') %]
+ </div>
+ <div ng-if="modifiers.void_overdues" class="alert-danger pad-all-min">
+ [% l('Amnesty Mode') %]
+ </div>
+ <div ng-if="modifiers.auto_print_holds_transits"
+ class="alert-danger pad-all-min">
+ [% l('Auto-Print Hold and Transit Slips') %]
+ </div>
+ <div ng-if="modifiers.clear_expired" class="alert-danger pad-all-min">
+ [% l('Clear Holds Shelf') %]
+ </div>
+ <div ng-if="modifiers.retarget_holds" class="alert-danger pad-all-min">
+ <div ng-if="modifiers.retarget_holds_all">
+ [% l('Always Retarget Local Holds') %]
+ </div>
+ <div ng-if="!modifiers.retarget_holds_all">
+ [% l('Retarget Local Holds') %]
+ </div>
+ </div>
+ <div ng-if="modifiers.hold_as_transit" class="alert-danger pad-all-min">
+ [% l('Capture Local Holds As Transits') %]
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- checkin form -->
+<div class="row pad-vert">
+ <div class="col-md-4">
+ <form ng-submit="checkin(checkinArgs)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <label class="input-group-addon"
+ for="patron-checkin-barcode" >[% l('Barcode') %]</label>
+
+ <input focus-me="focusMe" blur-me="blurMe"
+ class="form-control"
+ ng-model="checkinArgs.copy_barcode"
+ placeholder="[% l('Barcode') %]"
+ id="patron-checkin-barcode" type="text"/>
+
+ <input type="submit" class="btn btn-default" value="[% l('Submit') %]"/>
+ </div>
+ </form>
+ </div>
+
+ <div class="col-md-4">
+ <div ng-if="alert" class="col-md-12 alert-danger pad-all-min">
+ <span ng-if="alert.already_checked_in">
+ [% l('[_1] was already checked in.', '{{alert.already_checked_in}}') %]
+ </span>
+ <span ng-if="alert.item_never_circed">
+ [% l('Item [_1] has never circulated.', '{{alert.item_never_circed}}') %]
+ </span>
+ </div>
+ </div>
+
+ <div class="col-md-4" ng-if="!is_capture">
+ <div class="flex-row">
+ <div class="flex-cell"></div>
+ <div class="pad-horiz">[% l('Effective Date') %]</div>
+ <!-- date max= not yet supported -->
+ <div><input eg-date-input
+ class="form-control" ng-model="checkinArgs.backdate"/>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="row" ng-if="fine_total">
+ <div class="col-md-12">
+ <span>[% l('Fine Tally:') %]</span>
+ <span class="pad-horiz alert alert-danger">{{fine_total | currency}}</span>
+ <span ng-if="billable_barcode">
+ <span>[% l('Transaction for [_1] billed:', '{{billable_barcode}}') %]</span>
+ <span class="pad-horiz alert alert-danger">{{billable_amount | currency}}</span>
+ </span>
+ </div>
+</div>
+
+<hr/>
+
+[% INCLUDE 'staff/circ/checkin/t_checkin_table.tt2' %]
+
+<div class="row pad-vert">
+ <div class="col-md-10">
+ <div class="flex-row">
+ <div class="flex-cell"></div>
+ <div class="pad-horiz">
+ <button class="btn btn-default"
+ ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+ </div>
+ <div class="checkbox" ng-if="using_hatch">
+ <label>
+ <input ng-model="show_print_dialog" type="checkbox"/>
+ [% l('Show Print Dialog') %]
+ </label>
+ </div>
+ <div class="pad-horiz" ng-if="using_hatch"></div>
+ <div class="checkbox">
+ <label>
+ <input ng-model="trim_list" type="checkbox"/>
+ [% l('Trim List (20 Rows)') %]
+ </label>
+ </div>
+ <div class="pad-horiz"></div>
+ <div class="checkbox">
+ <label>
+ <input ng-model="strict_barcode" type="checkbox"/>
+ [% l('Strict Barcode') %]
+ </label>
+ </div>
+ </div><!-- flex row -->
+ </div><!-- col -->
+ <div class="col-md-2">
+ <div class="input-group-btn" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ [% l('Checkin Modifiers') %]
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu pull-right">
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('no_precat_alert')">
+ <span ng-if="modifiers.no_precat_alert"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.no_precat_alert"
+ class="label label-warning">✗</span>
+ <span>[% l('Ignore Pre-cataloged Items') %]</span>
+ </a>
+ </li>
+ <li ng-if="!is_capture"><!-- nonsensical for hold capture -->
+ <a href dropdown-toggle
+ ng-click="toggle_mod('noop')">
+ <span ng-if="modifiers.noop"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.noop"
+ class="label label-warning">✗</span>
+ <span>[% l('Suppress Holds and Transits') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('void_overdues')">
+ <span ng-if="modifiers.void_overdues"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.void_overdues"
+ class="label label-warning">✗</span>
+ <span>[% l('Amnesty Mode') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('auto_print_holds_transits')">
+ <span ng-if="modifiers.auto_print_holds_transits"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.auto_print_holds_transits"
+ class="label label-warning">✗</span>
+ <span>[% l('Auto-Print Hold and Transit Slips') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('clear_expired')">
+ <span ng-if="modifiers.clear_expired"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.clear_expired"
+ class="label label-warning">✗</span>
+ <span>[% l('Clear Holds Shelf') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('retarget_holds')">
+ <span ng-if="modifiers.retarget_holds"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.retarget_holds"
+ class="label label-warning">✗</span>
+ <span>[% l('Retarget Local Holds') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('retarget_holds_all')">
+ <span ng-if="modifiers.retarget_holds_all"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.retarget_holds_all"
+ class="label label-warning">✗</span>
+ <span>[% l('Retarget All Statuses') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href dropdown-toggle
+ ng-click="toggle_mod('hold_as_transit')">
+ <span ng-if="modifiers.hold_as_transit"
+ class="label label-success">✓</span>
+ <span ng-if="!modifiers.hold_as_transit"
+ class="label label-warning">✗</span>
+ <span>[% l('Capture Local Holds As Transits') %]</span>
+ </a>
+ </li>
+ </ul>
+ </div><!-- btn grp -->
+ </div><!-- col -->
+</div><!-- row -->
+
--- /dev/null
+<!-- checkins list -->
+
+<eg-grid
+ id-field="index"
+ features="-sort,-multisort"
+ main-label="[% l('Items Checked In') %]"
+ items-provider="gridDataProvider"
+ grid-controls="gridControls"
+ persist-key="{{grid_persist_key}}">
+
+ <eg-grid-action
+ handler="fetchLastCircPatron"
+ label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+ </eg-grid-action>
+ <eg-grid-action
+ handler="showBackdateDialog"
+ label="[% l('Backdate Post-Checkin') %]">
+ </eg-grid-action>
+ <eg-grid-action
+ handler="showMarkDamaged"
+ label="[% l('Mark Items Damaged') %]">
+ </eg-grid-action>
+ <eg-grid-action
+ handler="abortTransit"
+ label="[% l('Abort Transits') %]">
+ </eg-grid-action>
+
+ <eg-grid-field label="[% l('Alert Msg') %]"
+ path="acp.alert_message"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Balance Owed') %]"
+ path='mbts.balance_owed'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+ <!-- FIXME: ng-if / ng-disabled not working since the contents
+ are $interpolate'd and not $compile'd.
+ I want to hide / disable the href when there is no acp ID
+ -->
+ <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+ {{item.copy_barcode}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Bill #') %]"
+ path='circ.id'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Checkin Date') %]"
+ path='circ.checkin_time' dateformat='short'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Family Name') %]"
+ path='au.family_name'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Finish') %]"
+ path='circ.stop_fines_time'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Location') %]"
+ path='acp.location.name'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Route To') %]" path='route_to'>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Start') %]"
+ path='circ.xact_start'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path="title">
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.doc_id()}}">
+ {{item.title}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Due Date') %]"
+ path='circ.due_date' dateformat='short' hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]"
+ path="author" hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Call Number') %]"
+ path="acn.label" hidden></eg-grid-field>
+
+ <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+ <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+ <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+ <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+ <eg-grid-field path="transit.*" parent-idl-class="atc" hidden></eg-grid-field>
+ <eg-grid-field path="hold.*" parent-idl-class="ahr" hidden></eg-grid-field>
+</eg-grid>
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Holds Shelf");
+ ctx.page_app = "egHoldsApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/holds/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+ s.CLEAR_SHELF_ACTION_shelf = "[% l('Reshelve') %]";
+ s.CLEAR_SHELF_ACTION_hold = "[% l('Needed for Hold') %]";
+ s.CLEAR_SHELF_ACTION_transit = "[% l('Needs Transiting') %]";
+ s.CLEAR_SHELF_ACTION_pl_changed = "[% l('Wrong Shelf') %]";
+}])
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span>[% l('Holds Pull List') %]</span>
+ </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_pull_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+ <div class="row">
+ <div class="col-md-2">
+ <button class="btn btn-default" ng-click="list_view()">
+ [% l('List View') %]
+ </button>
+ </div>
+ </div>
+ <div class="pad-vert"></div>
+ <eg-record-summary record='detail_hold_record'
+ record-id="detail_hold_record_id"></eg-record-summary>
+ <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
--- /dev/null
+<div ng-if="print_list_progress !== null" class="strong-text-2">
+ [% l('Loading... [_1]', '{{print_list_progress}}') %]
+</div>
+
+<eg-grid
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ persist-key="circ.holds.pull">
+
+ <eg-grid-menu-item handler="detail_view"
+ label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+ <eg-grid-menu-item handler="print_full_list"
+ label="[% l('Print Full List') %]"></eg-grid-menu-item>
+
+ <!--
+ The Alternate print UI appears to be generated in a very similar
+ fashion to our native full list printer. Also, since it's
+ generated from a separate standalone HTML page, the print
+ action bypasses Hatch and goes straight to the browser printer.
+ <eg-grid-menu-item handler="print_list_alt"
+ label="[% l('Print Full List (Alt)') %]"></eg-grid-menu-item>
+ -->
+
+ <eg-grid-action handler="grid_actions.show_recent_circs"
+ label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_copy_quality"
+ label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_pickup_lib"
+ label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_notify_prefs"
+ label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_dates"
+ label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.activate"
+ label="[% l('Activate') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.suspend"
+ label="[% l('Suspend') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_top_of_queue"
+ label="[% l('Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.clear_top_of_queue"
+ label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+ label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_damaged"
+ label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_missing"
+ label="[% l('Mark Item Missing') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.retarget"
+ label="[% l('Find Another Target') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.cancel_hold"
+ label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+ <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+ <eg-grid-field label="[% l('Current Copy') %]"
+ path='hold.current_copy.barcode'>
+ <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+ {{item.hold.current_copy().barcode()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Part') %]" path='part.label'></eg-grid-field>
+ <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+ <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+ <eg-grid-field label="[% l('Copy Location') %]" path='copy.location.name'></eg-grid-field>
+ <eg-grid-field label="[% l('Call Number') %]" path='volume.label'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+ {{item.mvr.title()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+ <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+ <eg-grid-field label="[% l('Status') %]" path='status_string' hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+ <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+ <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
--- /dev/null
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span>[% l('Holds Shelf') %]</span>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-3">
+ <div class="input-group">
+ <span class="input-group-addon">[% l('Pickup Library') %]</span>
+ <eg-org-selector selected="pickup_ou" ng-disabled="is_clearing()"></eg-org-selector>
+ </div>
+ </div>
+ <div class="col-md-3" ng-show="is_clearing()">
+ <progressbar max="clear_progress.max" value="clear_progress.value">
+ <span class="progressbar-text">{{clear_progress.value}} / {{clear_progress.max}}</span>
+ </progressbar>
+ </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_shelf_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+ <div class="row">
+ <div class="col-md-2">
+ <button class="btn btn-default" ng-click="list_view()">
+ [% l('List View') %]
+ </button>
+ </div>
+ </div>
+ <div class="pad-vert"></div>
+ <eg-record-summary record='detail_hold_record'
+ record-id="detail_hold_record_id"></eg-record-summary>
+ <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
--- /dev/null
+
+<eg-grid
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ grid-controls="gridControls"
+ persist-key="circ.holds.shelf">
+
+ <eg-grid-menu-item handler="detail_view"
+ label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+ <eg-grid-menu-item handler="show_clearable"
+ hidden="clear_mode" disabled="is_clearing"
+ label="[% l('Show Clearable Holds') %]"></eg-grid-menu-item>
+
+ <eg-grid-menu-item handler="show_active"
+ hidden="active_mode" disabled="is_clearing"
+ label="[% l('Show All Holds') %]"></eg-grid-menu-item>
+
+ <eg-grid-menu-item handler="clear_holds" disabled="disable_clear"
+ label="[% l('Clear These Holds') %]"></eg-grid-menu-item>
+
+ <eg-grid-action handler="grid_actions.show_recent_circs"
+ label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_copy_quality"
+ label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_pickup_lib"
+ label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_notify_prefs"
+ label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_dates"
+ label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.activate"
+ label="[% l('Activate') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.suspend"
+ label="[% l('Suspend') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_top_of_queue"
+ label="[% l('Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.clear_top_of_queue"
+ label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+ label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_damaged"
+ label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_missing"
+ label="[% l('Mark Item Missing') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.retarget"
+ label="[% l('Find Another Target') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.cancel_hold"
+ label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+ <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+ <eg-grid-field label="[% l('Current Copy') %]"
+ path='hold.current_copy.barcode'>
+ <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+ {{item.hold.current_copy().barcode()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+ <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+ <eg-grid-field label="[% l('Post-Clear') %]" path='post_clear'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+ {{item.mvr.title()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+ <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+ <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+ <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+ <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("In-House Use");
+ ctx.page_app = "egInHouseUseApp";
+ ctx.page_ctrl = "InHouseUseCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/in_house_use/app.js"></script>
+[% END %]
+
+<style>
+ /* FIXME: MOVE ME */
+ #in-house-use-barcode {width: 16em;}
+ #in-house-use-form { margin-bottom: 20px }
+</style>
+
+<form id="in-house-use-form" ng-submit="checkout(args)" role="form">
+ <div class="row">
+
+ <div class="col-md-2">
+ <div class="input-group">
+ <label class="input-group-addon" for="in-house-num-uses">
+ [% l('# of Uses:') %]
+ </label>
+ <input type="number" min="1" max="{{countMax}}"
+ class="form-control" focus-me="useFocus"
+ id="in-house-num-uses" ng-model="args.num_uses"/>
+ </div>
+ </div>
+
+ <div class="col-md-6">
+ <div class="input-group">
+ <div class="input-group-btn" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ {{selectedNcType() || "[% l('Barcode') %]"}}
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a href dropdown-toggle
+ ng-click="args.noncat_type='barcode';bcFocus=true">
+ [% l('Barcode') %]</a>
+ </li>
+ <li class="divider"></li>
+ <li><a href dropdown-toggle
+ ng-repeat='type in nonCatTypes'
+ ng-click="args.noncat_type=type.id()">{{type.name()}}</a>
+ </li>
+ </ul>
+ </div>
+
+ <input type="text" id="in-house-use-barcode" focus-me="bcFocus"
+ class="form-control" ng-model="args.barcode"
+ ng-disabled="args.noncat_type != 'barcode'"/>
+ <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+ </div><!-- input group -->
+ </div><!-- col -->
+ </div><!-- row -->
+</form>
+
+<div clas="row" ng-if="copyNotFound">
+ <div class="col-md-6 alert alert-danger">[% l('Copy Not Found') %]</div>
+</div>
+
+<eg-grid
+ id-field="index"
+ features="-display,-sort,-multisort"
+ main-label="[% l('In-House Use') %]"
+ items-provider="gridDataProvider"
+ persist-key="circ.in_house_use">
+ <eg-grid-field label="[% l('# of Uses') %]" path='num_uses' visible></eg-grid-field>
+ <eg-grid-field label="[% l('Barcode') %]" path='copy.barcode' visible></eg-grid-field>
+ <eg-grid-field label="[% l('Call Number') %]" path="copy.call_number.label" visible></eg-grid-field>
+ <eg-grid-field label="[% l('Location') %]" path="copy.location.name" visible></eg-grid-field>
+ <eg-grid-field label="[% l('Title') %]" path="title" visible></eg-grid-field>
+</eg-grid>
+
+
+[% END %]
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Patron");
+ ctx.page_app = "egPatronApp";
+ ctx.page_ctrl = "PatronCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
+
+<!-- load the rest on demand? -->
+
+<!-- required for credentials verify API -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/checkout.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/items_out.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/holds.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/bills.js"></script>
+
+<!-- TODO: APP_JS should really be called APP_ADDONS or some such.
+ It just means "load these things, too, and load them last" -->
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+ s.ANNOTATE_PAYMENT_MSG = "[% l('Please annotate this payment') %]";
+ s.CONFIRM_REFUND_PAYMENT =
+ "[% |l('{{xactIds}}') -%]Are you sure you would like to refund excess payment on bills [_1]? This action will simply put the amount in the Payment Pending column as a negative value. You must still select Apply Payment! Certain types of payments may not be refunded. The refund may be applied to checked transactions that follow the refunded transaction.[% END %]";
+ s.EDIT_BILL_PAY_NOTE = "[% l('Enter new note for #[_1]:','{{ids}}') %]";
+ s.GROUP_ADD_USER = "[% l('Enter the patron barcode') %]";
+ s.RENEW_ITEMS = "[% l('Renew Items?') %]";
+ s.RENEW_ALL_ITEMS = "[% l('Renew All Items?') %]";
+ s.CHECK_IN_CONFIRM = "[% l('Check In Items?') %]";
+}]);
+</script>
+
+[% END %]
+
+<div class="row">
+ <div class="col-md-3">
+ <div ng-show="patron()">
+ <h4 title="{{patron().id()}}">
+ <div class="flex-row">
+ <div class="flex-cell">
+ [% l('[_1], [_2] [_3]',
+ '{{patron().family_name()}}',
+ '{{patron().first_given_name()}}',
+ '{{patron().second_given_name()}}') %]
+ </div>
+ <div ng-show="tab != 'search'">
+ <a href ng-click="toggle_expand_summary()"
+ title="[% l('Collapse Patron Summary Display') %]"
+ ng-hide="collapse_summary()">
+ <span class="glyphicon glyphicon-resize-small"></span>
+ </a>
+ <a href ng-click="toggle_expand_summary()"
+ title="[% l('Expand Patron Summary Display') %]"
+ ng-show="collapse_summary()">
+ <span class="glyphicon glyphicon-resize-full"></span>
+ </a>
+ </div>
+ </div><!-- row -->
+ </h4>
+ </div><!-- if patron -->
+ </div><!-- col -->
+ <div class="col-md-9">
+ <ul class="nav nav-pills nav-pills-like-tabs">
+ <li ng-class="{active : tab == 'checkout', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/checkout">[% l('Check Out') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'items_out', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/items_out">
+ [% l('Items Out') %]
+ <span ng-if="patron()"><!-- lack of space / newline below intentional -->
+ (<span ng-class="{'patron-summary-alert-small' : patron_stats().checkouts.overdue}">{{patron_stats().checkouts.total_out}}</span>)
+ </span>
+ </a>
+ </li>
+ <li ng-class="{active : tab == 'holds', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/holds">
+ [% l('Holds') %]
+ <span ng-if="patron()">
+ (<span>{{patron_stats().holds.total}} / {{patron_stats().holds.ready}}</span>)
+ </span>
+ </a>
+ </li>
+ <li ng-class="{active : tab == 'bills', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/bills">
+ [% l('Bills') %]
+ <span ng-if="patron()">
+ (<span ng-class="{'patron-summary-alert-small' : patron_stats().fines.balance_owed}">{{patron_stats().fines.balance_owed | currency}}</span>)
+ </span>
+ </a>
+ </li>
+ <li ng-class="{active : tab == 'messages', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/messages">[% l('Messages') %]</a>
+ </li>
+ <li ng-class="{active : tab == 'edit', disabled : !patron()}">
+ <a href="./circ/patron/{{patron().id()}}/edit">[% l('Edit') %]</a>
+ </li>
+ <li class="dropdown" ng-class="{active : tab == 'other', disabled : !patron()}">
+ <a href class="dropdown-toggle" data-toggle="dropdown">
+ [% l('Other') %]
+ <b class="caret"></b>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/alerts">
+ [% l('Display Alert and Messages') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/notes">
+ [% l('Notes') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/triggered_events">
+ [% l('Triggered Events / Notifications') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/stat_cats">
+ [% l('Statistical Categories') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/group">
+ [% l('Group Member Details') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/edit_perms">
+ [% l('User Permission Editor') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/{{patron().id()}}/credentials">
+ [% l('Test Password') %]
+ </a>
+ </li>
+ </ul>
+ </li>
+ <li ng-class="{active : tab == 'search'}" class="pull-right">
+ <a href="./circ/patron/search">[% l('Patron Search') %]</a>
+ </li>
+ </ul>
+ </div><!-- col -->
+</div><!-- row -->
+
+<div class="row">
+ <div class="col-md-3" ng-hide="collapse_summary()">
+ [% INCLUDE 'staff/circ/patron/t_summary.tt2' %]
+ </div>
+ <div ng-class="{'col-md-12' : collapse_summary(),'col-md-9' : !collapse_summary()}">
+ <div class="tab-content">
+ <div class="tab-pane active">
+ <div ng-view></div>
+ </div>
+ </div>
+ </div><!-- col -->
+</div><!-- row -->
+
+[% END %]
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Pending Patrons");
+ ctx.page_app = "egPendingPatronsApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/pending.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Patron Registration");
+ ctx.page_app = "egPatronRegApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/register.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+<div>
+
+ <!-- FIXME: move image file -->
+ <img src='/xul/server/skin/media/images/stop_sign.png'>
+
+ <div class="alert alert-info" ng-if="patron_stats().holds.ready > 0">
+ [% l('Holds available: [_1]', '{{patron_stats().holds.ready}}') %]
+ </div>
+
+ <div class="alert alert-warning" ng-if="patronExpired">
+ [% l('Patron account is EXPIRED.') %]
+ </div>
+
+ <div class="alert alert-warning" ng-if="patronExpiresSoon">
+ [% l('Patron account will expire soon. Please renew.') %]
+ </div>
+
+ <div class="alert alert-warning" ng-if="patron().barred() == 't'">
+ [% l('Patron account is BARRED') %]
+ </div>
+
+ <div class="alert alert-warning" ng-if="patron().active() == 'f'">
+ [% l('Patron account is INACTIVE') %]
+ </div>
+
+ <div class="alert alert-warning" ng-if="retrievedWithInactive">
+ [% l('Patron account retrieved with an INACTIVE card.') %]
+ </div>
+
+ <!-- alert message -->
+ <div class="row" ng-if="patron().alert_message()">
+ <div class="col-md-12">
+ <div class="panel panel-warning">
+ <div class="panel-heading">
+ <div class="panel-title text-center">[% l('Alert Message') %]</div>
+ </div>
+ <div class="panel-body">
+ {{patron().alert_message()}}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- penalties -->
+ <div class="row" ng-if="alert_penalties().length">
+ <div class="col-md-12">
+ <div class="panel panel-warning">
+ <div class="panel-heading">
+ <div class="panel-title text-center">[% l('Penalties') %]</div>
+ </div>
+ <div class="panel-body">
+ <div class="row"
+ ng-repeat="penalty in alert_penalties()">
+ <div class="col-md-2">
+ {{penalty.org_unit().shortname()}}
+ </div>
+ <div class="col-md-8"
+ title="{{penalty.standing_penalty().name()}}">
+ {{penalty.standing_penalty().label()}}
+ <div>{{penalty.note()}}</div><!-- force newline -->
+ </div>
+ <div class="col-md-2">
+ {{penalty.set_date() | date:'shortDate'}}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <br/>
+ <div class="well">
+[% l('Press a navigation button above (for example, Check Out) to clear this alert.') %]
+ </div>
+</div>
--- /dev/null
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <label class="input-group-addon"
+ for="patron-checkout-barcode" >[% l('Patron Barcode') %]</label>
+
+ <input select-me="selectMe" class="form-control"
+ ng-model="args.barcode"
+ placeholder="[% l('Patron Barcode') %]"
+ id="patron-checkout-barcode" type="text"/>
+
+ <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+ </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+ [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
--- /dev/null
+<h2>[% l('Bill History') %]</h2>
+
+<ul class="nav nav-tabs">
+ <li ng-class="{active : bill_tab == 'transactions'}">
+ <a href="./circ/patron/{{patron().id()}}/bill_history/transactions">
+ [% l('Transactions') %]
+ </a>
+ </li>
+ <li ng-class="{active : bill_tab == 'payments'}">
+ <a href="./circ/patron/{{patron().id()}}/bill_history/payments">
+ [% l('Payments') %]
+ </a>
+ </li>
+</ul>
+<div class="tab-content">
+ <div class="tab-pane active">
+
+ <div class="flex-row padded">
+ <div ng-if="bill_tab == 'transactions'">[% l('Selected Billed:') %]</div>
+ <div ng-if="bill_tab == 'transactions'">{{totals.selected_billed() | currency}}</div>
+ <div>[% l('Selected Paid:') %]</div>
+ <div>{{totals.selected_paid() | currency}}</div>
+ <div class="flex-cell"></div>
+ <div>[% l('Start Date:') %]</div>
+ <div><input eg-date-input class="form-control" ng-model="dates.xact_start"/></div>
+ <div>[% l('End Date:') %]</div>
+ <div><input eg-date-input class="form-control" ng-model="dates.xact_finish"/></div>
+ </div><!-- top row -->
+ <hr/>
+ [% INCLUDE 'staff/circ/patron/t_bill_history_xacts.tt2' %]
+ [% INCLUDE 'staff/circ/patron/t_bill_history_payments.tt2' %]
+ </div>
+</div>
+
--- /dev/null
+
+<div ng-if="bill_tab == 'payments'" ng-controller="BillPaymentHistoryCtrl">
+
+ <eg-grid
+ idl-class="mp"
+ id-field="id"
+ grid-controls="gridControls">
+
+ <eg-grid-action
+ label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+ <eg-grid-field path="amount" label="[% l('Amount') %]"></eg-grid-field>
+ <eg-grid-field path="id" label="[% l('Payment ID') %]"></eg-grid-field>
+ <eg-grid-field path="payment_ts" label="[% l('Payment Time') %]"></eg-grid-field>
+ <eg-grid-field path="note" label="[% l('Note') %]"></eg-grid-field>
+ <eg-grid-field path="voided" label="[% l('Voided') %]"></eg-grid-field>
+ <eg-grid-field path="xact.summary.xact_type" label="[% l('Transaction Type') %]"></eg-grid-field>
+ <eg-grid-field path="xact.summary.last_billing_type" label="[% l('Last Billing Type') %]"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" name="title"
+ path="xact.circulation.target_copy.call_number.record.simple_record.title">
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+ </eg-grid-field>
+
+ <!-- needed for bib link -->
+ <eg-grid-field name="record_id"
+ path="xact.circulation.target_copy.call_number.record.id"
+ required hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode"
+ path="xact.circulation.target_copy.barcode">
+ <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+ </eg-grid-field>
+
+ <!-- needed for item link -->
+ <eg-grid-field name="copy_id"
+ path="xact.circulation.target_copy.id" required hidden></eg-grid-field>
+
+ <!-- ... -->
+
+ <eg-grid-field path="xact.id" required hidden></eg-grid-field>
+ <eg-grid-field path="xact.usr" required hidden></eg-grid-field>
+ <eg-grid-field path="xact.*" hidden></eg-grid-field>
+ <eg-grid-field path="xact.summary.*" hidden></eg-grid-field>
+
+ <!--
+ <eg-grid-field path="xact.summary.balance_owed"></eg-grid-field>
+ <eg-grid-field path="xact.xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+ <eg-grid-field path="xact.xact_start" label="[% l('Start') %]"></eg-grid-field>
+ <eg-grid-field path="xact.summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+ <eg-grid-field path="xact.summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+ <eg-grid-field path="xact.summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" name="title"
+ path="circulation.target_copy.call_number.record.simple_record.title">
+ <a href="[% ctx.base_path %]/opac/record/{{item.record_id}}">{{item.title}}</a>
+ </eg-grid-field>
+
+ <eg-grid-field name="record_id"
+ path="circulation.target_copy.call_number.record.id"
+ required hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode"
+ path="circulation.target_copy.barcode">
+ <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+ </eg-grid-field>
+
+ <eg-grid-field name="copy_id"
+ path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+ <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+ <eg-grid-field path="summary.*" hidden></eg-grid-field>
+ <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+ <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+ -->
+ </eg-grid>
+</div>
+
--- /dev/null
+
+<div ng-if="bill_tab == 'transactions'" ng-controller="BillXactHistoryCtrl">
+
+ <eg-grid
+ idl-class="mbt"
+ id-field="id"
+ grid-controls="gridControls">
+
+ <eg-grid-action
+ label="[% l('Add Billing') %]" handler="addBilling"></eg-grid-action>
+ <eg-grid-action
+ label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+ <eg-grid-field path="summary.balance_owed"></eg-grid-field>
+ <eg-grid-field path="id" label="[% l('Bill #') %]"></eg-grid-field>
+ <eg-grid-field path="xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+ <eg-grid-field path="xact_start" label="[% l('Start') %]"></eg-grid-field>
+ <eg-grid-field path="summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+ <eg-grid-field path="summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+ <eg-grid-field path="summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" name="title"
+ path="circulation.target_copy.call_number.record.simple_record.title">
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+ </eg-grid-field>
+
+ <!-- needed for bib link -->
+ <eg-grid-field name="record_id"
+ path="circulation.target_copy.call_number.record.id"
+ required hidden></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode"
+ path="circulation.target_copy.barcode">
+ <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+ </eg-grid-field>
+
+ <!-- needed for item link -->
+ <eg-grid-field name="copy_id"
+ path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+ <!-- needed for grid query -->
+ <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+ <eg-grid-field path="summary.*" hidden></eg-grid-field>
+ <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+ <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+ </eg-grid>
+</div>
+
--- /dev/null
+
+<div class="row">
+ <div class="col-md-7">
+
+ <div class="row">
+ <div class="col-md-4">[% l('Total Owed:') %]</div>
+ <div class="col-md-2 strong-text">{{summary.balance_owed() | currency}}</div>
+ <div class="col-md-4">[% l('Refunds Available:') %]</div>
+ <div class="col-md-2">{{refunds_available() | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Total Billed:') %]</div>
+ <div class="col-md-2">{{summary.total_owed() | currency}}</div>
+ <div class="col-md-4">[% l('Credit Available:') %]</div>
+ <div class="col-md-2">{{patron().credit_forward_balance() | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Total Paid:') %]</div>
+ <div class="col-md-2">{{summary.total_paid() | currency}}</div>
+ <div class="col-md-4">[% l('Session Voided:') %]</div>
+ <div class="col-md-2">{{session_voided | currency}}</div>
+ </div>
+ <div class="row"><hr/></div>
+ <div class="row">
+ <div class="col-md-4">[% l('Owed for Selected:') %]</div>
+ <div class="col-md-2">{{owed_selected() | currency}}</div>
+ <div class="col-md-4">[% l('Pending Payment:') %]</div>
+ <div class="col-md-2 strong-text">{{pending_payment() | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Billed for Selected:') %]</div>
+ <div class="col-md-2">{{billed_selected() | currency}}</div>
+ <div class="col-md-4">[% l('Pending Change:') %]</div>
+ <div class="col-md-2 strong-text">{{pending_change() | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Paid for Selected:') %]</div>
+ <div class="col-md-2">{{paid_selected() | currency}}</div>
+ </div>
+ </div><!-- col -->
+
+ <div class="col-md-5">
+ <form role="form" class="form-horizontal" ng-submit="applyPayment()">
+ <fieldset>
+ <legend>[% l('Pay Bill') %]</legend>
+
+ <div class="form-group">
+ <label for="type-input" class="col-md-6 control-label">[% l('Payment Type') %]</label>
+ <div class="col-md-6">
+ <select ng-model="payment_type" class="form-control">
+ <option value="cash_payment" selected="selected">[% l('Cash') %]</option>
+ <option value="check_payment">[% l('Check') %]</option>
+ <option value="credit_card_payment">[% l('Credit Card') %]</option>
+ <option value="credit_payment">[% l('Patron Credit') %]</option>
+ <option value="work_payment">[% l('Work') %]</option>
+ <option value="forgive_payment">[% l('Forgive') %]</option>
+ <option value="goods_payment">[% l('Goods') %]</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="amount-input" class="col-md-6 control-label">
+ [% l('Payment Received') %]
+ </label>
+ <div class="col-md-6">
+ <input type="number" min="0" step="any" id="amount-input"
+ ng-model="payment_amount" focus-me="focus_payment"
+ value="" class="form-control col-md-6 "/>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="annotate-payment" class="control-label col-md-5">[% l('Annotate') %]</label>
+ <div class="col-md-1">
+ <input id="annotate-payment" type="checkbox" ng-model="annotate_payment"/>
+ </div>
+ <div class="col-md-6">
+ <button type="submit" class="btn btn-default">[% l('Apply Payment') %]</button>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ </div>
+</div>
+
+<div class="pad-vert">
+[% INCLUDE 'staff/circ/patron/t_bills_list.tt2' %]
+</div>
+
+<!-- pull-right is causing the content to flow several pixels
+off to the right. flex-row is honoring the boundaries better.
+not sure what's up, there. -->
+<div class="flex-row">
+ <div class="flex-cell"></div>
+ <form class="form-inline" role="form">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" ng-model="receipt_on_pay"/>
+ [% l('Receipt On Payment') %]
+ </label>
+ </div>
+ <div class="form-group" style="margin-left:10px">
+ <label for="bill-receipt-copies">[% l('# Copies') %]</label>
+ <input type="number" min="1" style="width:5em"
+ ng-model="receipt_count"
+ class="form-control" id="bill-receipt-copies"/>
+ </div>
+ </form>
+</div>
+
--- /dev/null
+
+<eg-grid
+ idl-class="mbt"
+ query="gridQuery"
+ sort="gridSort"
+ grid-controls="gridControls"
+ revision="gridRevision"
+ persist-key="circ.patron.bills">
+
+ <eg-grid-menu-item label="[% l('Bill Patron') %]"
+ handler="showBillDialog"></eg-grid-menu-item>
+
+ <eg-grid-menu-item label="[% l('History') %]"
+ handler="showHistory"></eg-grid-menu-item>
+
+ <eg-grid-menu-item label="[% l('Check All Refunds') %]"
+ handler="selectRefunds"></eg-grid-menu-item>
+
+ <eg-grid-action label="[% l('Print Bills') %]"
+ handler="printBills"></eg-grid-action>
+
+ <!--
+ need to decide if these are necessary here w/ inline links
+ to record and copy details (though they could be hidden).
+ it's misleading to allow the user to select multiple bills
+ but only open the link to one
+
+ <eg-grid-action label="[% l('Show in Catalog') %]"
+ handler=""></eg-grid-action>
+
+ <eg-grid-action label="[% l('Show Item Details') %]"
+ handler=""></eg-grid-action>
+ -->
+
+ <eg-grid-action label="[% l('Void All Billings') %]"
+ handler="voidAllBillings"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Refund') %]"
+ handler="refundXact"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Add Billing') %]"
+ handler="addBilling"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Full Details') %]"
+ handler="showFullDetails"></eg-grid-action>
+
+ <eg-grid-field label="[% ('Balance Owed') %]" path='summary.balance_owed'></eg-grid-field>
+ <eg-grid-field label="[% ('Bill #') %]" path='id'></eg-grid-field>
+ <eg-grid-field label="[% ('Start') %]" path='xact_start'></eg-grid-field>
+ <eg-grid-field label="[% ('Total Billed') %]" path='summary.total_owed'></eg-grid-field>
+ <eg-grid-field label="[% ('Total Paid') %]" path='summary.total_paid'></eg-grid-field>
+ <eg-grid-field label="[% ('Type') %]" path='xact_type'></eg-grid-field>
+
+ <!-- receipt data -->
+ <eg-grid-field path='summary.last_billing_type' required></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" name="title"
+ path='circulation.target_copy.call_number.record.simple_record.title'>
+ <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+ </eg-grid-field>
+ <!-- fetch the record ID so we can link to it. hide it by default -->
+ <eg-grid-field path="circulation.target_copy.call_number.record.id"
+ label="[% l('Record ID') %]" name="record_id" required hidden>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" required
+ path='circulation.target_copy.barcode' name="copy_barcode">
+ <a href="./cat/item/{{item.copy_id}}" target="_self">
+ {{item.copy_barcode}}
+ </a>
+ </eg-grid-field>
+ <!-- fetch the copy ID so we can link to it. hide it by default -->
+ <eg-grid-field path="circulation.target_copy.id"
+ label="[% l('Copy ID') %]" name="copy_id" required hidden>
+ </eg-grid-field>
+
+ <!-- virtual field -->
+ <eg-grid-field datatype="money" label="[% ('Payment Pending') %]"
+ name="payment_pending"></eg-grid-field>
+
+ <!-- import all circ fields, hidden by default -->
+ <eg-grid-field path='circulation.*' hidden> </eg-grid-field>
+
+ <eg-grid-field path='circulation.target_copy.*' hidden> </eg-grid-field>
+
+</eg-grid>
+
--- /dev/null
+<!-- item checkout form / list -->
+
+<div class="row pad-vert">
+ <div class="col-md-6">
+ <form ng-submit="checkout(checkoutArgs)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <div class="input-group-btn" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle"
+ ng-class="{disabled : disable_checkout()}">
+ {{selectedNcType() || "[% l('Barcode') %]"}}
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li><a href dropdown-toggle
+ ng-click="checkoutArgs.noncat_type='barcode';focusMe=true">
+ [% l('Barcode') %]</a>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <a href ng-repeat='type in nonCatTypes' dropdown-toggle
+ ng-click="checkoutArgs.noncat_type=type.id()">{{type.name()}}</a>
+ </li>
+ </ul>
+ </div>
+
+ <input focus-me="focusMe" class="form-control"
+ ng-model="checkoutArgs.copy_barcode"
+ ng-disabled="checkoutArgs.noncat_type != 'barcode' || disable_checkout()"
+ id="patron-checkout-barcode" type="text"/>
+
+ <input class="btn btn-default" type="submit"
+ ng-class="{disabled : disable_checkout()}" value="[% l('Submit') %]"/>
+
+ </div>
+ </form>
+ </div>
+ <div class="col-md-6">
+ <div class="flex-row">
+ <div class="flex-cell"></div>
+ <div class="checkbox pad-horiz">
+ <label>
+ <input type="checkbox" ng-model="checkoutArgs.sticky_date"/>
+ [% l('Specific Due Date') %]
+ </label>
+ </div>
+ <!--
+ <div><input type="checkbox" class="checkbox" ng-model="checkoutArgs.sticky_date"/></div>
+ <div class="pad-horiz">[% l('Specific Due Date') %]</div>
+ -->
+ <!-- FIXME: This needs a time component as well, but type="datetime"
+ is not yet supported by any browsers -->
+ <div><input eg-date-input class="form-control"
+ ng-model="checkoutArgs.due_date"/>
+ </div>
+ </div>
+ </div>
+</div>
+<hr/>
+
+<eg-grid
+ id-field="index"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ grid-controls="gridControls"
+ persist-key="circ.patron.checkout">
+
+ <eg-grid-field label="[% l('Alert Msg') %]"
+ path="acp.alert_message"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Balance Owed') %]"
+ path='mbts.balance_owed'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+ <!-- FIXME: ng-if / ng-disabled not working since the contents
+ are $interpolate'd and not $compile'd.
+ I want to hide / disable the href when there is no acp ID
+ -->
+ <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+ {{item.copy_barcode}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Bill #') %]"
+ path='circ.id'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Call Number') %]"
+ path="acn.label"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Due Date') %]"
+ path='circ.due_date' dateformat='short'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Family Name') %]"
+ path='au.family_name'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Location') %]"
+ path='acp.location.name'> </eg-grid-field>
+
+ <eg-grid-field label="[% l('Remaining Renewals') %]"
+ path='circ.renewal_remaining'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path="title">
+ <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+ {{item.title}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]"
+ path="author" hidden></eg-grid-field>
+
+ <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+ <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+ <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+ <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+ <div class="flex-cell"></div>
+ <div class="checkbox">
+ <label>
+ <input ng-model="strict_barcode" type="checkbox"/>
+ [% l('Strict Barcode') %]
+ </label>
+ </div>
+ <div class="pad-horiz" ng-if="using_hatch"></div>
+ <div class="checkbox" ng-if="using_hatch">
+ <label>
+ <input ng-model="show_print_dialog" type="checkbox"/>
+ [% l('Show Print Dialog') %]
+ </label>
+ </div>
+ <div class="pad-horiz">
+ <button class="btn btn-default"
+ ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+ </div>
+ <div>
+ <button class="btn btn-default"
+ ng-click="done()">[% l('Done') %]</button>
+ </div>
+</div>
+
--- /dev/null
+<div class="container">
+ <div class="row">
+ <div class="col-md-6">
+ <fieldset>
+ <legend>[% l('Verify Credentials') %]</legend>
+ <form ng-submit="verify()"
+ name="verify-creds-form" class="form-horizontal" role="form">
+
+ <div class="form-group">
+ <label class="col-md-4 control-label"
+ for="verify-username">[% l('Username') %]</label>
+ <div class="col-md-8">
+ <input type="text" id="verify-username" class="form-control"
+ focus-me="focusMe" ng-disabled="prepop"
+ placeholder="[% l('Username') %]" ng-model="username"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label class="col-md-4 control-label"
+ for="verify-barcode">[% l('Barcode') %]</label>
+ <div class="col-md-8">
+ <input type="text" id="verify-barcode" class="form-control"
+ ng-disabled="prepop"
+ placeholder="[% l('Barcode') %]" ng-model="barcode"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label class="col-md-4 control-label"
+ for="verify-password">[% l('Password') %]</label>
+ <div class="col-md-8">
+ <input type="password" id="verify-password" class="form-control"
+ placeholder="[% l('Password') %]" ng-model="password"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="col-md-offset-4 col-md-2">
+ <button type="submit" class="btn btn-default">[% l('Verify') %]</button>
+ </div>
+ <div class="col-md-2" ng-hide="prepop">
+ <button class="btn btn-default" ng-click="load($event)">[% l('Retrieve') %]</button>
+ </div>
+ </div>
+
+ <div class="form-group" ng-cloak>
+ <div class="col-md-offset-4 col-md-8">
+ <div class="alert alert-success" ng-show="verified">
+ [% l('Succes testing credentials') %]
+ </div>
+ <div class="alert alert-danger" ng-show="verified === false">
+ [% l('Failure testing credentials') %]
+ </div>
+ <div class="alert alert-danger" ng-show="notFound">
+ [% l('No user found with the requested username / barcode') %]
+ </div>
+ </div>
+ </div>
+
+ </form>
+ </fieldset>
+ <div><!-- col -->
+ </div><!-- row -->
+</div><!-- container -->
--- /dev/null
+<eg-embed-frame url="patron_edit_url" handlers="funcs"></eg-embed-frame>
+
--- /dev/null
+<form ng-submit="ok(args)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Edit Due Date For [_1] Items', '{{args.num_circs}}') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row pad-vert">
+ <div class="col-md-6">
+ [% l('Enter Due Date: ') %]
+ </div>
+ <div class="col-md-6">
+ <input eg-date-input class="form-control" ng-model="args.due_date"/>
+ </div>
+ </div>
+ <!-- TODO: time picker -->
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+</form>
--- /dev/null
+<eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
--- /dev/null
+
+<div class="strong-text-2">[% l('Group Member Details') %]</div>
+<div class="pad-vert flex-row">
+ <div>[% l('Total Owed: ') %]</div>
+ <div class="pad-horiz">{{totals.owed | currency}}</div>
+ <div>[% l('Total Out: ') %]</div>
+ <div class="pad-horiz">{{totals.total_out}}</div>
+ <div>[% l('Total Overdue: ') %]</div>
+ <div class="pad-horiz">{{totals.overdue}}</div>
+</div>
+<div class="pad-vert"></div>
+<eg-grid
+ idl-class="au"
+ sort="gridSort"
+ grid-controls="gridControls"
+ menu-label="[% l('Group Actions') %]">
+
+ <eg-grid-menu-item handler="moveToGroup"
+ label="[% l('Move Another Patron To This Group') %]"></eg-grid-menu-item>
+
+ <eg-grid-action
+ label="[% l('Register a New Group Member by Cloning Selected Patron') %]"
+ handler="cloneUser"></eg-grid-action>
+
+ <eg-grid-action label="[% l('Remove Selected From Group') %]"
+ handler="removeFromGroup"></eg-grid-action>
+
+ <eg-grid-action label="[% l("Move Selected Patrons to Another Patron's Group") %]"
+ handler="moveToAnotherGroup"></eg-grid-action>
+
+ <eg-grid-action label="[% l("Retrieve Selected Patron") %]"
+ handler="retrieveSelected"></eg-grid-action>
+
+ <eg-grid-field path="active"></eg-grid-field>
+ <eg-grid-field path="barred"></eg-grid-field>
+ <eg-grid-field path="dob"></eg-grid-field>
+ <eg-grid-field path="family_name"></eg-grid-field>
+ <eg-grid-field path="first_given_name"></eg-grid-field>
+ <eg-grid-field path="master_account"></eg-grid-field>
+ <eg-grid-field path="second_given_name"></eg-grid-field>
+ <eg-grid-field path="stats.fines.balance_owed" nonsortable label="[% l('Balance Owed') %]"></eg-grid-field>
+ <eg-grid-field path="stats.checkouts.out" nonsortable label="[% l('Items Out') %]"></eg-grid-field>
+ <eg-grid-field path="stats.checkouts.overdue" nonsortable label="[% l('Items Overdue') %]"></eg-grid-field>
+
+ <!-- needed for query, sorting -->
+ <eg-grid-field path="id" hidden required></eg-grid-field>
+ <eg-grid-field path="usrgroup" hidden required></eg-grid-field>
+ <eg-grid-field path="deleted" hidden required></eg-grid-field>
+ <eg-grid-field path="create_date" hidden required></eg-grid-field>
+
+ <!--
+ <eg-grid-field path=".*"></eg-grid-field>
+ -->
+
+</eg-grid>
--- /dev/null
+<!-- patron holds list -->
+
+<ul class="nav nav-tabs" ng-if="!detail_hold_id">
+ <li ng-class="{active : holds_display == 'main', disabled : detail_hold_id}">
+ <a href ng-click="show_main_list()">[% l('Open Hold Requests') %]</a>
+ </li>
+ <li ng-class="{active : holds_display == 'alt', disabled : detail_hold_id}">
+ <a href ng-click="show_alt_list()">[% l('Recently Canceled Holds') %]</a>
+ </li>
+</ul>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/patron/t_holds_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+ <div class="row">
+ <div class="col-md-2">
+ <button class="btn btn-default" ng-click="list_view()">
+ [% l('List View') %]
+ </button>
+ </div>
+ </div>
+ <div class="pad-vert"></div>
+ <eg-record-summary record='detail_hold_record'
+ record-id="detail_hold_record_id"></eg-record-summary>
+ <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
+
+<!-- catalog view for holds placement -->
+
+<div ng-if="placing_hold">
+ <eg-embed-frame url="catalog_url" handlers="handlers"
+ onchange="handle_page"></eg-embed-frame>
+</div>
--- /dev/null
+<!-- holds are created within the catalog -->
+
+<eg-embed-frame url="catalog_url"
+ handlers="handlers" onchange="handle_page"></eg-embed-frame>
--- /dev/null
+<eg-grid
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ persist-key="circ.patron.holds">
+
+ <eg-grid-menu-item handler="place_hold"
+ label="[% l('Place Hold') %]"></eg-grid-menu-item>
+ <eg-grid-menu-item handler="detail_view"
+ label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+ <eg-grid-action handler="grid_actions.show_recent_circs"
+ label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_copy_quality"
+ label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_pickup_lib"
+ label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_notify_prefs"
+ label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.edit_dates"
+ label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.activate"
+ label="[% l('Activate') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.suspend"
+ label="[% l('Suspend') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.set_top_of_queue"
+ label="[% l('Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.clear_top_of_queue"
+ label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+ label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_damaged"
+ label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.mark_missing"
+ label="[% l('Mark Item Missing') %]"></eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.retarget"
+ label="[% l('Find Another Target') %]"></eg-grid-action>
+ <eg-grid-action handler="grid_actions.cancel_hold"
+ label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+ <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+ <eg-grid-field label="[% l('Current Copy') %]"
+ path='hold.current_copy.barcode'>
+ <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+ {{item.hold.current_copy().barcode()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+ <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+ <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+ <a href="[% ctx.base_path %]/opac/record/{{item.mvr.doc_id()}}">
+ {{item.mvr.title()}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+ <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+ <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+ <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+ <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+ <div class="flex-cell"></div>
+ <div>
+ <button class="btn btn-default" ng-click="print()">
+ [% l('Print') %]
+ </button>
+ </div>
+</div>
+
--- /dev/null
+<!-- items out list -->
+
+<div ng-if="show_alt_circs">
+ <!-- only show the main vs. alt circ list tabs if the alt
+ circ list is meant to display -->
+ <ul class="nav nav-tabs">
+ <li ng-class="{active : items_out_display == 'main'}">
+ <a href ng-click="show_main_list()">
+ [% l('Items Checked Out') %] ({{main_list.length}})
+ </a>
+ </li>
+ <li ng-class="{active : items_out_display == 'alt'}">
+ <a href ng-click="show_alt_list()">
+ [% l('Other/Special Circulations') %] ({{alt_list.length}})
+ </a>
+ </li>
+ </ul>
+</div>
+<div ng-if="!show_alt_circs" class="strong-text-2">
+ [% l('Items Checked Out') %]
+</div>
+
+<div class="tab-content">
+ <div class="tab-pane active">
+<eg-grid
+ idl-class="circ"
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ persist-key="circ.patron.items_out">
+
+ <eg-grid-action handler="print_receipt"
+ label="[% l('Print Item Receipt') %]"></eg-grid-action>
+ <eg-grid-action handler="edit_due_date"
+ label="[% l('Edit Due Date') %]"></eg-grid-action>
+ <eg-grid-action handler="mark_lost"
+ label="[% l('Mark Lost (By Patron)') %]"></eg-grid-action>
+ <eg-grid-action handler="mark_claims_returned"
+ label="[% l('Mark Claims Returned') %]"></eg-grid-action>
+ <eg-grid-action handler="mark_claims_never_checked_out"
+ label="[% l('Mark Claims Never Checked Out') %]"></eg-grid-action>
+ <eg-grid-action handler="renew" label="[% l('Renew') %]"></eg-grid-action>
+ <eg-grid-action handler="renew_all" label="[% l('Renew All') %]"></eg-grid-action>
+ <eg-grid-action handler="renew_with_date"
+ label="[% l('Renew With Specific Due Date') %]"></eg-grid-action>
+ <eg-grid-action handler="checkin"
+ label="[% l('Check In') %]"></eg-grid-action>
+ <eg-grid-action handler="add_billing"
+ label="[% l('Add Billing') %]"></eg-grid-action>
+
+ <eg-grid-field label="[% l('Circ ID') %]" path='id'></eg-grid-field>
+ <eg-grid-field label="[% l('Barcode') %]" path='target_copy.barcode'>
+ <a href="./cat/item/{{item.target_copy().id()}}" target="_self">
+ {{item.target_copy().barcode()}}
+ </a>
+ </eg-grid-field>
+ <eg-grid-field label="[% l('Due Date') %]" path='due_date' dateformat='short'></eg-grid-field>
+ <eg-grid-field label="[% l('Checkout / Renewal Library') %]" path='circ_lib.shortname'></eg-grid-field>
+ <eg-grid-field label="[% l('Renewals Remaining') %]" path='renewal_remaining'></eg-grid-field>
+ <eg-grid-field label="[% l('Fines Stopped') %]" path='stop_fines'></eg-grid-field>
+ <eg-grid-field label="[% l('Title') %]" name="title">
+ <a href="[% ctx.base_path %]/opac/record/{{item.target_copy().call_number().record().id()}}">
+ {{item.target_copy().call_number().record().simple_record().title()}}
+ </a>
+ </eg-grid-field>
+ <eg-grid-field path="*" hidden></eg-grid-field>
+ <eg-grid-field path="target_copy.*" hidden></eg-grid-field>
+ <eg-grid-field path="target_copy.call_number.*" hidden></eg-grid-field>
+ <eg-grid-field path="target_copy.call_number.record.*" hidden></eg-grid-field>
+ <eg-grid-field path="target_copy.call_number.record.simple_record.*" hidden></eg-grid-field>
+</eg-grid>
+ </div>
+</div>
--- /dev/null
+<div class="col-md-6">
+ <div ng-if="no_last" class="alert alert-warning">
+ [% l('No patrons recently accessed.') %]
+ <span class="pad-horiz">
+ <a href='./circ/patron/search'>[% l('Try Patron Search') %]</a>
+ </span>
+ </div>
+ <br/>
+</div>
--- /dev/null
+
+<div class="strong-text-2">[% l('Staff-Generated Penalties / Messages') %]</div>
+<div class="pad-vert"></div>
+<eg-grid
+ idl-class="ausp"
+ grid-controls="activeGridControls">
+
+ <eg-grid-menu-item handler="createPenalty"
+ label="[% l('Apply Penalty / Message') %]"></eg-grid-menu-item>
+
+ <eg-grid-action label="[% l('Remove Penalty / Message') %]"
+ handler="removePenalty"></eg-grid-action>
+ <eg-grid-action label="[% l('Modify Penalty / Message') %]"
+ handler="editPenalty"></eg-grid-action>
+ <eg-grid-action label="[% l('Archive Penalty / Message') %]"
+ handler="archivePenalty"></eg-grid-action>
+
+ <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+ <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+ <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+ <eg-grid-field path="note"></eg-grid-field>
+ <eg-grid-field path="id" required hidden></eg-grid-field>
+ <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+ <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+<div class="pad-vert"><hr/></div>
+
+<div class="pad-vert flex-row padded">
+ <div class="strong-text-2">[% l('Archived Penalties / Messages') %]</div>
+ <div class="flex-cell"></div>
+ <div>[% l('Set Date Start:') %]</div>
+ <div><input eg-date-input class="form-control" ng-model="dates.start_date"/></div>
+ <div>[% l('Set Date End:') %]</div>
+ <div><input eg-date-input class="form-control" ng-model="dates.end_date"/></div>
+</div>
+<eg-grid
+ idl-class="ausp"
+ grid-controls="archiveGridControls">
+
+ <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+ <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+ <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+ <eg-grid-field path="note"></eg-grid-field>
+ <eg-grid-field path="id" required hidden></eg-grid-field>
+ <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+ <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+
--- /dev/null
+<div>
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 ng-if="!outbound" class="modal-title">
+ [% l('Move user into this group?') %]
+ </h4>
+ <h4 ng-if="outbound" class="modal-title">
+ [% l("Move selected users to the following user's group?") %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <a href="./circ/patron/{{user.id()}}/checkout" target="_self">
+ [%
+ l('[_1], [_2] [_3] : [_4]',
+ '{{user.family_name()}}',
+ '{{user.first_given_name()}}',
+ '{{user.second_given_name()}}',
+ '{{user.card().barcode()}}')
+ %]
+ </a>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-primary" ng-click="ok()">[% l('Move User') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
--- /dev/null
+<form ng-submit="ok(args)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Create a new note') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-title">[% l('Title') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="text" class="form-control" focus-me='focusNote' required
+ id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-pub">[% l('Patron Visible?') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="checkbox" class="checkbox"
+ id="note-pub" ng-model="args.pub"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-value">[% l('Value') %]</label>
+ </div>
+ <div class="col-md-9">
+ <textarea class="form-control" required
+ id="note-value" ng-model="args.value" placeholder="[% l('Value...') %]">
+ </textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+</form>
--- /dev/null
+<div class="row">
+ <div class="col-md-12">
+ <button class="btn btn-default" ng-click="newNote()">
+ [% l('Add New Note') %]
+ </button>
+ </div>
+</div>
+
+<div class="row pad-vert" ng-repeat="note in notes">
+ <div class="col-md-12">
+ <div class="row">
+ <div class="col-md-6 strong-text">{{note.title()}}</div>
+ <div class="col-md-6">
+ <div class="pull-right">
+ <span class="pad-horiz alert alert-warning" ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+ <span class="pad-horiz alert alert-info" ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+ <span class="pad-horiz">{{note.create_date() | date:'short'}}</span>
+ <span>[% l('Created by [_1]', '{{note.creator().usrname()}}') %]</span>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <!-- hmm, not sure why the margin-left is needed.. the well? -->
+ <div class="col-md-12 well" style="margin-left:12px">
+ <div class="row">
+ <div class="col-md-8">
+ <div class="">{{note.value()}}</div>
+ </div>
+ <div class="col-md-4">
+ <div class="pull-right">
+ <button ng-click="printNote(note)" class="btn btn-default">
+ [% l('Print') %]
+ </button>
+ <button ng-click="deleteNote(note)" class="btn btn-warning">
+ [% l('Delete') %]
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <hr/>
+</div>
--- /dev/null
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ <span>[% l('Pending Patrons') %]</span>
+ </div>
+</div>
+
+<span>[% l('Home Library: ' ) %]</span>
+<span><eg-org-selector selected="context_org"></eg-org-selector></span>
+<hr/>
+
+<eg-grid
+ id-field="id"
+ features="-sort,-multisort"
+ items-provider="grid_data_provider"
+ grid-controls="grid_controls"
+ persist-key="circ.pending_patrons.list">
+
+ <eg-grid-menu-item handler="load_patron"
+ label="[% l('Load Patron') %]"></eg-grid-menu-item>
+
+ <eg-grid-field path='user.row_date' label="[% l('Create Date') %]"></eg-grid-field>
+ <eg-grid-field path='user.first_given_name' label="[% l('First Name') %]"></eg-grid-field>
+ <eg-grid-field path='user.second_given_name' label="[% l('Middle Name') %]"></eg-grid-field>
+ <eg-grid-field path='user.family_name' label="[% l('Last Name') %]"></eg-grid-field>
+ <eg-grid-field path='user.email' label="[% l('Email') %]"></eg-grid-field>
+ <eg-grid-field path='user.home_ou.shortname' label="[% l('Home Library') %]"></eg-grid-field>
+ <eg-grid-field path='mailing_address.street1' label="[% l('Street 1') %]"></eg-grid-field>
+ <eg-grid-field path='mailing_address.city' label="[% l('City') %]"></eg-grid-field>
+ <eg-grid-field path='mailing_address.post_code' label="[% l('Post Code') %]"></eg-grid-field>
+ <eg-grid-field path='user.usrname' label="[% l('Requested Username') %]"></eg-grid-field>
+ <eg-grid-field path='user.*' parent-idl-class="stgu" hidden></eg-grid-field>
+ <eg-grid-field path='mailing_address.*' parent-idl-class="stgma" hidden></eg-grid-field>
+</eg-grid>
+
--- /dev/null
+<div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Renew Items with Specific Due Date') %]
+ </h4>
+</div>
+<div class="modal-body">
+ <div class="pad-vert row">
+ <div class="col-md-12">
+ [% l('Enter due date for items: [_1]', '{{args.barcodes.join(" ")}}') %]
+ </div>
+ </div>
+ <div class="pad-vert row">
+ <div class="col-md-5">
+ <input eg-date-input required
+ class="form-control" ng-model="args.date"/>
+ </div>
+ </div>
+</div>
+<div class="modal-footer">
+ <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
--- /dev/null
+
+<!-- TODO: inputs need sr-only labels
+ <label class="sr-only" for="input-id">label</label>
+-->
+
+<div class="row" id="patron-search-form-row">
+ <div class="col-md-11">
+ <form ng-submit="search(searchArgs)" id="patron-search-form"
+ role="form" class="form-horizontal">
+
+ <div class="form-group">
+
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ focus-me="focusMe"
+ ng-model="searchArgs.family_name" placeholder="[% l('Last Name') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.first_given_name" placeholder="[% l('First Name') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.second_given_name" placeholder="[% l('Middle Name') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <input type="submit" class="btn btn-default" value="[% l('Search') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <input type="reset" class="btn btn-default" ng-click="searchArgs={}"
+ value="[% l('Clear Form') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <button class="btn btn-default" ng-click="applyShowExtras($event, true)"
+ title="[% l('Show More Fields') %]" ng-show="!showExtras">
+ <span class="glyphicon glyphicon-circle-arrow-down"></span>
+ </button>
+ <button class="btn btn-default" ng-click="applyShowExtras($event, false)"
+ title="[% l('Show Fewer Fields') %]" ng-show="showExtras">
+ <span class="glyphicon glyphicon-circle-arrow-up"></span>
+ </button>
+ </div>
+ </div>
+
+ <div class="form-group" ng-show="showExtras">
+ <div class="col-md-2">
+ <input type="text" class="form-control" ng-model="searchArgs.card"
+ placeholder="[% l('Barcode') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.alias" placeholder="[% l('Alias') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.usrname" placeholder="[% l('Username') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.email" placeholder="[% l('Email') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.ident" placeholder="[% l('Identification') %]"/>
+ </div>
+ </div>
+
+ <div class="form-group" ng-show="showExtras">
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.id" placeholder="[% l('Database ID') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.phone" placeholder="[% l('Phone') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.street1" placeholder="[% l('Street 1') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.street2" placeholder="[% l('Street 2') %]"/>
+ </div>
+ <div class="col-md-2">
+ <input type="text" class="form-control"
+ ng-model="searchArgs.city" placeholder="[% l('City') %]"/>
+ </div>
+ </div>
+
+ <div class="form-group" ng-show="showExtras">
+ <div class="col-md-2">
+ <input type="text" class="form-control" ng-model="searchArgs.state"
+ placeholder="[% l('State') %]" title="[% l('State') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <input type="text" class="form-control" ng-model="searchArgs.post_code"
+ placeholder="[% l('Post Code') %]" title="[% l('Post Code') %]"/>
+ </div>
+
+ <div class="col-md-2">
+ <!--
+ <input type="text" class="form-control"
+ placeholder="[% l('Profile Group') %]"
+ ng-model="searchArgs.profile"
+ typeahead="grp as grp.name for grp in profiles | filter:$viewValue"
+ typeahead-editable="false" />
+ -->
+
+ <div class="btn-group patron-search-selector" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ <span style="padding-right: 5px;">{{searchArgs.profile.name() || "[% l('Profile Group') %]"}}</span>
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li ng-repeat="grp in profiles">
+ <a href dropdown-toggle
+ style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
+ ng-click="searchArgs.profile = grp">{{grp.name()}}</a>
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="col-md-2">
+ <eg-org-selector label="[% l('Home Library') %]"
+ selected="searchArgs.home_ou">
+ </eg-org-selector>
+ </div>
+
+ <div class="col-md-2">
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" ng-model="searchArgs.inactive"/>
+ [% l('Include Inactive?') %]
+ </label>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+</div>
+
+
+<br/>
+<div class="row">
+ <div class="col-md-12">
+ [% INCLUDE 'staff/circ/patron/t_search_results.tt2' %]
+ </div>
+</div>
+
+
--- /dev/null
+<eg-grid
+ idl-class="au" id-field="id"
+ features="-sort,-display,-multisort"
+ main-label="[% l('Patron Search Results') %]"
+ grid-controls="gridControls"
+ items-provider="patronSearchGridProvider"
+ persist-key="circ.patron.search">
+ <eg-grid-field label="[% ('ID') %]" path='id' visible></eg-grid-field>
+ <eg-grid-field label="[% ('Card') %]" path='card.barcode' visible></eg-grid-field>
+ <eg-grid-field label="[% ('Last Name') %]" path='family_name' visible sortable multisortable></eg-grid-field>
+ <eg-grid-field label="[% ('First Name') %]" path='first_given_name' visible sortable multisortable></eg-grid-field>
+ <eg-grid-field label="[% ('Middle Name') %]" path='second_given_name' visible sortable multisortable></eg-grid-field>
+ <eg-grid-field label="[% ('DoB') %]" path='dob' visible sortable multisortable></eg-grid-field>
+ <eg-grid-field label="[% ('Home Library') %]" path='home_ou.shortname' visible></eg-grid-field>
+ <eg-grid-field label="[% ('Created On') %]" path='create_date' visible sortable multisortable></eg-grid-field>
+
+ <eg-grid-field label="[% ('Mailing:Street 1') %]" path='mailing_address.street1' visible></eg-grid-field>
+ <eg-grid-field label="[% ('Mailing:Street 2') %]" path='mailing_address.street2'></eg-grid-field>
+ <eg-grid-field label="[% ('Mailing:City') %]" path='mailing_address.city'></eg-grid-field>
+ <eg-grid-field label="[% ('Mailing:County') %]" path='mailing_address.county'></eg-grid-field>
+ <eg-grid-field label="[% ('Mailing:State') %]" path='mailing_address.state'></eg-grid-field>
+ <eg-grid-field label="[% ('Mailing:Zip') %]" path='mailing_address.post_code'></eg-grid-field>
+
+ <eg-grid-field label="[% ('Billing:Street 1') %]" path='billing_address.street1'></eg-grid-field>
+ <eg-grid-field label="[% ('Billing:Street 2') %]" path='billing_address.street2'></eg-grid-field>
+ <eg-grid-field label="[% ('Billing:City') %]" path='billing_address.city'></eg-grid-field>
+ <eg-grid-field label="[% ('Billing:County') %]" path='billing_address.county'></eg-grid-field>
+ <eg-grid-field label="[% ('Billing:State') %]" path='billing_address.state'></eg-grid-field>
+ <eg-grid-field label="[% ('Billing:Zip') %]" path='billing_address.post_code'></eg-grid-field>
+</eg-grid>
--- /dev/null
+<div class="row pad-vert" ng-repeat="map in patron().stat_cat_entries()">
+ <div class="col-md-12 well" style="margin-left:12px">
+ <div class="row">
+ <div class="col-md-7">
+ <span class="strong-text">{{map.stat_cat().name()}}</span>
+ <span class="pad-horiz">{{map.stat_cat_entry()}}</span>
+ </div>
+ <div class="col-md-5">
+ <div class="pull-right">
+ <span class="pad-horiz alert alert-warning"
+ ng-if="map.stat_cat().opac_visible() == 't'">[% l('Patron Visible') %]</span>
+ <span class="pad-horiz alert alert-info"
+ ng-if="map.stat_cat().opac_visible() == 'f'">[% l('Staff Only') %]</span>
+ <span>[% l('@ [_1]', '{{map.stat_cat().owner().shortname()}}') %]</span>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+
+<div ng-cloak>
+ <div ng-show="patron()" id="patron-summary-grid">
+ <div class="row"
+ ng-class="{'patron-summary-divider' : !$index}"
+ ng-repeat="penalty in alert_penalties()">
+ <div
+ class="col-md-9 patron-summary-alert"
+ title="{{penalty.standing_penalty().name()}}">
+ {{penalty.note() || penalty.standing_penalty().label()}}
+ </div>
+ <div class="col-md-3">
+ {{penalty.set_date() | date:'shortDate'}}
+ </div>
+ </div>
+ <div class="row"
+ ng-class="{'patron-summary-divider' : alert_penalties().length}">
+ <div class="col-md-5">[% l('Profile') %]</div>
+ <div class="col-md-7">{{patron().profile().name()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Home Library') %]</div>
+ <div class="col-md-7">{{patron().home_ou().shortname()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Net Access') %]</div>
+ <div class="col-md-7">{{patron().net_access_level().name()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Last Activity') %]</div>
+ <div class="col-md-7">{{patron().usr_activity()[0].event_time() | date:'shortDate'}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Last Updated') %]</div>
+ <div class="col-md-7">{{patron().last_update_time() | date:'shortDate'}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Create Date') %]</div>
+ <div class="col-md-7">{{patron().create_date() | date:'shortDate'}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Expire Date') %]</div>
+ <div class="col-md-7">{{patron().expire_date() | date:'shortDate'}}</div>
+ </div>
+ <div class="row patron-summary-divider"
+ ng-class="{'patron-summary-alert' : patron_stats().fines.balance_owed}">
+ <div class="col-md-5">[% l('Fines Owed') %]</div>
+ <div class="col-md-7">
+ {{patron_stats().fines.balance_owed | currency}}
+ </div>
+ </div>
+ <div class="row"
+ ng-show="patron_stats().fines.group_balance_owed > patron_stats().fines.balance_owed"
+ ng-class="{'patron-summary-alert' : patron_stats().fines.group_balance_owed}">
+ <div class="col-md-5">[% l('Group Fines') %]</div>
+ <div class="col-md-7">
+ {{patron_stats().fines.group_balance_owed | currency}}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Items Out') %]</div>
+ <div class="col-md-7">{{patron_stats().checkouts.out}}</div>
+ </div>
+ <div class="row"
+ ng-class="{'patron-summary-alert' : patron_stats().checkouts.overdue}">
+ <div class="col-md-5">[% l('Overdue') %]</div>
+ <div class="col-md-7">{{patron_stats().checkouts.overdue}}</div>
+ </div>
+ <div class="row"
+ ng-class="{'patron-summary-alert' : patron_stats().checkouts.long_overdue}">
+ <div class="col-md-5">[% l('Long Overdue') %]</div>
+ <div class="col-md-7">{{patron_stats().checkouts.long_overdue}}</div>
+ </div>
+ <div class="row"
+ ng-class="{'patron-summary-alert' : patron_stats().checkouts.claims_returned}">
+ <div class="col-md-5">[% l('Claimed Returned') %]</div>
+ <div class="col-md-7">{{patron_stats().checkouts.claims_returned}}</div>
+ </div>
+ <div class="row"
+ ng-class="{'patron-summary-alert' : patron_stats().checkouts.lost}">
+ <div class="col-md-5">[% l('Lost') %]</div>
+ <div class="col-md-7">{{patron_stats().checkouts.lost}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Holds') %]</div>
+ <div class="col-md-7">
+ {{patron_stats().holds.total}} / {{patron_stats().holds.ready}}
+ </div>
+ </div>
+ <div class="row patron-summary-divider">
+ <div class="col-md-5">[% l('Card') %]</div>
+ <div class="col-md-7">{{patron().card().barcode()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Username') %]</div>
+ <div class="col-md-7">{{patron().usrname()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Day Phone') %]</div>
+ <div class="col-md-7">{{patron().day_phone()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Evening Phone') %]</div>
+ <div class="col-md-7">{{patron().evening_phone()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Other Phone') %]</div>
+ <div class="col-md-7">{{patron().other_phone()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('ID1') %]</div>
+ <div class="col-md-7">{{patron().ident_type().name()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('ID2') %]</div>
+ <div class="col-md-7">{{patron().ident_type2().name()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-5">[% l('Email') %]</div>
+ <div class="col-md-7">{{patron().email()}}</div>
+ </div>
+ <div class="row" ng-repeat="map in summary_stat_cats()">
+ <div class="col-md-5">{{map.stat_cat().name()}}</div>
+ <div class="col-md-7">{{map.stat_cat_entry()}}</div>
+ </div>
+ </div>
+
+ <div class="row" ng-repeat="addr in patron().addresses()">
+ <div class="panel">
+ <div class="panel-body">
+ <fieldset>
+ <legend>
+ {{addr.address_type()}}
+ <a href class="pad-horiz patron-summary-act-link"
+ ng-click="print_address(addr)">[% l('(print)') %]</a>
+ </legend>
+ <div>{{addr.street1()}} {{addr.street2()}}</div>
+ <div>{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</div>
+ </fieldset>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+<!-- insert the patron registration UI -->
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
--- /dev/null
+<h3>[% l('Transaction #[_1]', '{{xact.id()}}') %]</h3>
+
+<div class="row">
+ <div class="col-md-2 strong-text">[% l('Billing Location') %]</div>
+ <div class="col-md-2">{{xact.billing_location().shortname()}}</div>
+ <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+ <div class="col-md-2">{{xact.summary().total_owed() | currency}}</div>
+ <div class="col-md-2 strong-text">[% l('Title') %]</div>
+ <div class="col-md-2">
+ <a ng-if="title_id" href="[% ctx.base_path %]/opac/record/{{title_id}}">{{title}}</a>
+ <span ng-if="!title_id">{{title}}</span>
+ </div>
+</div>
+<div class="row">
+ <div class="col-md-2 strong-text">[% l('Type') %]</div>
+ <div class="col-md-2">{{xact.summary().xact_type()}}</div>
+ <div class="col-md-2 strong-text">[% l('Total Paid') %]</div>
+ <div class="col-md-2">{{xact.summary().total_paid() | currency}}</div>
+ <div class="col-md-2 strong-text">[% l('Checked Out') %]</div>
+ <div class="col-md-2">{{xact.circulation().xact_start() | date:'short'}}</div>
+</div>
+<div class="row">
+ <div class="col-md-2 strong-text">[% l('Start') %]</div>
+ <div class="col-md-2">{{xact.xact_start() | date:'short'}}</div>
+ <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+ <div class="col-md-2">{{xact.summary().balance_owed() | currency}}</div>
+ <div class="col-md-2 strong-text">[% l('Due Date') %]</div>
+ <div class="col-md-2">{{xact.circulation().due_date() | date:'short'}}</div>
+</div>
+<div class="row">
+ <div class="col-md-2 strong-text">[% l('Finish') %]</div>
+ <div class="col-md-2">{{xact.xact_finish() | date:'short'}}</div>
+ <div class="col-md-2 strong-text">[% l('Renewal?') %]</div>
+ <div class="col-md-2">
+ <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+ <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+ <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+ </div>
+ <div class="col-md-2 strong-text">[% l('Checked In') %]</div>
+ <div class="col-md-2">{{xact.circulation().checkin_time() | date:'short'}}</div>
+</div>
+
+<div ng-if="xact.circulation()">
+ <hr/>
+ <h3>[% l('Item Summary') %]</h3>
+ <div class="row">
+ <div class="col-md-2 strong-text">[% l('Barcode') %]</div>
+ <div class="col-md-2">
+ <a title="[% l('Item Details') %]" target="_self"
+ href='./cat/item/{{xact.circulation().target_copy().id()}}'>
+ {{xact.circulation().target_copy().barcode()}}
+ </a>
+ </div>
+ <div class="col-md-2 strong-text">[% l('Location') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().location().name()}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('Call Number') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().call_number().label()}}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-2 strong-text">[% l('Status') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().status().name()}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('Circulate') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().circulate() == 't'}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('Reference') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().ref() == 't'}}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-2 strong-text">[% l('Holdable') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().holdable() == 't'}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('OPAC Visible') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().opac_visible() == 't'}}
+ </div>
+
+ <div class="col-md-2 strong-text">[% l('Created') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().create_date() | date:'short'}}
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-2 strong-text">[% l('Edited') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().edit_date() | date:'short'}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('Age Protect') %]</div>
+ <div class="col-md-2">
+ {{xact.circulation().target_copy().age_protect().name()}}
+ </div>
+ <div class="col-md-2 strong-text">[% l('Total Circulations') %]</div>
+ <div class="col-md-2">
+ TODO
+ </div>
+ </div>
+</div>
+
+
+<!-- set a lower default page size (limit) to allow for more space -->
+<hr/>
+<eg-grid
+ main-label="[% l('Bills') %]"
+ idl-class="mb"
+ id-field="id"
+ grid-controls="xactGridControls"
+ auto-fields="true"
+ page-size="10">
+
+ <eg-grid-action
+ label="[% l('Void Billings') %]" handler="voidBillings"></eg-grid-action>
+
+ <eg-grid-action
+ label="[% l('Edit Note') %]" handler="editBillNotes"></eg-grid-action>
+
+</eg-grid>
+
+<!-- TODO: this grid may contain objects (payments) of different types..
+ apply manual columns, see xul -->
+<!-- NOTE: sorting disabled since payments are fetched via non-sortable API -->
+<br/>
+<eg-grid
+ main-label="[% l('Payments') %]"
+ idl-class="mp"
+ id-field="id"
+ auto-fields="true"
+ grid-controls="paymentGridControls"
+ page-size="10">
+ <eg-grid-action
+ label="[% l('Edit Note') %]" handler="editPaymentNotes"></eg-grid-action>
+
+ <eg-grid-field path="cash_payment.cash_drawer.name"
+ label="[% l('Cash Drawer') %]"></eg-grid-field>
+</eg-grid>
+
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Renew");
+ ctx.page_app = "egRenewApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/renew/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
--- /dev/null
+<!-- item renewal form / list -->
+
+<div class="container-fluid" style="text-align:center">
+ <div class="alert alert-info alert-less-pad strong-text-2">
+ [% l('Renew Items') %]
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-6">
+ <form ng-submit="renew(renewalArgs)" role="form" class="form-inline">
+ <div class="input-group">
+
+ <label class="input-group-addon"
+ for="patron-renewal-barcode" >[% l('Barcode') %]</label>
+
+ <input focus-me="focusBarcode" class="form-control"
+ ng-model="renewalArgs.copy_barcode"
+ id="patron-renewal-barcode" type="text"/>
+
+ <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+ </div>
+ </form>
+ </div>
+ <div class="col-md-6">
+ <div class="flex-row">
+ <div class="flex-cell"></div>
+ <div class="checkbox pad-horiz">
+ <label>
+ <input type="checkbox" ng-model="renewalArgs.sticky_date"/>
+ [% l('Specific Due Date') %]
+ </label>
+ </div>
+ <!-- FIXME: This needs a time component as well, but type="datetime"
+ is not yet supported by any browsers -->
+ <div><input eg-date-input class="form-control" ng-model="renewalArgs.due_date"/>
+ </div>
+ </div>
+ </div>
+</div>
+<hr/>
+
+<eg-grid
+ id-field="index"
+ features="-sort,-multisort"
+ items-provider="gridDataProvider"
+ grid-controls="gridControls"
+ persist-key="circ.renew">
+
+ <eg-grid-action
+ handler="fetchLastCircPatron"
+ label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+ </eg-grid-action>
+ <eg-grid-action
+ handler="showLastFewCircs"
+ label="[% l('Show Last Few Circluations') %]">
+ </eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action
+ handler="showMarkDamaged"
+ label="[% l('Mark Items Damaged') %]">
+ </eg-grid-action>
+ <eg-grid-action divider="true"></eg-grid-action>
+ <eg-grid-action
+ handler="abortTransit"
+ label="[% l('Abort Transits') %]">
+ </eg-grid-action>
+
+
+ <eg-grid-field label="[% l('Alert Msg') %]"
+ path="acp.alert_message"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Balance Owed') %]"
+ path='mbts.balance_owed'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+ <!-- FIXME: ng-if / ng-disabled not working since the contents
+ are $interpolate'd and not $compile'd.
+ I want to hide / disable the href when there is no acp ID
+ -->
+ <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+ {{item.copy_barcode}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Bill #') %]"
+ path='circ.id'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Call Number') %]"
+ path="acn.label"></eg-grid-field>
+
+ <eg-grid-field label="[% l('Due Date') %]"
+ path='circ.due_date' dateformat='short'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Family Name') %]"
+ path='au.family_name'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Location') %]"
+ path='acp.location.name'> </eg-grid-field>
+
+ <eg-grid-field label="[% l('Remaining Renewals') %]"
+ path='circ.renewal_remaining'></eg-grid-field>
+
+ <eg-grid-field label="[% l('Title') %]" path="title">
+ <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+ {{item.title}}
+ </a>
+ </eg-grid-field>
+
+ <eg-grid-field label="[% l('Author') %]"
+ path="author" hidden></eg-grid-field>
+
+ <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+ <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+ <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+ <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+ <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+ <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+ <div class="flex-cell"></div>
+ <div class="pad-horiz">
+ <button class="btn btn-default"
+ ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+ </div>
+ <div class="checkbox">
+ <label>
+ <input ng-model="trim_list" type="checkbox"/>
+ [% l('Trim List (20 Rows)') %]
+ </label>
+ </div>
+ <div class="pad-horiz"></div>
+ <div class="checkbox">
+ <label>
+ <input ng-model="strict_barcode" type="checkbox"/>
+ [% l('Strict Barcode') %]
+ </label>
+ </div>
+</div>
+
--- /dev/null
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.PATRON_CARD_INACTIVE =
+ "[% l('The card used to retrieve this account is inactive and may not be used to circulate items.') %]";
+s.PATRON_INACTIVE =
+ "[% l('This account is inactive and may not circulate items.') %]";
+s.PATRON_ACCOUNT_EXPIRED =
+ "[% l('This account has expired and may not circulate items.') %]";
+s.CIRC_CLAIMS_RETURNED =
+ '[% l('Item "[_1]" is marked as Claims Returned', '{{barcode}}') %]';
+s.CHECKOUT_FAILED_GENERIC =
+ '[% l('Unable to checkout copy "[_1]" : [_2]', '{{barcode}}', '{{textcode}}') %]';
+s.COPY_ALERT_MSG_DIALOG_TITLE =
+ '[% l('Copy Alert Message for "[_1]"', '{{copy_barcode}}') %]';
+s.UNCAT_ALERT_DIALOG =
+ '[% l('Copy "[_1]" was mis-scanned or is not cataloged', '{{copy_barcode}}') %]';
+s.PERMISSION_DENIED =
+ '[% l('Permission Denied : [_1]', '{{permission}}') %]';
+s.PRECAT_CHECKIN_MSG =
+ '[% l("This item needs to be routed to CATALOGING") %]';
+s.LOCATION_ALERT_MSG =
+ '[% l("Item [_1] needs to be routed to [_2]",
+ "{{copy.barcode()}}","{{copy.location().name()}}") %]';
+s.MARK_DAMAGED_CONFIRM = '[% l("Mark {{num_items}} items as DAMAGED?") %]';
+s.MARK_MISSING_CONFIRM = '[% l("Mark {{num_items}} items as MISSING?") %]';
+s.ABORT_TRANSIT_CONFIRM = '[% l("Abort {{num_transits}} transits?") %]';
+s.ROUTE_TO_HOLDS_SHELF = '[% l("Holds Shelf") %]';
+s.ROUTE_TO_CATALOGING = '[% l("Cataloging") %]';
+s.COPY_IN_TRANSIT = '[% l("Copy is In-Transit") %]';
+s.TOO_MANY_CLAIMS_RETURNED =
+ '[% l("Patron exceeds claims returned count. Force this action?") %]';
+s.MARK_NEVER_CHECKED_OUT =
+ '[% l("Mark Never Checked Out: [_1]", "{{barcodes.toString()}}") %]'
+}]);
+</script>
+
+
--- /dev/null
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s['HOLD_STATUS_-1'] = "[% l('Error (-1)') %]";
+s.HOLD_STATUS_1 = "[% l('Waiting for Copy') %]";
+s.HOLD_STATUS_2 = "[% l('Waiting for Capture') %]";
+s.HOLD_STATUS_3 = "[% l('In Transit') %]";
+s.HOLD_STATUS_4 = "[% l('Ready for Pickup') %]";
+s.HOLD_STATUS_5 = "[% l('Hold Shelf Delay') %]";
+s.HOLD_STATUS_6 = "[% l('Canceled') %]";
+s.HOLD_STATUS_7 = "[% l('Suspended') %]";
+s.HOLD_STATUS_8 = "[% l('Wrong Shelf') %]";
+s.ACTIVATE_HOLDS = "[% l('Activate [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SUSPEND_HOLDS = "[% l('Suspend [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SET_TOP_OF_QUEUE =
+ "[% l('Move [_1] Hold(s) to the front of the holds queue above other holds that are not likewise flagged as Top of Queue?',
+ '{{num_holds}}') %]";
+s.CLEAR_TOP_OF_QUEUE =
+ "[% l('Unset the Top of Queue flag for [_1] Hold(s)?', '{{num_holds}}') %]";
+s.TRANSFER_HOLD_TO_TITLE =
+ "[% l('Tranfer [_1] Hold(s) to bib record ID [_2]?', '{{num_holds}}', '{{bib_id}}') %]";
+s.NO_HOLD_TRANSFER_TITLE_MARKED =
+ "[% l('No record is marked as a hold transfer target!') %]";
+s.RETARGET_HOLDS =
+ "[% l('Reset hold(s) [_1]?', '{{hold_ids}}') %]";
+}]);
+</script>
+
+
--- /dev/null
+<div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Backdate Already Checked-In Circulations') %]
+ </h4>
+</div>
+<div class="modal-body">
+ <div>[% l('Number of circulations selected: [_1]', '{{dialog.num_circs}}') %]</div>
+ <div class="pad-vert">
+ <progress max="dialog.num_circs" value="dialog.num_processed"></progress>
+ </div>
+ <div class="pad-vert row">
+ <div class="col-md-6">[% l('Effective Date:') %]</div>
+ <div class="col-md-6">
+ <input eg-date-input required
+ class="form-control" ng-model="dialog.backdate"/>
+ </div>
+ </div>
+</div>
+<div class="modal-footer">
+ <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
--- /dev/null
+<div>
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Bad Barcode') %]</h4>
+ </div>
+ <div class="modal-body">
+ <img src="[% ctx.media_prefix %]/images/bad_barcode.png"/>
+ <div>
+[% |l('{{barcode}}') %]
+Bad check digit, possibly due to a bad scan. Use this barcode ("[_1]") anyway?
+[% END %]
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary"
+ ng-click="ok()" value="[% l('Accept Barcode') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
+
--- /dev/null
+<!-- edit bucket dialog -->
+<form ng-submit="ok(billArgs)" role="form" class="form-horizontal">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Bill Patron: [_1], [_2] [_3] : [_4]',
+ '{{patron.family_name()}}',
+ '{{patron.first_given_name()}}',
+ '{{patron.second_given_name()}}',
+ '{{patron.card().barcode()}}') %]
+ </h4>
+
+ <div ng-if="xact">
+ <hr/>
+ <div class="row">
+ <div class="col-md-3">[% l('Bill #') %]</div>
+ <div class="col-md-3">{{xact.id}}</div>
+ <div class="col-md-3">[% l('Total Billed') %]</div>
+ <div class="col-md-3">{{xact.summary.total_owed | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-3">[% l('Type') %]</div>
+ <div class="col-md-3">{{xact.summary.xact_type}}</div>
+ <div class="col-md-3">[% l('Total Paid') %]</div>
+ <div class="col-md-3">{{xact.summary.total_paid | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-3">[% l('Start') %]</div>
+ <div class="col-md-3">{{xact.xact_start | date:'short'}}</div>
+ <div class="col-md-3">[% l('Total Billed') %]</div>
+ <div class="col-md-3">{{xact.summary.balance_owed | currency}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-3">[% l('Finish') %]</div>
+ <div class="col-md-3">{{xact.xact_finish | date:'short'}}</div>
+ <div class="col-md-3">[% l('Renewal?') %]</div>
+ <div class="col-md-3">
+ <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+ <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+ <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="bill-dialog-location" class="control-label col-md-4">
+ [% l('Location:') %]
+ </label>
+ <div class="col-md-8">
+ <p class="form-control-static">{{location.shortname()}}</p>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="bill-dialog-type" class="control-label col-md-4">
+ [% l('Billing Type:') %]
+ </label>
+ <div class="col-md-8">
+ <select ng-model="billArgs.billingType" class="form-control"
+ ng-change="updateDefaultPrice()">
+ <option ng-repeat="type in billingTypes" value="{{type.id()}}">
+ {{type.name()}}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="bill-dialog-amount" class="control-label col-md-4">[% l('Amount:') %]</label>
+ <div class="col-md-8">
+ <input type="number" min="0" step="any" class="form-control"
+ focus-me='focus' required id="bill-dialog-amount"
+ ng-model="billArgs.amount"/>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="bill-dialog-note" class="control-label col-md-4">[% l('Note:') %]</label>
+ <div class="col-md-8">
+ <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+ id="bill-dialog-note" ng-model="billArgs.note"></textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-success" value="[% l('Submit Bill') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+ </div>
+</form>
+
+
--- /dev/null
+<form ng-submit="ok()" role="form" class="form-horizontal">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Cancel [_1] Hold(s)', '{{args.num_holds}}') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="hold-cancel-reason" class="control-label col-md-4">
+ [% l('Cancel Reason:') %]
+ </label>
+ <div class="col-md-8">
+ <select class="form-control" id="hold-cancel-reason"
+ ng-model="args.cancel_reason"
+ ng-options="reason.label() for reason in args.cancel_reasons">
+ </select>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="hold-cancel-note" class="control-label col-md-4">
+ [% l('Note:') %]
+ </label>
+ <div class="col-md-8">
+ <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+ id="hold-cancel-note" ng-model="args.note"></textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-success" value="[% l('Cancel Hold') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Exit') %]</button>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+<form class="form-validated" novalidate ng-submit="ok()" name="form">
+ <div>
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Open Circulation') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div ng-if="sameUser">
+ [% |l("{{circDate | date:'shortDate'}}") %]
+ There is an open circulation on the requested item.
+ This item was already checked out to this user on [_1].
+ [% END %]
+ </div>
+ <div ng-if="!sameUser">
+ [% |l("{{circDate | date:'shortDate'}}") %]
+ There is an open circulation on the requested item.
+ This copy was checked out by another patron on [_1].
+ [% END %]
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary"
+ value="[% l('Normal Checkin then Checkout') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+ </div> <!-- modal-dialog -->
+</form>
--- /dev/null
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Copy In Transit') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="strong-text">
+ [% l('There is an open transit on copy [_1]',
+ '{{transit.target_copy().barcode()}}') %]
+ </div>
+ <div class="pad-vert"></div>
+ <div class="row">
+ <div class="col-md-4">[% l('Transit Date:') %]</div>
+ <div class="col-md-8">{{transit.source_send_time() | date:'short'}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Transit Source:') %]</div>
+ <div class="col-md-8">{{transit.source().shortname()}}</div>
+ </div>
+ <div class="row">
+ <div class="col-md-4">[% l('Transit Destination:') %]</div>
+ <div class="col-md-8">{{transit.dest().shortname()}}</div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" ng-click="ok()"
+ value="[% l('Abort Transit then Checkout') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
--- /dev/null
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Copy Not Available.') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="alert alert-warning">
+ [% l('Copy Status: [_1]', '{{copyStatus.name()}}') %]
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" ng-click="ok()"
+ value="[% l('Force this action?') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
--- /dev/null
+<form ng-submit="ok()" role="form">
+ <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">
+ <div ng-if="copy_barcode" class="strong-text-2">{{copy_barcode}}</div>
+ {{evt.desc}}
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <i ng-if="auto_override">[% |l %]If overridden, subsequent checkouts during this patron's
+ session will auto-override this event[% END %]</i>
+ <br/><br/>
+ <input type="submit" class="btn btn-primary"
+ value="[% l('Force Action?') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+</form>
--- /dev/null
+<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('Accept only "Good Quality" copies?') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <div class="col-md-4">
+ <button class="btn btn-default" ng-click="good()">[% l('Good Condition') %]</button>
+ </div>
+ <div class="col-md-4">
+ <button class="btn btn-default" ng-click="any()">[% l('Any Condition') %]</button>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
+
--- /dev/null
+<div class="modal-content" id='hold-notify-settings'>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Modify Dates for [_1] Hold(s)', '{{num_holds}}') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="row header-row">
+ <div class="col-md-12">
+ [% l('Check the checkbox next to each field you wish to modify.') %]
+ </div>
+ </div>
+ <hr/>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="modify_thaw_date" class="sr-only">[% l('Update Activate Email') %]</label>
+ <input id='modify_thaw_date' ng-model="args.modify_thaw_date" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='thaw_date'>[% l("Hold Activate Date") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id='thaw_date' eg-date-input
+ ng-disabled="!args.modify_thaw_date" ng-model="args.thaw_date"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="modify_request_time" class="sr-only">[% l('Update Phone Number') %]</label>
+ <input id='modify_request_time' ng-model="args.modify_request_time" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='request_time'>[% l("Hold Request Date") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id='request_time' eg-date-input
+ ng-disabled="!args.modify_request_time" ng-model="args.request_time"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="modify_expire_time" class="sr-only">[% l('Update Expire Time') %]</label>
+ <input id='modify_expire_time' ng-model="args.modify_expire_time" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='expire_time'>[% l("Hold Expire Date") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id='expire_time' eg-date-input
+ ng-disabled="!args.modify_expire_time" ng-model="args.expire_time"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="modify_shelf_expire_time" class="sr-only">[% l('Update SMS Carrier') %]</label>
+ <input id='modify_shelf_expire_time' ng-model="args.modify_shelf_expire_time" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='shelf_expire_time'>[% l("Shelf Expire Date") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id='shelf_expire_time' eg-date-input
+ ng-disabled="!args.modify_shelf_expire_time" ng-model="args.shelf_expire_time"/>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
+
--- /dev/null
+<!-- hold info -->
+<h4 class="pad-vert">[% l('Hold Details') %]</h4>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Request Date') %]</div>
+ <div class="flex-cell well">{{hold.request_time() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Capture Date') %]</div>
+ <div class="flex-cell well">{{hold.capture_time() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Available On') %]</div>
+ <div class="flex-cell well">{{hold.shelf_time() | date:'short'}}</div>
+ </div>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Hold Type') %]</div>
+ <div class="flex-cell well">{{hold.hold_type()}}</div>
+ <div class="flex-cell">[% l('Current Copy') %]</div>
+ <div class="flex-cell well">
+ <a href="./cat/item/{{hold.current_copy().id()}}" target="_self">
+ {{hold.current_copy().barcode()}}
+ </a>
+ </div>
+ <div class="flex-cell">[% l('Call Number') %]</div>
+ <div class="flex-cell well">{{volume.label()}}</div>
+</div>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Pickup Lib') %]</div>
+ <div class="flex-cell well">{{hold.pickup_lib().shortname()}}</div>
+ <div class="flex-cell">[% l('Status') %]</div>
+ <div class="flex-cell well">{{status_string}}</div>
+ <div class="flex-cell">[% l('Behind Desk') %]</div>
+ <div class="flex-cell well">{{hold.behind_desk() == 't'}}</div>
+</div>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+ <div class="flex-cell well">{{hold.current_shelf_lib().shortname()}}</div>
+ <div class="flex-cell">[% l('Current Copy Location') %]</div>
+ <div class="flex-cell well">{{copy.location().name()}}</div>
+ <div class="flex-cell">[% l('Force Copy Quality') %]</div>
+ <div class="flex-cell well">{{hold.mint_condition() == 't'}}</div>
+</div>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Email Notify') %]</div>
+ <div class="flex-cell well">{{hold.email_notify() == 't'}}</div>
+ <div class="flex-cell">[% l('Phone Notify') %]</div>
+ <div class="flex-cell well">{{hold.phone_notify()}}</div>
+ <div class="flex-cell">[% l('SMS Notify') %]</div>
+ <div class="flex-cell well">{{hold.sms_notify()}}</div>
+</div>
+<div class="flex-row">
+ <div class="flex-cell">[% l('Cancel Cause') %]</div>
+ <div class="flex-cell well">{{hold.cancel_cause().label()}}</div>
+ <div class="flex-cell">[% l('Cancel Time') %]</div>
+ <div class="flex-cell well">{{hold.cancel_time() | date:'short'}}</div>
+ <div class="flex-cell">[% l('Cancel Note') %]</div>
+ <div class="flex-cell well">{{hold.cancel_note()}}</div>
+</div>
+
+<ul class="nav nav-tabs pad-vert" ng-init="detail_tab='notes'">
+ <li ng-class="{active : detail_tab == 'notes'}">
+ <a href ng-click="detail_tab = 'notes'">[% l('Notes') %]</a>
+ </li>
+ <li ng-class="{active : detail_tab == 'notify'}">
+ <a href ng-click="show_notify_tab()">
+ [% l('Staff Notifications') %]
+ </a>
+ </li>
+</ul>
+<div class="tab-content">
+ <div class="tab-pane active">
+
+ <div ng-if="detail_tab == 'notes'">
+
+ <button class="btn btn-default" ng-click="new_note()">
+ [% l('New Note') %]
+ </button>
+
+ <div class="row pad-vert" ng-repeat="note in hold.notes()">
+ <div class="col-md-12">
+ <div class="row">
+ <div class="col-md-6 strong-text">{{note.title()}}</div>
+ <div class="col-md-6">
+ <div class="pull-right">
+ <span class="pad-horiz alert alert-info"
+ ng-if="note.slip() == 't'">[% l('Print on Slip') %]</span>
+ <span class="pad-horiz alert alert-warning"
+ ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+ <span class="pad-horiz alert alert-info"
+ ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+ <span class="pad-horiz alert alert-info"
+ ng-if="note.staff() == 't'">[% l('Staff Created') %]</span>
+ <span class="pad-horiz alert alert-info"
+ ng-if="note.staff() == 'f'">[% l('Patron Created') %]</span>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <!-- hmm, not sure why the margin-left is needed.. the well? -->
+ <div class="col-md-12 well" style="margin-left:12px">
+ <div class="row">
+ <div class="col-md-8">
+ <div class="">{{note.body()}}</div>
+ </div>
+ <div class="col-md-4">
+ <div class="pull-right">
+ <button ng-click="delete_note(note)" class="btn btn-warning">
+ [% l('Delete') %]
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div><!-- notes tab content -->
+
+ <div ng-if="detail_tab == 'notify'">
+
+ <button class="btn btn-default" ng-click="new_notification()">
+ [% l('Add Record of Notification') %]
+ </button>
+
+ <div class="row pad-vert"
+ ng-repeat="notify in hold.notifications()">
+ <div class="col-md-12">
+ <div class="row">
+ <div class="col-md-6 strong-text">{{notify.method()}}</div>
+ <div class="col-md-6">
+ <div class="pull-right">
+ <span class="pad-horiz">{{notify.notify_time() | date:'short'}}</span>
+ <span>[% l('Created by [_1]', '{{notify.notify_staff().usrname()}}') %]</span>
+ </div>
+ </div>
+ </div>
+ <div class="row">
+ <!-- hmm, not sure why the margin-left is needed.. the well? -->
+ <div class="col-md-12 well" style="margin-left:12px">
+ <div class="row">
+ <div class="col-md-8">
+ <div class="">{{notify.note()}}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div><!-- notes tab content -->
+
+ </div><!-- tab pane -->
+</div><!-- tab-content -->
+
--- /dev/null
+<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 hold pickup library') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <div class="col-md-4">[% l('Select Library:') %]</div>
+ <div class="col-md-8">
+ <eg-org-selector selected="args.org_unit"></eg-org-selector>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
+
--- /dev/null
+<form ng-submit="ok(args)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Create a new note') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-pub">[% l('Patron Visible?') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="checkbox" class="checkbox"
+ id="note-pub" ng-model="args.pub"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-slip">[% l('Print on Slip?') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="checkbox" class="checkbox"
+ id="note-slip" ng-model="args.slip"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-title">[% l('Title') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="text" class="form-control" focus-me='focusNote' required
+ id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-body">[% l('Note Body') %]</label>
+ </div>
+ <div class="col-md-9">
+ <textarea class="form-control" required
+ id="note-body" ng-model="args.body" placeholder="[% l('Note Body...') %]">
+ </textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+</form>
--- /dev/null
+<form ng-submit="ok(args)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Create Record of Hold Notification') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-method">[% l('Notification Method') %]</label>
+ </div>
+ <div class="col-md-9">
+ <input type="text" class="form-control" focus-me='focusNote' required
+ id="note-method" ng-model="args.method" placeholder="[% l('Notification Method...') %]"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-3">
+ <label for="note-note">[% l('Note') %]</label>
+ </div>
+ <div class="col-md-9">
+ <textarea class="form-control" required
+ id="note-note" ng-model="args.note" placeholder="[% l('Note') %]">
+ </textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+</form>
--- /dev/null
+<div class="modal-content" id='hold-notify-settings'>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Edit Notification Settings for [_1] Hold(s)', '{{num_holds}}') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="row header-row">
+ <div class="col-md-12">
+ [% l('Check the checkbox next to each field you wish to modify.') %]
+ </div>
+ </div>
+ <hr/>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="activate-use-email" class="sr-only">[% l('Update Activate Email') %]</label>
+ <input id='activate-use-email' ng-model="args.update_email_notify" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='use-email'>[% l("Send Emails") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id='use-email' ng-model="args.email_notify"
+ type="checkbox" ng-disabled="!args.update_email_notify"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="activate-phone-number" class="sr-only">[% l('Update Phone Number') %]</label>
+ <input id='activate-phone-number' ng-model="args.update_phone_notify" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='phone-number'>[% l("Phone #") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id="phone-number" type='tel'
+ ng-model="args.phone_notify" ng-disabled="!args.update_phone_notify"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="activate-sms-number" class="sr-only">[% l('Update SMS Number') %]</label>
+ <input id='activate-sms-number' ng-model="args.update_sms_notify" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='sms-number'>[% l("Text/SMS #") %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id="sms-number" type='tel'
+ ng-model="args.sms_notify" ng-disabled="!args.update_sms_notify"/>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-1">
+ <label for="activate-sms-carrier" class="sr-only">[% l('Update SMS Carrier') %]</label>
+ <input id='activate-sms-carrier' ng-model="args.update_sms_carrier" type="checkbox"/>
+ </div>
+ <div class="col-md-4">
+ <label for='sms-carrier'>[% l("SMS Carrier") %]</label>
+ </div>
+ <div class="col-md-7">
+ <select id='sms-carrier'
+ ng-model="args.sms_carrier"
+ ng-disabled="!args.update_sms_carrier"
+ ng-options="carrier.name() for carrier in sms_carriers">
+ </select>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
+
+
--- /dev/null
+<div class="">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="ok()" aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Hold Slip') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div ng-switch on="hold.behind_desk">
+ <div ng-switch-when="t">
+ [% l('This item should be routed to the [_1]Private Holds Shelf[_2]',
+ '<strong>','</strong>') %]
+ </div>
+ <div ng-switch-when="f">
+ [% l('This item should be routed to the [_1]Public Holds Shelf[_2]',
+ '<strong>','</strong>') %]
+ </div>
+ </div>
+ <br/>
+ <div>
+ <span>[% l('Item Barcode:') %]</span>
+ <span>{{copy.barcode}}</span>
+ </div>
+ <div>
+ <span>[% l('Title:') %]</span>
+ <span>{{title}}</span>
+ </div>
+ <div>
+ <span>[% l('Author:') %]</span>
+ <span>{{author}}</span>
+ </div>
+ <br/>
+ <div>
+
+ <div ng-show="patron.alias">
+ [% l('Hold for patron {{patron.alias}}') %]
+ </div>
+ <div ng-hide="patron.alias">
+ [% |l %]
+ Hold for patron {{patron.family_name}},
+ {{patron.first_given_name}} {{patron.second_given_name}}
+ [% END %]
+ </div>
+ <div>
+ <span>[% l('Patron Barcode:') %]</span>
+ <span>{{patron.card.barcode}}</span>
+ </div>
+ <br/>
+ <div>
+ <span>[% l('Request Date:') %]</span>
+ <span>{{hold.request_time | date:'shortDate'}}</span>
+ </div>
+ <div>
+ <span>[% l('Slip Date:') %]</span>
+ <span>{{today | date:'shortDate'}}</span>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="button" class="btn btn-primary"
+ ng-click="print()" value="[% l('Print') %]"/>
+ <input type="submit" class="btn btn-warning"
+ ng-click="ok()" value="[% l('Do Not Print') %]"/>
+ </div>
+</div>
--- /dev/null
+<div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Mark Item(s) Claims Returned') %]
+ </h4>
+</div>
+<div class="modal-body">
+ <div class="pad-vert row">
+ <div class="col-md-12">
+ [% l('Enter claims returned date for items: [_1]',
+ '{{args.barcodes.toString()}}') %]
+ </div>
+ </div>
+ <div class="pad-vert row">
+ <div class="col-md-5">
+ <input eg-date-input required
+ class="form-control" ng-model="args.date"/>
+ </div>
+ </div>
+</div>
+<div class="modal-footer">
+ <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
--- /dev/null
+<form ng-submit="ok(args)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close" ng-click="cancel()"
+ aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Apply Standing Penalty / Message') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div class="row">
+ <div class="col-md-8">
+ <ul class="nav nav-pills">
+ <!-- 21 == SILENT_NOTE -->
+ <li ng-class="{active : args.penalty == 21}">
+ <a href ng-click="args.penalty=21">[% l('Note') %]</a>
+ </li>
+ <!-- 20 == ALERT_NOTE -->
+ <li ng-class="{active : args.penalty == 20}">
+ <a href ng-click="args.penalty=20">[% l('Alert') %]</a>
+ </li>
+ <!-- 25 == STAFF_CHR -->
+ <li ng-class="{active : args.penalty == 25}">
+ <a href ng-click="args.penalty=25">[% l('Block') %]</a>
+ </li>
+ </ul>
+ </div>
+ <div class="col-md-4 pull-right">
+ <select class="form-control" ng-model="args.penalty">
+ <option ng-selected="args.penalty < 100"></option>
+ <option ng-repeat="penalty in penalties"
+ value="{{penalty.id()}}">{{penalty.label()}}</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row pad-vert">
+ <div class="col-md-12">
+ <textarea class="form-control"
+ ng-model="args.note" placeholder="[% l('Note...') %]">
+ </textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+ <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+</form>
--- /dev/null
+<!-- edit bucket dialog -->
+<form ng-submit="ok(count)" role="form">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Enter the number of {{type.name()}} circulating') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="noncat-count" class="sr-only">[% l('Count') %]</label>
+ <input type="number" class="form-control" focus-me='focusMe'
+ required id="noncat-title" ng-model="count"
+ min="1" max="{{noncatMax}}"
+ placeholder="[% l('Count...') %]"/>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary"
+ ng-disabled="form.$invalid" value="[% l('OK') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel($event)">[% l('Cancel') %]</button>
+ </div>
+</form>
--- /dev/null
+<!-- edit bucket dialog -->
+<form ng-submit="ok(precatArgs)" role="form">
+ <div class="">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title">
+ [% l('Barcode "{{precatArgs.copy_barcode}}" was mis-scanned or is a non-cataloged item.') %]
+ </h4>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="precat-title">[% l('Title') %]</label>
+ <input type="text" class="form-control" focus-me='focusMe' required
+ id="precat-title" ng-model="precatArgs.dummy_title" placeholder="[% l('Title...') %]"/>
+ </div>
+ <div class="form-group">
+ <label for="precat-author">[% l('Author') %]</label>
+ <input type="text" class="form-control" id="precat-author"
+ ng-model="precatArgs.dummy_author" placeholder="[% l('Author...') %]"/>
+ </div>
+ <div class="form-group">
+ <label for="precat-isbn">[% l('ISBN') %]</label>
+ <input type="text" class="form-control" id="precat-isbn"
+ ng-model="precatArgs.dummy_isbn" placeholder="[% l('ISBN...') %]"/>
+ </div>
+ <div class="form-group">
+ <label for="precat-circmod">[% l('Circulation Modifier') %]</label>
+ <select class="form-control" id="precat-circmod"
+ ng-model="precatArgs.circ_modifier">
+ <option ng-repeat="mod in circModifiers"
+ value="{{mod.code()}}">{{mod.name()}}</option>
+ </select>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="submit" class="btn btn-primary" value="[% l('Precat Checkout') %]"/>
+ <button class="btn btn-warning" ng-click="cancel()"
+ ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+ </div>
+ </div> <!-- modal-content -->
+ </div> <!-- modal-dialog -->
+</form>
--- /dev/null
+<div class="">
+ <div class="">
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="ok()" aria-hidden="true">×</button>
+ <h4 class="modal-title">[% l('Transit Slip') %]</h4>
+ </div>
+ <div class="modal-body">
+ <div>
+ <span>[% l('Destination') %]</span>
+ <strong>{{dest_location.shortname}}</strong>
+ </div>
+ <br/>
+ <div>
+ <address>
+ <strong>{{dest_location.name}}</strong><br>
+ {{dest_address.street1}} {{dest_address.street2}}<br/>
+ {{dest_address.city}}, {{dest_address.state}} {{dest_address.post_code}}<br/>
+ <abbr title="[% l('Phone') %]">P:</abbr> {{dest_location.phone}}
+ </address>
+ </div>
+ <div>
+ <span>[% l('Item Barcode:') %]</span>
+ <span>{{copy.barcode}}</span>
+ </div>
+ <div>
+ <span>[% l('Title:') %]</span>
+ <span>{{title}}</span>
+ </div>
+ <div>
+ <span>[% l('Author:') %]</span>
+ <span>{{author}}</span>
+ </div>
+ <div ng-if="patron">
+ <br/>
+ <div>[% |l %]
+ Hold for patron {{patron.family_name}},
+ {{patron.first_given_name}} {{patron.second_given_name}}
+ [% END %]
+ </div>
+ <div>
+ <span>[% l('Patron Barcode:') %]</span>
+ <span>{{patron.card.barcode}}</span>
+ </div>
+ <br/>
+ <div>
+ <span>[% l('Request Date:') %]</span>
+ <span>{{hold.request_time | date:'shortDate'}}</span>
+ </div>
+ </div>
+ <div>
+ <div>
+ <span>[% l('Slip Date:') %]</span>
+ <span>{{today | date:'shortDate'}}</span>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <input type="button" class="btn btn-primary"
+ ng-click="print()" value="[% l('Print') %]"/>
+ <input type="submit" class="btn btn-warning"
+ ng-click="ok()" value="[% l('Do Not Print') %]"/>
+ </div>
+ </div>
+</div>
--- /dev/null
+[%-
+
+# FIXME: update via build process
+EVERGREEN_VERSION='0.0.1'
+
+# create script / css refs to individual files instead of using
+# compressed build files. Use this for development and debugging.
+EXPAND_WEB_IMPORTS = 1;
+
+# path to build files (js, css, fonts)
+WEB_BUILD_PATH = ctx.media_prefix _ '/js/ui/default/staff/build/';
+
+%]
--- /dev/null
+/** style to make a grid look like a striped table */
+#patron-summary-grid div.row {padding: 3px; border-right: 2px solid rgb(248, 248, 248);}
+#patron-summary-grid div.row:nth-child(odd) {background-color: rgb(248, 248, 248);}
+
+/* there are bootstrap tyles for error, warning, etc.,
+but the ones I'm finding aren't quite cutting it..*/
+.patron-summary-alert {color: red; font-weight:bold}
+.patron-summary-alert-small {color: red}
+.patron-summary-divider { border-top: 1px solid #CCC}
+.patron-summary-act-link {font-size: .8em;}
+
+/* FIXME: use .barcode instead */
+#patron-checkout-barcode,
+#patron-renewal-barcode,
+#patron-checkin-barcode { width: 16em; }
+
+#patron-search-form div.form-group {
+ margin-bottom: 5px;
+}
+
+/* let search form elements fill their containers w/ slight padding */
+#patron-search-form-row {margin-left: 0px;}
+#patron-search-form div.col-md-2 { padding: 2px; }
+#patron-search-form input:not([type="checkbox"]) { width: 100%; }
+#patron-search-form .eg-org-selector,
+#patron-search-form .eg-org-selector button,
+#patron-search-form .patron-search-selector,
+ #patron-search-form .patron-search-selector button {
+ width: 100%;
+ text-align: left
+}
+
+
+#patron-payments-spreadsheet {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid #aaa;
+}
+
+#patron-payments-spreadsheet .flex-cell {
+ margin: 2px;
+}
+
+#patron-payments-spreadsheet .flex-cell.well {
+ min-height: 1.5em;
+ margin-bottom: 0px; /* bootstrap default is 20px */
+}
+
+#hold-notify-settings div.row { margin-top: 12px; }
+#hold-notify-settings div.row:not(.header-row):nth-child(odd) {
+ background-color: rgb(248, 248, 248);
+}
+#hold-notify-settings div.row:not(.header-row) {
+ border-bottom: 1px solid #CCC;
+}
+
+
+[%#
+vim: ft=css
+%]
--- /dev/null
+
+/* hide everything but the print div */
+head { display: none; } /* just to be safe */
+body div:not([id="print-div"]) { display:none }
+
+div { display: none }
+#print-div { display: block }
+#print-div div { display: block }
+#print-div pre { border: none }
+
+[%#
+vim: ft=css
+%]
--- /dev/null
+/* --------------------------------------------------------------------------
+ * Simple default navbar style adjustements to apply the Evergreen color.
+ * TODO: style other components to match EG color scheme
+ */
+#top-navbar.navbar-default {
+ background: -webkit-linear-gradient(#00593d, #007a54);
+ background-color: #007a54;
+ color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a {
+ color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a:hover {
+ color: #ddd;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a .caret {
+ border-top-color: #fff;
+ border-bottom-color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a:hover .caret {
+ border-top-color: #ddd;
+ border-bottom-color: #ddd;
+}
+
+/* status bar along the bottom of the page ------------------------ */
+/* decrease padding to decrease overall height */
+
+/** TODO:move status bar items into navbar config entry (top-right)
+ * to avoid body padding weirdness. Or if we want a permenently
+ * visible status bar, maybe put it just below the navbar.. */
+
+/* bottom padding ensures no body content is hidden behind the status
+ * bar. When content reaches the status bar a scroll bar appears */
+/*body { padding-bottom: 26px; }*/
+
+#status-bar {
+ min-height:1.8em !important;
+}
+#status-bar > ul {
+ margin-right:6px;
+}
+#status-bar li {
+ padding-left: 10px;
+}
+#status-bar > li > a {
+ padding-top:5px !important;
+ padding-bottom:5px !important;
+}
+.status-bar-connected {
+ color: rgb(92, 184, 92); /* success */
+}
+
+/* --------------------------------------------------------------------------
+ * Structural modifications
+ */
+
+#top-content-container {
+ /* allow the primary container to occupy most of the page,
+ * but leave some narrow gutters along the side, much
+ * narrower than the default Bootstrapp container gutters.
+ */
+ width: 95%;
+}
+
+
+/* --------------------------------------------------------------------------
+ * Temporaray local CSS required to make angular-ui-bootstrap
+ * version 0.6.0 look right with Bootstrap CSS 3.0
+ */
+.nav, .pagination, .carousel a { cursor: pointer; }
+/*
+.modal {
+ display: block;
+ height: 0;
+ overflow: visible;
+}
+.modal-body:before,
+.modal-body:after {
+ display: table;
+ content: " ";
+}
+.modal-header:before,
+.modal-header:after {
+ display: table;
+ content: " ";
+}
+*/
+
+/* --------------------------------------------------------------------------
+/* Form Validation CSS - http://docs.angularjs.org/guide/forms
+ * TODO: these colors are harsh and don't fit the EG color scheme
+ */
+.form-validated input.ng-invalid.ng-dirty {
+ background-color: #FA787E;
+}
+.form-validated input.ng-valid.ng-dirty {
+ background-color: #78FA89;
+}
+
+/* --------------------------------------------------------------------------
+ * Local style
+ */
+
+#splash-nav .panel-body div {
+ padding-bottom: 10px;
+}
+
+table.list tr.selected td { /* deprecated? */
+ color: #2a6496;
+ background-color: #F5F5F5;
+}
+
+.pad-horiz {padding : 0px 10px 0px 10px; }
+.pad-vert {padding : 20px 0px 10px 0px;}
+.pad-left {padding-left: 10px;}
+.pad-right {padding-right: 10px;}
+.pad-all-min {padding : 5px; }
+.pad-all {padding : 10px; }
+
+#print-div { display: none; }
+
+/* by default, give all tab panes some top padding */
+.tab-pane { padding-top: 20px; }
+
+.nav-pills-like-tabs {
+ border-bottom:1px solid #CCC;
+}
+
+.btn-pad {
+ /* sometimes you don't want buttons scrunched together -- add some margin */
+ margin-left: 10px;
+}
+
+.strong-text {
+ font-weight: bold;
+}
+.strong-text-1 {
+ font-size: 110%;
+ font-weight: bold;
+}
+.strong-text-2 {
+ font-size: 120%;
+ font-weight: bold;
+}
+.strong-text-3 {
+ font-size: 130%;
+ font-weight: bold;
+}
+.strong-text-4 {
+ font-size: 140%;
+ font-weight: bold;
+}
+
+.currency-input {
+ width: 8em;
+}
+
+/* barcode inputs are everywhere. Let's have a consistent style. */
+.barcode { width: 16em !important; }
+
+/* bootstrap alerts are heavily padded. use this to reduce */
+.alert-less-pad {padding: 5px;}
+
+/* text displayed inside a <progressbar>, typically the max/progress values */
+.progressbar-text {
+ color:black;
+ white-space:nowrap;
+}
+
+/* embedded UI iframe */
+.eg-embed-frame {
+ width: 100%;
+}
+.eg-embed-frame iframe {
+ width: 100%;
+ border: none;
+ margin: 0px;
+ padding: 0px;
+}
+
+/* ----------------------------------------------------------------------
+ * Grid
+ * ---------------------------------------------------------------------- */
+
+.eg-grid-primary-label {
+ font-weight: bold;
+ font-size: 120%;
+}
+
+/* odd/even row styling */
+.eg-grid-content-body > div:nth-child(odd):not(.eg-grid-row-selected) {
+ background-color: rgb(248, 248, 248);
+}
+
+.eg-grid-row {
+ width: 100%;
+ display: flex;
+ border: 1px solid #ccc;
+}
+
+.eg-grid-row:not(.eg-grid-header-row):not(.eg-grid-conf-row) {
+ /* TODO: remove, pretty sure this is no longer needed w/ nowrap */
+ /*height: 1.8em;*/
+}
+
+.eg-grid-action-row {
+ border: none;
+ /* margin should not have to be this large; something's up */
+ margin-bottom: 12px;
+}
+
+.eg-grid-header-row {
+ font-weight: bold;
+}
+
+.eg-grid-header-row > .eg-grid-cell {
+ border-right: 1px solid #CCC;
+ text-align: center;
+
+ /* vertically align header cell text by treating
+ each header cell as a vertical flex container */
+ display:flex;
+ flex-direction:column;
+ justify-content:flex-end;
+}
+
+.eg-grid-cell {
+ /* avoid text flowing into adjacent cells */
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+/* in config display, make cells more obvious */
+.eg-grid-as-conf .eg-grid-row {
+ border: 1px solid #777;
+}
+.eg-grid-as-conf .eg-grid-cell {
+ border-right: 1px solid #777;
+}
+
+/* stock columns need fixed-width controls */
+.eg-grid-cell-stock {
+ width: 2.2em;
+ text-align: center;
+}
+
+/* the conf header must be twice the stock flex */
+.eg-grid-cell-conf-header {
+ width: 4.4em;
+ font-weight: bold;
+}
+
+.eg-grid-row-selected {
+ color: rgb(51, 51, 51);
+ background-color: rgb(201, 221, 225);
+ border-bottom: 1px solid #888;
+}
+
+/* Improve ::selection styling by only allowing selection on text
+ * content cells within the main body of the grid. Otherwise, the browser
+ * styles row background and text (all dark blue?) when shift-click or
+ * click-drag is used.
+ */
+.eg-grid-content-body .eg-grid-row {
+ user-select:none;
+ -moz-user-select: none;
+ -webkit-user-select: none;
+}
+.eg-grid-content-body .eg-grid-cell-content {
+ user-select:text;
+ -moz-user-select: text;
+ -webkit-user-select: text;
+}
+.eg-grid-cell-content::-moz-selection {
+ color: rgb(51, 51, 51);
+ background: rgb(201, 221, 225);
+ border-bottom: 1px solid #888;
+}
+.eg-grid-cell-content::selection {
+ color: rgb(51, 51, 51);
+ background: rgb(201, 221, 225);
+ border-bottom: 1px solid #888;
+}
+
+.eg-grid-conf-cell-entry {
+ width:98%;
+ text-align:center;
+ padding: 3px;
+}
+
+.eg-grid-conf-cell-entry:not(:first-child) {
+ border-top:1px solid #ccc;
+}
+
+.eg-grid-conf-row {
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+
+.eg-grid-conf-row:first-child {
+ /* alignment fix; account for one missing border */
+ padding-right: 1px;
+}
+
+.eg-grid-column-move-handle:hover {
+ cursor: move;
+}
+
+.eg-grid-column-move-handle-active,
+.eg-grid-column-move-handle-active:active {
+ /* similar to label-primary, sans padding */
+ background-color: rgb(66, 139, 202);
+ color: #fff;
+}
+
+.eg-grid-col-hover {
+ /* similar to label-success, sans padding */
+ background-color: rgb(92, 184, 92);
+ color: #fff;
+}
+
+.eg-grid-column-resize-handle {
+ height: 100%;
+}
+.eg-grid-column-resize-handle:hover {
+ cursor: col-resize;
+}
+
+/* for these to be useful, they would have to be applied
+ * to the dragover targets. not yet done */
+.eg-grid-column-resize-handle-west {
+ cursor: w-resize;
+}
+.eg-grid-column-resize-handle-east {
+ cursor: e-resize;
+}
+
+.eg-grid-menu-item {
+ margin-right: 10px;
+}
+
+
+/* hack to make the header columns line up with the content columns
+ when the scroll bar is visible along the right side of the content
+ columns. TODO: if this varies enough by browser, we'll need to
+ calculate the width instead. */
+/*
+.eg-grid-scroll > .eg-grid-header-row,
+.eg-grid-scroll > .eg-grid-conf-row {
+ padding-right: 15px;
+}
+.eg-grid-scroll > .eg-grid-content-body {
+ overflow-y:scroll;
+ height: 600px;
+}
+*/
+.eg-grid-column-picker {
+ height: auto;
+ max-height: 400px;
+ overflow: auto;
+ box-shadow: none;
+}
+
+
+/* ----------------------------------------------------------------------
+ * /Grid
+ * ---------------------------------------------------------------------- */
+
+
+/* simple flex container for consistent-width cell-based structures */
+.flex-container-striped > .flex-row:nth-child(odd) {
+ background-color: #f5f5f5;
+}
+.flex-container-bordered .flex-cell {
+ border-bottom: 1px solid #ddd;
+}
+.flex-row {
+ display: flex;
+}
+.flex-row.padded div {
+ padding: 5px;
+}
+.flex-row.left-anchored > div {
+ margin-right: 10px;
+}
+.flex-cell {
+ flex: 1;
+ padding: 4px; /* bootstrap default is much bigger */
+}
+.flex-cell.well {
+ min-height: 2.5em; /* don't let empty wells scrunch down */
+ margin-bottom: 5px; /* bootstrap default is 20px */
+}
+.flex-2 { /* meh, convience */
+ flex: 2;
+}
+
+/* TODO: match media size to Bootstrap "md" col resizing */
+@media all and (max-width: 800px) {
+ .flex-row {
+ flex-direction: column;
+ }
+ .eg-grid-row {
+ flex-direction: column;
+ }
+}
+
+
+[%#
+vim: ft=css
+%]
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Home");
+ ctx.page_app = "egHome";
+%]
+
+[% BLOCK APP_JS %]
+<!-- needed for login -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+<!-- splash / login page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/app.js"></script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
--- /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 id="top-navbar" role="navigation"
+ class="navbar navbar-default navbar-static-top" role="navigation">
+
+ <!-- navbar-header here needed for supporting angular-ui-bootstrap -->
+ <div class="navbar-header">
+ <button type="button" class="navbar-toggle"
+ ng-init="navCollapsed = true" ng-click="navCollapsed = !navCollapsed">
+ <span class="sr-only">[% l('Toggle navigation') %]</span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ </button>
+ </div>
+
+ <div class="navbar-collapse collapse" ng-class="!navCollapsed && 'in'">
+ <ul class="nav navbar-nav">
+ <li><a href='./' title="[% l('Home') %]" target="_self"
+ class="glyphicon glyphicon-home"></a><li>
+
+ <!-- search -->
+ <li class="dropdown">
+ <a href="javascript:;" class="dropdown-toggle"
+ data-toggle="dropdown">[% l('Search') %]
+ <b class="caret"></b>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <a href="./circ/patron/search" target="_self"
+ eg-accesskey="[% l('alt+s') %]"
+ eg-accesskey-desc="[% l('Patron search by name, address, etc.') %]">
+ <span class="glyphicon glyphicon-user"></span>
+ <span eg-accesskey-label>[% l('Search for Patrons') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href="./cat/item/search" target="_self">
+ <span class="glyphicon glyphicon-barcode"></span>
+ <span>[% l('Search for Copies by Barcode') %]</span>
+ </a>
+ </li>
+ </ul>
+ </li>
+
+
+ <!-- circulation -->
+ <li class="dropdown">
+ <a href="javascript:;" class="dropdown-toggle"
+ data-toggle="dropdown">[% l('Circulation') %]
+ <b class="caret"></b>
+ </a>
+
+ <ul class="dropdown-menu">
+ <li>
+ <a href="./circ/patron/bcsearch" target="_self">
+ <span class="glyphicon glyphicon-export"></span>
+ [% l('Check Out') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/checkin/checkin" target="_self">
+ <span class="glyphicon glyphicon-import"></span>
+ [% l('Check In') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/checkin/capture" target="_self">
+ <span class="glyphicon glyphicon-pushpin"></span>
+ [% l('Capture Holds') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/holds/pull" target="_self">
+ <span class="glyphicon glyphicon-th-list"></span>
+ [% l('Pull List for Hold Requests') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/renew/renew" target="_self">
+ <span class="glyphicon glyphicon-refresh"></span>
+ [% l('Renew Items') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/register" target="_self">
+ <span class="glyphicon glyphicon-user"></span>
+ [% l('Register Patron') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/last" target="_self">
+ <span class="glyphicon glyphicon-share-alt"></span>
+ [% l('Retrieve Last Patron') %]
+ </a>
+ </li>
+ <li>
+ <a href="./circ/patron/pending/list" target="_self">
+ <span class="glyphicon glyphicon-thumbs-up"></span>
+ [% l('Pending Patrons') %]
+ </a>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <a href="./circ/patron/credentials" target="_self">
+ <span class="glyphicon glyphicon-ok"></span>
+ <span>[% l('Verify Credentials') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href="./circ/in_house_use/index" target="_self">
+ <span class="glyphicon glyphicon-pencil"></span>
+ <span>[% l('Record In-House Use') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href="./circ/holds/shelf" target="_self">
+ <span class="glyphicon glyphicon-tasks"></span>
+ <span>[% l('Holds Shelf') %]</span>
+ </a>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <a href="./cat/item/replace_barcode/index" target="_self">
+ <span class="glyphicon glyphicon-barcode"></span>
+ <span>[% l('Replace Barcode') %]</span>
+ </a>
+ </li>
+ <li>
+ <a href="./cat/item/missing_pieces" target="_self">
+ <span class="glyphicon glyphicon-th"></span>
+ <span>[% l('Scan Item as Missing Pieces') %]</span>
+ </a>
+ </li>
+ </ul>
+ </li><!-- circ -->
+
+ <!-- cataloging -->
+ <li class="dropdown">
+ <a href="javascript:;" class="dropdown-toggle"
+ data-toggle="dropdown">[% l('Cataloging') %]
+ <b class="caret"></b>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <a href="./cat/catalog/index" target="_self">
+ <span class="glyphicon glyphicon-search"></span>
+ [% l('Search the Catalog') %]
+ </a>
+ </li>
+ <li>
+ <a href="./cat/bucket/record/view" target="_self">
+ <span class="glyphicon glyphicon-list-alt"></span>
+ [% l('Record Buckets') %]
+ </a>
+ </li>
+ </ul>
+ </li>
+
+ <!-- admin -->
+ <li class="dropdown">
+ <a href="javascript:;" class="dropdown-toggle"
+ data-toggle="dropdown">[% l('Administration') %]
+ <b class="caret"></b>
+ </a>
+ <ul class="dropdown-menu">
+ <li>
+ <a href="./admin/workstation/index" target="_self">
+ <span class="glyphicon glyphicon-hdd"></span>
+ [% l('Workstation') %]
+ </a>
+ </li>
+ <li>
+ <a href="./admin/user_perms" target="_self">
+ <span class="glyphicon glyphicon-user"></span>
+ [% l('User Permission Editor') %]
+ </a>
+ </li>
+ </ul> <!-- admin dropdown -->
+ </li>
+ </ul> <!-- end left side entries -->
+
+ <!-- entries along the right side of the navbar -->
+ <ul class="nav navbar-nav navbar-right" style='margin-right: 6px;'>
+ <li>
+ <a ng-cloak ng-show="username"
+ ng-init="workstation = '[% l('<no workstation>') %]'">
+ [% l('{{username}} @ {{workstation}}') %]
+ </a>
+ </li>
+
+ <!-- locale selector.
+ only shown if multiple locales are registered -->
+ [% IF ctx.locales.keys.size > 1 %]
+ <li class="dropdown">
+ <a href='' class="dropdown-toggle" data-toggle="dropdown">
+ [% lcl = ctx.locale; ctx.locales.$lcl %]
+ <span class="glyphicon glyphicon-flag"></span>
+ </a>
+ <ul class="dropdown-menu">
+ [% FOR locale IN ctx.locales.keys.sort %]
+ <!-- disable the selected locale -->
+ <li ng-class="{disabled : '[% ctx.locale %]'=='[% locale %]'}">
+ <a href="" ng-click="applyLocale('[% locale %]')">
+ [% ctx.locales.$locale %]
+ </a>
+ </li>
+ [% END %]
+ </ul>
+ </li>
+ [% END %]
+
+ <li class="dropdown" ng-show="username">
+ <a href='' class="dropdown-toggle glyphicon glyphicon-list"
+ data-toggle="dropdown"></a>
+ <ul class="dropdown-menu">
+ <li class="disabled">
+ <a href="" ng-click="" target="_self">
+ <span class="glyphicon glyphicon-random"></span>
+ [% l('Change Operator') %]
+ </a>
+ </li>
+ <li>
+ <a href="./login" ng-click="logout()" target="_self">
+ <span class="glyphicon glyphicon-log-out"></span>
+ [% l('Log Out') %]
+ </a>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+</div>
+
+
--- /dev/null
+Location for globally shared template files. These are generally used
+by AngularJS directives.
+
+App-specific shared templates should live within the application's
+directory.
--- /dev/null
+[% USE CGI %]
+[% l('Print Template Not Found: [_1]', CGI.url("-path",1,"-relative",1)) %]
--- /dev/null
+Welcome to {{current_location.name}}!<br/>
+A receipt of your transaction:<hr/>
+
+<table style="width:100%">
+ <tr>
+ <td>[% l('Original Balance:') %]</td>
+ <td align="right">{{previous_balance | currency}}</td>
+ </tr>
+ <tr>
+ <td>[% l('Payment Method:') %]</td>
+ <td align="right">
+ <div ng-switch="payment_type">
+ <div ng-switch-when="cash_payment">[% l('Cash') %]</div>
+ <div ng-switch-when="check_payment">[% l('Check') %]</div>
+ <div ng-switch-when="credit_card_payment">[% l('Credit Card') %]</div>
+ <div ng-switch-when="credit_payment">[% l('Patron Credit') %]</div>
+ <div ng-switch-when="work_payment">[% l('Work') %]</div>
+ <div ng-switch-when="forgive_payment">[% l('Forgive') %]</div>
+ <div ng-switch-when="goods_payment">[% l('Goods') %]</div>
+ </div>
+ </td>
+ </tr>
+ <tr>
+ <td>[% l('Payment Received:') %]</td>
+ <td align="right">{{payment_total | currency}}</td>
+ </tr>
+ <tr>
+ <td>[% l('Payment Applied:') %]</td>
+ <td align="right">{{payment_applied | currency}}</td>
+ </tr>
+ <tr>
+ <td>[% l('Billings Voided:') %]</td>
+ <td align="right">{{amount_voided | currency}}</td>
+ </tr>
+ <tr>
+ <td>[% l('Change Given:') %]</td>
+ <td align="right">{{change_given | currency}}</td>
+ </tr>
+ <tr>
+ <td>[% l('New Balance:') %]</td>
+ <td align="right">{{new_balance | currency}}</td>
+ </tr>
+</table>
+
+<p>[% l('Note: [_1]', '{{payment_note}}') %]</p>
+
+<p>
+[% l('Specific Bills') %]
+ <blockquote>
+ <div ng-repeat="payment in payments">
+ <table style="width:100%">
+ <tr>
+ <td>[% l('Bill # [_1]', '{{payment.xact.id}}') %]</td>
+ <td>{{payment.xact.summary.last_billing_type}}</td>
+ <td>[% l('Received: [_1]', '{{payment.amount | currency}}') %]</td>
+ </tr>
+ <tr>
+ <td colspan="5">
+ {{payment.xact.copy_barcode}} {{payment.xact.title}}
+ </td>
+ </tr>
+ </table>
+ <br/>
+ </div>
+ </blockquote>
+</p>
+<hr/>
+<br/><br/>
+{{current_location.shortname}} {{today | date:'short'}}
--- /dev/null
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+ <div ng-repeat="xact in transactions">
+ <dt><b>Bill #{{xact.id}}</b></dt>
+ <dd>
+ <table>
+ <tr valign="top">
+ <td>[% l('Date:') %]</td>
+ <td>{{xact.xact_start | date:'short'}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Type') %]:</td>
+ <td>{{xact.summary.xact_type}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Last Billing') %]:</td>
+ <td>{{xact.summary.last_billing_type}}<br/>
+ {{xact.summary.last_billing_note}}
+ </td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Total Billed') %]:</td>
+ <td>{{xact.summary.total_owed | currency}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Last Payment') %]:</td>
+ <td>{{xact.summary.last_payment_type}}<br/>
+ {{xact.summary.last_payment_note}}
+ </td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Total Paid') %]:</td>
+ <td>{{xact.summary.total_paid | currency}}</td>
+ </tr>
+ <tr valign="top">
+ <td><b>[% l('Balance') %]:</b></td>
+ <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+ </tr>
+ </table>
+ </dd>
+ <br/>
+ </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
--- /dev/null
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+ <div ng-repeat="xact in transactions">
+ <dt><b>Bill #{{xact.id}}</b></dt>
+ <dd>
+ <table>
+ <tr valign="top">
+ <td>[% l('Date:') %]</td>
+ <td>{{xact.xact_start | date:'short'}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Type') %]:</td>
+ <td>{{xact.summary.xact_type}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Last Billing') %]:</td>
+ <td>{{xact.summary.last_billing_type}}<br/>
+ {{xact.summary.last_billing_note}}
+ </td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Total Billed') %]:</td>
+ <td>{{xact.summary.total_owed | currency}}</td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Last Payment') %]:</td>
+ <td>{{xact.summary.last_payment_type}}<br/>
+ {{xact.summary.last_payment_note}}
+ </td>
+ </tr>
+ <tr valign="top">
+ <td>[% l('Total Paid') %]:</td>
+ <td>{{xact.summary.total_paid | currency}}</td>
+ </tr>
+ <tr valign="top">
+ <td><b>[% l('Balance') %]:</b></td>
+ <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+ </tr>
+ </table>
+ </dd>
+ <br/>
+ </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
--- /dev/null
+<div>
+ <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+ <div>[% l('You checked in the following items:') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkin in checkins">
+ <div>{{checkin.title}}</div>
+ <span>[% l('Barcode: ') %]</span>
+ <span>{{checkin.copy_barcode}}</span>
+ <span>[% l('Call Number: ') %]</span>
+ <span>{{checkin.call_number.label || "[% l("Not Cataloged") %]"}}</span>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <br/>
+</div>
--- /dev/null
+<div>
+ <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+ <div>[% l('You checked out the following items:') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkout in circulations">
+ <div>{{checkout.title}}</div>
+ <div>[% l('Barcode: [_1] Due: [_2]',
+ '{{checkout.copy.barcode}}',
+ '{{checkout.circ.due_date | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
--- /dev/null
+<table id='pull-list-template-table'>
+ <style>
+ #pull-list-template-table td,
+ #pull-list-template-table th {
+ padding: 5px;
+ border: 1px solid #000;
+ }
+ </style>
+ <thead>
+ <tr>
+ <th>[% l('Type') %]</th>
+ <th>[% l('Title') %]</th>
+ <th>[% l('Author') %]</th>
+ <th>[% l('Shelf Location') %]</th>
+ <th>[% l('Call Number') %]</th>
+ <th>[% l('Barcode/Part') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="hold_data in holds">
+ <td>{{hold_data.hold.hold_type}}</td>
+ <td>{{hold_data.title}}</td>
+ <td>{{hold_data.author}}</td>
+ <td>{{hold_data.copy.location.name}}</td>
+ <td>{{hold_data.volume.label}}</td>
+ <td>{{hold_data.copy.barcode}} {{hold_data.part.label}}</td>
+ </tr>
+ </tbody>
+</table>
--- /dev/null
+<div>
+ <div ng-switch on="hold.behind_desk">
+ <div ng-switch-when="t">
+ [% l('This item needs to be routed to the [_1]Private Holds Shelf[_2].',
+ '<strong>','</strong>') %]
+ </div>
+ <div ng-switch-when="f">
+ [% l('This item needs to be routed to the [_1]Public Holds Shelf[_2].',
+ '<strong>','</strong>') %]
+ </div>
+ </div>
+ <br/>
+
+ [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+ [% l('Title: [_1]', '{{title}}') %]</div>
+
+ <br/>
+ <br/>
+
+ <div>[% l('Hold for patron [_1], [_2] [_3]',
+ '{{patron.family_given_name}}',
+ '{{patron.first_given_name}}',
+ '{{patron.second_given_name}}') %]</div>
+ <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+ <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+ <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+ <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+ <br/>
+
+ <div>[% l('Request Date: [_1]',
+ '{{hold.request_time | date:"short"}}') %]</div>
+ <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+ <div>[% l('Printed by [_1] at [_2]',
+ '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
--- /dev/null
+<div>
+ <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+ <div>{{dest_location.name}}</div>
+ <div>{{dest_address.street1}}
+ <div>{{dest_address.street2}}</div>
+ <div>{{dest_address.city}},
+ {{dest_address.state}}
+ {{dest_address.post_code}}</div>
+ <br/>
+
+ [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+ [% l('Title: [_1]', '{{title}}') %]</div>
+ [% l('Author: [_1]', '{{author}}') %]</div>
+
+ <br/>
+
+ <div>[% l('Hold for patron [_1], [_2] [_3]',
+ '{{patron.family_given_name}}',
+ '{{patron.first_given_name}}',
+ '{{patron.second_given_name}}') %]</div>
+ <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+ <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+ <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+ <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+ <br/>
+
+ <div>[% l('Request Date: [_1]',
+ '{{hold.request_time | date:"short"}}') %]</div>
+ <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+ <div>[% l('Printed by [_1] at [_2]',
+ '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
--- /dev/null
+<div>
+ <div>[% l('Holds for record: [_1]', '{{holds[0].title}}') %]</div>
+ <hr/>
+ <style>#holds-for-bib-table td { padding: 5px; }</style>
+ <table id="holds-for-bib-table">
+ <thead>
+ <tr>
+ <th>[% l('Request Date') %]</th>
+ <th>[% l('Patron Barcode') %]</th>
+ <th>[% l('Patron Last') %]</th>
+ <th>[% l('Patron Alias') %]</th>
+ <th>[% l('Current Copy') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="hold in holds">
+ <td>{{hold.hold.request_time | date:'short'}}</td>
+ <td>{{hold.patron_barcode}}</td>
+ <td>{{hold.patron_last}}</td>
+ <td>{{hold.patron_alias}}</td>
+ <td>{{hold.copy.barcode}}</td>
+ </tr>
+ </tbody>
+ </table>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <div>[% l('Printed by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
--- /dev/null
+<div>
+ <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+ <div>[% l('You have the following title on hold:') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="hold in holds">
+ <div>{{hold.title}}</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
--- /dev/null
+<div>
+ <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+ <div>[% l('You have the following items:') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkout in circulations">
+ <div>{{checkout.title}}</div>
+ <div>[% l('Barcode: [_1] Due: [_2]',
+ '{{checkout.copy.barcode}}',
+ '{{checkout.circ.due_date | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
--- /dev/null
+<div>
+ <div>
+ {{patron.first_given_name}}
+ {{patron.second_given_name}}
+ {{patron.family_name}}
+ </div>
+ <div>{{address.street1}}</div>
+ <div ng-if="address.street2">{{address.street2}}</div>
+ <div>
+ {{address.city}}, {{address.state}} {{address.post_code}}
+ </div>
+</div>
--- /dev/null
+<h3>[% l(
+ 'Pertaining to [_1], [_2] [_3] : [_4]',
+ '{{note.usr.family_name}}',
+ '{{note.usr.first_given_name}}',
+ '{{note.usr.second_given_name}}',
+ '{{note.usr.card.barcode}}') %]</h3>
+
+<p>[% l('Created on [_1]', '{{note.create_date | date:"short"}}') %]</p>
+<b>{{note.title}}</b>
+<br/>
+<p>{{note.value}}</p>
--- /dev/null
+<div>
+ <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+ <div>[% l('You renewed the following items:') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="renewal in circulations">
+ <div>{{renewal.title}}</div>
+ <div>[% l('Barcode: [_1] Due: [_2]',
+ '{{renewal.copy.barcode}}',
+ '{{renewal.circ.due_date | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+ <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
--- /dev/null
+<div>
+ <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+ <div>{{dest_location.name}}</div>
+ <div>{{dest_address.street1}}
+ <div>{{dest_address.street2}}</div>
+ <div>{{dest_address.city}},
+ {{dest_address.state}}
+ {{dest_address.post_code}}</div>
+ <br/>
+
+ [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+ [% l('Title: [_1]', '{{title}}') %]</div>
+ [% l('Author: [_1]', '{{author}}') %]</div>
+
+ <br/>
+
+ <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+ <div>[% l('Printed by [_1] at [_2]',
+ '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
--- /dev/null
+<!--
+ Generic alert dialog.
+ The only user action allowed is the 'OK' button.
+-->
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="ok()" aria-hidden="true">×</button>
+ <h4 class="modal-title alert alert-danger">[% l('Alert') %]</h4>
+ </div>
+ <div class="modal-body">{{message}}</div>
+ <div class="modal-footer">
+ <input type="submit"
+ class="btn btn-primary" ng-click="ok()" value="[% l('OK') %]"/>
+ </div>
+</div>
--- /dev/null
+
+<!--
+ Actions row.
+ This sits above the grid and contains the column picker, etc.
+-->
+
+<div class="eg-grid-row eg-grid-action-row">
+
+ <div class="eg-grid-primary-label">{{mainLabel}}</div>
+
+ <div class="btn-group eg-grid-menuiitem"
+ is-open="gridMenuIsOpen" ng-if="menuLabel" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle">
+ {{menuLabel}}<span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li ng-repeat="item in menuItems" ng-class="{divider: item.divider}">
+ <a ng-if="!item.divider" href ng-disabled="item.disabled"
+ ng-click="item.handler()">{{item.label}}</a>
+ </li>
+ </ul>
+ </div>
+
+ <!-- if no menu label is present, present menu-items as a
+ horizontal row of buttons -->
+ <div class="btn-group" ng-if="!menuLabel">
+ <button ng-if="!item.hidden()"
+ class="btn btn-default eg-grid-menu-item"
+ ng-disabled="item.disabled()"
+ ng-repeat="item in menuItems"
+ ng-click="item.handler(item, item.handlerData)">
+ {{item.label}}
+ </button>
+ </div>
+
+ <!-- putting a flex div here forces the remaining content to float right -->
+ <div class="flex-cell"></div>
+
+ <!-- column picker, pager, etc. -->
+ <div class="btn-group column-picker">
+
+ <!-- first page -->
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : onFirstPage()}"
+ ng-click="offset(0);collect()"
+ title="[% l('Start') %]">
+ <span class="glyphicon glyphicon-fast-backward"></span>
+ </button>
+
+ <!-- previous page -->
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : onFirstPage()}"
+ ng-click="decrementPage()"
+ title="[% l('Previous Page') %]">
+ <span class="glyphicon glyphicon-backward"></span>
+ </button>
+
+ <!-- next page -->
+ <!-- todo: paging needs a total count value to be fully functional -->
+ <button type="button" class="btn btn-default"
+ ng-class="{disabled : !hasNextPage()}"
+ ng-click="incrementPage()"
+ title="[% l('Next Page') %]">
+ <span class="glyphicon glyphicon-forward"></span>
+ </button>
+
+ <!-- actions drop-down menu -->
+ <div class="btn-group" ng-if="actions.length" dropdown>
+ <button type="button" class="btn btn-default dropdown-toggle"
+ ng-class="{disabled : false}">
+ [% l('Actions') %] <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu pull-right">
+ <li ng-repeat="action in actions" ng-class="{divider: action.divider}">
+ <a ng-if="!action.divider" href dropdown-toggle
+ ng-click="actionLauncher(action)">{{action.label}}</a>
+ </li>
+ </ul>
+ </div>
+
+ <div class="btn-group" dropdown is-open="gridRowCountIsOpen">
+ <button type="button" title="[% ('Select Row Count') %]"
+ class="btn btn-default dropdown-toggle">
+ [% l('Rows [_1]', '{{limit()}}') %]
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li ng-repeat="t in [5,10,25,50,100]">
+ <a href ng-click='offset(0);limit(t);collect()'>
+ {{t}}
+ </a>
+ </li>
+ </ul>
+ </div>
+
+ <div class="btn-group" dropdown is-open="gridPageSelectIsOpen">
+ <button type="button" title="[% ('Select Page') %]"
+ class="btn btn-default dropdown-toggle">
+ [% l('Page [_1]', '{{page()}}') %]
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li>
+ <div class="input-group">
+ <input type="text" class="form-control"
+ ng-model="pageFromUI"
+ ng-click="$event.stopPropagation()"/>
+ <span class="input-group-btn">
+ <button class="btn btn-default" type="button"
+ ng-click="goToPage(pageFromUI);pageFromUI='';">
+ [% l('Go To...') %]
+ </button>
+ </span>
+ </div>
+ </li>
+ <li role="presentation" class="divider"></li>
+ <li ng-repeat="t in [1,2,3,4,5,10,25,50,100]">
+ <a href ng-click='goToPage(t);gridPageSelectIsOpen=false;'>{{t}}</a>
+ </li>
+ </ul>
+ </div>
+
+ <div class="btn-group" dropdown is-open="gridColumnPickerIsOpen">
+ <button type="button"
+ class="btn btn-default dropdown-toggle">
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu pull-right eg-grid-column-picker">
+ <li><a href ng-click="toggleConfDisplay()">
+ <span class="glyphicon glyphicon-wrench"></span>
+ [% l('Configure Columns') %]
+ </a></li>
+ <li><a href ng-click="saveConfig()">
+ <span class="glyphicon glyphicon-floppy-save"></span>
+ [% l('Save Columns') %]
+ </a></li>
+ <li><a href ng-click="showAllColumns()">
+ <span class="glyphicon glyphicon-resize-full"></span>
+ [% l('Show All Columns') %]
+ </a></li>
+ <li><a href ng-click="hideAllColumns()">
+ <span class="glyphicon glyphicon-resize-small"></span>
+ [% l('Hide All Columns') %]
+ </a></li>
+ <li><a href ng-click="resetColumns()">
+ <span class="glyphicon glyphicon-refresh"></span>
+ [% l('Reset Columns') %]
+ </a></li>
+ <li><a ng-click="generateCSVExportURL()"
+ download="{{csvExportFileName}}.csv" ng-href="{{csvExportURL}}">
+ <span class="glyphicon glyphicon-download"></span>
+ [% l('Download CSV') %]
+ </a></li>
+ <li><a href ng-click="printCSV()">
+ <span class="glyphicon glyphicon-print"></span>
+ [% l('Print CSV') %]
+ </a></li>
+ <li role="presentation" class="divider"></li>
+ <li ng-repeat="col in columns">
+ <a href ng-click="toggleColumnVisibility(col)">
+ <span ng-if="col.visible"
+ class="label label-success">✓</span>
+ <span ng-if="!col.visible"
+ class="label label-warning">✗</span>
+ <span>{{col.label}}</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
+
+<!-- Grid -->
+<div class="eg-grid" ng-class="{'eg-grid-as-conf' : showGridConf}">
+
+ <!-- import our eg-grid-field defs -->
+ <div ng-transclude></div>
+
+ <div class="eg-grid-row eg-grid-header-row">
+ <div class="eg-grid-cell eg-grid-cell-stock">
+ <div title="[% l('Row Number Column') %]">[% l('#') %]</div>
+ </div>
+ <div class="eg-grid-cell eg-grid-cell-stock">
+ <div>
+ <input title="[% l('Row Selector Column') %]"
+ focus-me="gridControls.focusRowSelector"
+ type='checkbox' ng-model="selectAll"/>
+ </div>
+ </div>
+ <div class="eg-grid-cell"
+ eg-grid-column-drag-dest
+ column="{{col.name}}"
+ eg-right-click="onContextMenu($event)"
+ ng-repeat="col in columns"
+ style="flex:{{col.flex}}"
+ ng-show="col.visible">
+
+ <div style="display:flex">
+ <div style="flex:1" class="eg-grid-column-move-handle">
+ <div ng-if="col.sortable">
+ <a column="{{col.name}}" href
+ eg-grid-column-drag-source
+ ng-click="quickSort(col.name)">{{col.label}}</a>
+ </div>
+ <div ng-if="!col.sortable">
+ <div column="{{col.name}}" eg-grid-column-drag-source>{{col.label}}</div>
+ </div>
+ </div>
+ <div eg-grid-column-drag-source
+ drag-type="resize" column="{{col.name}}"
+ class="eg-grid-column-resize-handle"> </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Inline grid configuration row -->
+ <div class="eg-grid-row eg-grid-conf-row" ng-show="showGridConf">
+ <div class="eg-grid-cell eg-grid-cell-conf-header">
+ <div class="eg-grid-conf-cell-entry">[% l('Expand') %]</div>
+ <div class="eg-grid-conf-cell-entry">[% l('Shrink') %]</div>
+ <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">[% l('Sort') %]</div>
+ </div>
+ <div class="eg-grid-cell"
+ ng-repeat="col in columns"
+ style="flex:{{col.flex}}"
+ ng-show="col.visible">
+ <div class="eg-grid-conf-cell-entry">
+ <a href="" title="[% l('Make column wider') %]"
+ ng-click="modifyColumnFlex(col,1)">
+ <span class="glyphicon glyphicon-fast-forward"></span>
+ </a>
+ </div>
+ <div class="eg-grid-conf-cell-entry">
+ <a href="" title="[% l('Make column narrower') %]"
+ ng-click="modifyColumnFlex(col,-1)">
+ <span class="glyphicon glyphicon-fast-backward"></span>
+ </a>
+ </div>
+ <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">
+ <div ng-if="col.multisortable">
+ <input type='number' ng-model="col.sort"
+ title="[% l('Sort Priority / Direction') %]" style='width:2.3em'/>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="eg-grid-content-body">
+ <div ng-show="items.length == 0"
+ class="alert alert-info">[% l('No Items To Display') %]</div>
+
+ <div class="eg-grid-row"
+ id="eg-grid-row-{{$index + 1}}"
+ ng-repeat="item in items"
+ ng-show="items.length > 0"
+ ng-class="{'eg-grid-row-selected' : selected[indexValue(item)]}">
+ <div class="eg-grid-cell eg-grid-cell-stock"
+ ng-click="handleRowClick($event, item)" title="[% l('Row Index') %]">
+ <a href ng-show="gridControls.activateItem"
+ ng-click="gridControls.activateItem(item)" style="font-weight:bold">
+ {{$index + offset() + 1}}
+ </a>
+ <div ng-hide="gridControls.activateItem">{{$index + offset() + 1}}</div>
+ </div>
+ <div class="eg-grid-cell eg-grid-cell-stock">
+ <!-- ng-click=handleRowClick here has unintended
+ consequences and is unnecessary, avoid it -->
+ <div>
+ <input type='checkbox' title="[% l('Select Row') %]"
+ ng-model="selected[indexValue(item)]"/>
+ </div>
+ </div>
+ <div class="eg-grid-cell eg-grid-cell-content"
+ ng-click="handleRowClick($event, item)"
+ ng-dblclick="gridControls.activateItem(item)"
+ ng-repeat="col in columns"
+ style="flex:{{col.flex}}"
+ ng-show="col.visible">
+
+ <!-- if the cell comes with its own template,
+ translate that content into HTML and insert it here -->
+ <span ng-if="col.template"
+ ng-bind-html="translateCellTemplate(col, item)">
+ </span>
+
+ <!-- otherwise, simply display the item value, which may
+ pass through datatype-specific filtering. -->
+ <span ng-if="!col.template">
+ {{itemFieldValue(item, col) | egGridValueFilter:col}}
+ </span>
+ </div>
+ </div>
+ </div>
+
+
+</div>
+
--- /dev/null
+<!--
+ Generic confirmation dialog
+-->
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title alert alert-info">{{title}}</h4>
+ </div>
+ <div class="modal-body">{{message}}</div>
+ <div class="modal-footer">
+ [% dialog_footer %]
+ <input type="submit" class="btn btn-primary"
+ ng-click="ok()" value="[% l('OK/Continue') %]"/>
+ <button class="btn btn-warning"
+ ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
--- /dev/null
+<div class="eg-embed-frame">
+ <!-- height is calculated at render time -->
+ <iframe
+ src="{{url}}"
+ style="height:{{height}}px"
+ onload="egEmbedFrameLoader(this)">
+ </iframe>
+</div>
+
--- /dev/null
+<!--
+ Generic confirmation dialog
+-->
+<div>
+ <div class="modal-header">
+ <button type="button" class="close"
+ ng-click="cancel()" aria-hidden="true">×</button>
+ <h4 class="modal-title alert alert-info">{{message}}</h4>
+ </div>
+ <div class="modal-body">
+ <div class="col-md-12">
+ <input type='text' ng-model="args.value" class="form-control" focus-me="focus"/>
+ </div>
+ </div>
+ <div class="modal-footer">
+ [% dialog_footer %]
+ <input type="submit" class="btn btn-primary"
+ ng-click="ok()" value="[% l('OK/Continue') %]"/>
+ <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+ </div>
+</div>
--- /dev/null
+<!-- Status bar along the bottom of the page -->
+
+<div id="status-bar"
+ class="navbar navbar-default navbar-fixed-bottom"
+ role="navigation">
+
+ <!--
+ Define the status bar as a directive so it may be used globally.
+ The template is defined inline (below) to leverage i18n and
+ so one less network fetch is required.
+ -->
+ <eg-status-bar></eg-status-bar>
+ <script type="text/ng-template" id="eg-status-bar-template">
+ <ul class="nav navbar-nav navbar-right">
+ <li>{{messages[0]}}</li>
+ <li>
+ <span
+ ng-click="hatchConnect()"
+ title="[% l('Print/Store Connection Status') %]"
+ class="glyphicon glyphicon-transfer"
+ ng-class="{'status-bar-connected' : hatchConnected()}">
+ </span>
+ </li>
+ <li>
+ <span
+ title="[% l('Network Connection Status') %]"
+ class="glyphicon glyphicon-signal"
+ ng-class="{'status-bar-connected' : netConnected()}">
+ </span>
+ </li>
+ </ul>
+ </script>
+</div>
--- /dev/null
+<div class="container">
+ <div class="row">
+ <div class="col-md-3"></div><!-- offset? -->
+ <div class="col-md-6">
+ <fieldset>
+ <legend>[% l('Sign In') %]</legend>
+ <!--
+ login() hangs off the page $scope.
+ Values entered by the user are put into 'args',
+ which is is autovivicated if needed.
+ The input IDs are there to match the labels.
+ They are not referenced in the Login controller.
+ -->
+ <form ng-submit="login(args)" name="login-form" class="form-horizontal" role="form">
+ <div class="form-group">
+ <label class="col-md-4 control-label" for="login-username">[% l('Username') %]</label>
+ <div class="col-md-8">
+ <input type="text" id="login-username" class="form-control"
+ focus-me="focusMe" select-me="focusMe"
+ placeholder="Username" ng-model="args.username"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label class="col-md-4 control-label" for="login-password">[% l('Password') %]</label>
+ <div class="col-md-8">
+ <input type="password" id="login-password" class="form-control"
+ placeholder="Password" ng-model="args.password"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label class="col-md-4 control-label"
+ for="login-workstation">[% l('Workstation') %]</label>
+ <div class="col-md-8">
+ <select class="form-control" ng-model="args.workstation"
+ ng-options="ws for ws in workstations">
+ <option>[% l('Select Workstation') %]</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="col-md-offset-4 col-md-2">
+ <button type="submit" class="btn btn-default">[% l('Sign in') %]</button>
+ </div>
+ <div class="col-md-2">
+ <span ng-show="loginFailed" class="label label-warning">[% l('Login Failed') %]</span>
+ </div>
+ </div>
+
+ </form>
+ </fieldset>
+ </div>
+ <div class="col-md-3"></div><!-- offset? -->
+ </div>
+</div>
--- /dev/null
+<div class="container">
+ <div class="row">
+ <div class="col-md-12 text-center">
+ <img src="/xul/server/skin/media/images/portal/logo.png"/>
+ </div>
+ </div>
+ <br/>
+ <div class="row" id="splash-nav">
+
+ <div class="col-md-4">
+ <div class="panel panel-success">
+ <div class="panel-heading">
+ <div class="panel-title text-center">[% l('Circulation and Patrons') %]</div>
+ </div>
+ <div class="panel-body">
+ <div>
+ <img src="/xul/server/skin/media/images/portal/forward.png"/>
+ <a target="_self" href="./circ/patron/bcsearch">[% l('Check Out Items') %]</a>
+ </div>
+ <div>
+ <img src="/xul/server/skin/media/images/portal/back.png"/>
+ <a target="_self" href="./circ/checkin/index">[% l('Check In Items') %]</a>
+ </div>
+ <div>
+ <img src="/xul/server/skin/media/images/portal/retreivepatron.png"/>
+ <a target="_self" href="./circ/patron/search">[% l('Search For Patron By Name') %]</a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-md-4">
+ <div class="panel panel-success">
+ <div class="panel-heading">
+ <div class="panel-title text-center">[% l('Item Search and Cataloging') %]</div>
+ </div>
+ <div class="panel-body">
+ <div>
+ <img src="/xul/server/skin/media/images/portal/bucket.png"/>
+ <a target="_self" href="./cat/bucket/record/">[% l('Record Buckets') %]</a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-md-4">
+ <div class="panel panel-success">
+ <div class="panel-heading">
+ <div class="panel-title text-center">[% l('Administration') %]</div>
+ </div>
+ <div class="panel-body">
+ <div>
+ <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+ <a target="_top" href="http://docs.evergreen-ils.org/">
+ [% l('Evergreen Documentation') %]
+ </a>
+ </div>
+ <div>
+ <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+ <a target="_top" href="./admin/workstation/index">
+ [% l('Workstation Administration') %]
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ </div>
+</div>
/* staff client integration functions */
+
+// Browser staff client runs the TPAC within an iframe, whose onload
+// is not called until after the page onload is called. window.onload
+// actions are wrapped in timeouts (below) to ensure the wrapping page
+// has a chance to insert the necessary xulG, etc. functions into the
+// window.
+
function debug(msg){dump(msg+'\n')}
var eventCache={};
function attachEvt(scope, name, action) {
return;
}
- if(typeof xulG != 'undefined' && xulG.get_barcode_and_settings) {
- var cur_hold_barcode = undefined;
- var barcode = isload;
- if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
- var only_settings = true;
- if(!document.getElementById('hold_usr_is_requestor').checked) {
- if(!isload) {
- barcode = document.getElementById('hold_usr_input').value;
- only_settings = false;
- }
- if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
- document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
- }
- if(barcode == undefined || barcode == '') {
- document.getElementById('patron_name').innerHTML = '';
- // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
- document.getElementById('place_hold_submit').disabled = true;
- document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
- cur_hold_barcode = null;
- return;
+ if (!window.xulG) return;
+
+ var cur_hold_barcode = undefined;
+ var barcode = isload;
+ if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
+ var only_settings = true;
+ if(!document.getElementById('hold_usr_is_requestor').checked) {
+ if(!isload) {
+ barcode = document.getElementById('hold_usr_input').value;
+ only_settings = false;
}
- if(barcode == cur_hold_barcode)
- return;
- // No submitting until we think the barcode is valid
+ if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
+ document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
+ }
+ if(barcode == undefined || barcode == '') {
+ document.getElementById('patron_name').innerHTML = '';
+ // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
document.getElementById('place_hold_submit').disabled = true;
+ document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+ cur_hold_barcode = null;
+ return;
+ }
+ if(barcode == cur_hold_barcode)
+ return;
+ // No submitting until we think the barcode is valid
+ document.getElementById('place_hold_submit').disabled = true;
+
+ if (window.IAMBROWSER) {
+ // Browser client operates asynchronously
+ if (!xulG.get_barcode_and_settings_async) return;
+ xulG.get_barcode_and_settings_async(barcode, only_settings)
+ .then(
+ function(load_info) { // load succeeded
+ staff_hold_usr_barcode_changed2(
+ isload, only_settings, barcode, cur_hold_barcode, load_info);
+ },
+ function() {
+ // load failed (rejected). Call staff_hold_usr_barcode_changed2
+ // anyway, since it handles clearing the form
+ staff_hold_usr_barcode_changed2(
+ isload, only_settings, barcode, cur_hold_barcode, false);
+ }
+ )
+ } else {
+ // XUL version is synchronous
+ if (!xulG.get_barcode_and_settings) return;
var load_info = xulG.get_barcode_and_settings(window, barcode, only_settings);
- if(load_info == false || load_info == undefined) {
- document.getElementById('patron_name').innerHTML = '';
- document.getElementById("patron_usr_barcode_not_found").style.display = '';
- cur_hold_barcode = null;
- return;
- }
- cur_hold_barcode = load_info.barcode;
- if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
- if(load_info.settings['opac.default_pickup_location'])
- document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
- if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
- if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
- if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
- if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
- var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
- var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
- var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
- var update_elements = document.getElementsByName('email_notify');
- for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
- update_elements = document.getElementsByName('phone_notify_checkbox');
- for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
- update_elements = document.getElementsByName('sms_notify_checkbox');
- for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
- }
- update_elements = document.getElementsByName('phone_notify');
- for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
- update_elements = document.getElementsByName('sms_notify');
- for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
- update_elements = document.getElementsByName('sms_carrier');
- for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
- update_elements = document.getElementsByName('email_notify');
- for(var i in update_elements) {
- update_elements[i].disabled = (load_info.user_email ? false : true);
- if(update_elements[i].disabled) update_elements[i].checked = false;
- }
- update_elements = document.getElementsByName('email_address');
- for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
- if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
- document.getElementById('patron_name').innerHTML = load_info.patron_name;
- document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
- }
- // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
-
- // update the advanced hold options link to propagate the patron
- // barcode if clicked. This is needed when the patron barcode
- // is manually entered (i.e. the staff client does not provide one).
- var adv_link = document.getElementById('advanced_hold_link');
- if (adv_link) { // not present on MR hold pages
- var href = adv_link.getAttribute('href').replace(
- /;usr_barcode=[^;\&]+|$/,
- ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
- adv_link.setAttribute('href', href);
- }
+ staff_hold_usr_barcode_changed2(isload, only_settings, barcode, cur_hold_barcode, load_info);
+ }
+}
+
+function staff_hold_usr_barcode_changed2(
+ isload, only_settings, barcode, cur_hold_barcode, load_info) {
+
+ if(load_info == false || load_info == undefined) {
+ document.getElementById('patron_name').innerHTML = '';
+ document.getElementById("patron_usr_barcode_not_found").style.display = '';
+ cur_hold_barcode = null;
+ return;
+ }
+ cur_hold_barcode = load_info.barcode;
+ if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
+ if(load_info.settings['opac.default_pickup_location'])
+ document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
+ if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
+ if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
+ if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
+ if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
+ var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
+ var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
+ var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
+ var update_elements = document.getElementsByName('email_notify');
+ for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
+ update_elements = document.getElementsByName('phone_notify_checkbox');
+ for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
+ update_elements = document.getElementsByName('sms_notify_checkbox');
+ for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
+ }
+ update_elements = document.getElementsByName('phone_notify');
+ for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
+ update_elements = document.getElementsByName('sms_notify');
+ for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
+ update_elements = document.getElementsByName('sms_carrier');
+ for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
+ update_elements = document.getElementsByName('email_notify');
+ for(var i in update_elements) {
+ update_elements[i].disabled = (load_info.user_email ? false : true);
+ if(update_elements[i].disabled) update_elements[i].checked = false;
+ }
+ update_elements = document.getElementsByName('email_address');
+ for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
+ if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
+ document.getElementById('patron_name').innerHTML = load_info.patron_name;
+ document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+ }
+ // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
- if (isload !== true)
- document.getElementById('place_hold_submit').disabled = false;
+ // update the advanced hold options link to propagate the patron
+ // barcode if clicked. This is needed when the patron barcode
+ // is manually entered (i.e. the staff client does not provide one).
+ var adv_link = document.getElementById('advanced_hold_link');
+ if (adv_link) { // not present on MR hold pages
+ var href = adv_link.getAttribute('href').replace(
+ /;usr_barcode=[^;\&]+|$/,
+ ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
+ adv_link.setAttribute('href', href);
}
+
+ if (isload !== true)
+ document.getElementById('place_hold_submit').disabled = false;
}
window.onload = function() {
// record details page events
- var rec = location.href.match(/\/opac\/record\/(\d+)/);
- if(rec && rec[1]) {
- runEvt('rdetail', 'recordRetrieved', rec[1]);
- runEvt('rdetail', 'MFHDDrawn');
- }
- if(location.href.match(/place_hold/)) {
- // patron barcode may come from XUL or a CGI param
- var patron_barcode = xulG.patron_barcode ||
- document.getElementById('hold_usr_input').value;
- if(patron_barcode) {
- staff_hold_usr_barcode_changed(patron_barcode);
- } else {
- staff_hold_usr_barcode_changed(true);
+
+ setTimeout(function() {
+ var rec = location.href.match(/\/opac\/record\/(\d+)/);
+ if(rec && rec[1]) {
+ runEvt('rdetail', 'recordRetrieved', rec[1]);
+ runEvt('rdetail', 'MFHDDrawn');
}
- }
+ if(location.href.match(/place_hold/)) {
+ // patron barcode may come from XUL or a CGI param
+ var patron_barcode = xulG.patron_barcode ||
+ document.getElementById('hold_usr_input').value;
+ if(patron_barcode) {
+ staff_hold_usr_barcode_changed(patron_barcode);
+ } else {
+ staff_hold_usr_barcode_changed(true);
+ }
+ }
+ });
}
function rdetail_next_prev_actions(index, count, prev, next, start, end, results) {
ol = window.onload;
window.onload = function() {
if(ol) ol();
- runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count));
+ setTimeout(function() {
+ runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count));
+ });
};
}
--- /dev/null
+module.exports = function(grunt) {
+
+ // Project configuration.
+ var config = {
+ pkg: grunt.file.readJSON('package.json'),
+
+ // copy the files we care about from bower-fetched dependencies
+ // into our build directory
+ copy: {
+
+ js : {
+ files: [{
+ dest: 'build/js/',
+ flatten: true,
+ filter: 'isFile',
+ expand : true,
+ src: [
+ 'bower_components/angular/angular.min.js',
+ 'bower_components/angular/angular.min.js.map',
+ 'bower_components/angular-route/angular-route.min.js',
+ 'bower_components/angular-route/angular-route.min.js.map',
+ 'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js',
+ 'bower_components/angular-hotkeys/build/hotkeys.min.js',
+ ]
+ }]
+ },
+
+ css : {
+ files : [{
+ dest : 'build/css/',
+ flatten : true,
+ filter : 'isFile',
+ expand : true,
+ src : [
+ 'bower_components/angular-hotkeys/build/hotkeys.min.css',
+ 'bower_components/bootstrap/dist/css/bootstrap.min.css'
+ ]
+ }]
+ },
+
+ fonts : {
+ files : [{
+ dest : 'build/fonts/',
+ flatten : true,
+ filter : 'isFile',
+ expand : true,
+ src : [
+ 'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot',
+ 'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg',
+ 'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf',
+ 'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff'
+ ]
+ }]
+ }
+ },
+
+ // combine our CSS deps
+ // note: minification also supported, but not required (yet).
+ cssmin: {
+ combine: {
+ files: {
+ 'build/css/evergreen-staff-client-deps.<%= pkg.version %>.min.css' : [
+ 'build/css/hotkeys.min.css',
+ 'build/css/bootstrap.min.css'
+ ]
+ }
+ }
+ },
+
+ // concatenation + minification
+ uglify: {
+ options: {
+ banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
+ },
+ build: {
+ src: [
+ // These are concatenated in order in the final build file.
+ // The order is important.
+ 'build/js/angular.min.js',
+ 'build/js/angular-route.min.js',
+ 'build/js/ui-bootstrap-tpls.min.js',
+ 'build/js/hotkeys.min.js',
+ // NOTE: OpenSRF must be installed
+ '/openils/lib/javascript/JSON_v1.js',
+ '/openils/lib/javascript/opensrf.js',
+ '/openils/lib/javascript/opensrf_ws.js',
+ 'services/core.js',
+ 'services/strings.js',
+ 'services/idl.js',
+ 'services/event.js',
+ 'services/net.js',
+ 'services/auth.js',
+ 'services/pcrud.js',
+ 'services/env.js',
+ 'services/org.js',
+ 'services/startup.js',
+ 'services/hatch.js',
+ 'services/print.js',
+ 'services/coresvc.js',
+ 'services/navbar.js',
+ 'services/statusbar.js',
+ 'services/ui.js',
+ ],
+ dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js'
+ }
+ },
+
+ // bare concat operation; useful for testing concat w/o minification
+ // to more easily detect if concat order is incorrect
+ concat: {
+ options: {
+ separator: ';',
+ }
+ },
+
+ exec : {
+
+ // Generate test/data/IDL2js.js for unit tests.
+ // note: the output of this script is *not* part of the final build.
+ idl2js : {
+ command : 'cd test/data && perl idl2js.pl',
+ },
+
+ // Remove the unit test IDL2js.js file. We don't need it after testing
+ rmidl2js : {
+ command : 'rm test/data/IDL2js.js',
+ }
+ },
+
+ // unit tests configuration
+ karma : {
+ unit: {
+ configFile: 'test/karma.conf.js',
+ //background: true // for now, visually babysit unit tests
+ }
+ }
+ };
+
+ // tell concat about our uglify build options (instead of repeating them)
+ config.concat.build = config.uglify.build;
+
+ // apply our configuration
+ grunt.initConfig(config);
+
+ // Load our modules
+ grunt.loadNpmTasks('grunt-contrib-uglify');
+ grunt.loadNpmTasks('grunt-contrib-concat');
+ grunt.loadNpmTasks('grunt-contrib-copy');
+ grunt.loadNpmTasks('grunt-contrib-cssmin');
+ grunt.loadNpmTasks('grunt-karma');
+ grunt.loadNpmTasks('grunt-exec');
+
+ // note: "grunt concat" is not requried
+ grunt.registerTask('build', ['copy', 'cssmin', 'uglify']);
+
+ // test only, no minification
+ grunt.registerTask('test', ['copy', 'exec:idl2js', 'karma:unit', 'exec:rmidl2js']);
+
+ // note: "grunt concat" is not requried
+ grunt.registerTask('all', ['test', 'cssmin', 'uglify']);
+
+};
+
+// vim: ts=2:sw=2:softtabstop=2
--- /dev/null
+= Building, Testing, Packaging the Browser Client =
+:Author: Bill Erickson
+:Email: berick@esilibrary.com
+:Date: 2014-05-07
+
+== Prerequisites ==
+
+ * http://bower.io/[Bower]
+ ** Dependency retrieval
+ * http://jasmine.github.io/[Jasmine]
+ ** Headless unit tests runner
+ * http://gruntjs.com/[Grunt]
+ ** Coordinating the build
+ ** Concatenation + minification of JS and CSS
+
+These are all Node.js plugins, so start by installing Node.js
+
+=== Install Node.js ===
+
+Node.js does not have have Debian Wheezy build target. For now, I've opted
+to install from source. For more, see also
+https://github.com/joyent/node/wiki/installation[Node.js Installation]
+
+[source,sh]
+------------------------------------------------------------------------------
+% git clone https://github.com/joyent/node.git
+% cd node
+% git checkout -b v0.10.28 v0.10.28
+
+# set -j to number of CPU cores + 1
+% ./configure && make -j5 && sudo make install
+
+# update packages
+% sudo npm update
+------------------------------------------------------------------------------
+
+=== Install Grunt CLI ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g grunt-cli
+------------------------------------------------------------------------------
+
+=== Install Bower ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g bower
+------------------------------------------------------------------------------
+
+== Building, Testing, Minification ==
+
+The remaining steps all take place within the staff JS web root:
+
+[source,sh]
+------------------------------------------------------------------------------
+% cd $EVERGREEN_ROOT/Open-ILS/web/js/ui/default/staff/
+------------------------------------------------------------------------------
+
+=== Install Project-local Dependencies ===
+
+npm inspects the 'package.json' file for dependencies and fetches them
+from the Node package network.
+
+[source,sh]
+------------------------------------------------------------------------------
+% npm install # fetch Grunt dependencies
+% bower install # fetch JS dependencies
+------------------------------------------------------------------------------
+
+=== Running the Build Scripts ===
+
+[source,sh]
+------------------------------------------------------------------------------
+
+# build, run tests
+% grunt test
+
+# build, concat+minify
+% grunt uglify
+
+# build, run tests, concat+minify
+% grunt all
+------------------------------------------------------------------------------
+
+== TODO ==
+
+ * Minification of app-specific JS files
+ * Integrate this into the Evergreen Makefile test and install targets
+ ** Avoid installing test, node_modules, etc. into the web dir.
+ * Support fetching JS deps (angularjs, etc.) via direct retrieval for
+ installation without test + concat + minify (i.e. w/o requiring Node.js)?
+
--- /dev/null
+/**
+ * App to drive the base page.
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egUserPermsEditor',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider',
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/);
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ $routeProvider.when('/admin/user_perms', {
+ templateUrl: './admin/t_user_perms_lookup',
+ controller: 'UserPermsLookupCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/admin/user_perms/:user_id', {
+ templateUrl: 'user-perms-template',
+ controller: 'UserPermsCtrl',
+ resolve : resolver
+ });
+
+ // default page
+ $routeProvider.otherwise({
+ templateUrl : 'user-perms-template',
+ controller: 'UserPermsCtrl',
+ resolve : resolver
+ });
+}])
+
+.controller('UserPermsLookupCtrl',
+ ['$scope','$window','$location','egCore',
+function($scope , $window , $location , egCore) {
+
+ $scope.selectMe = true; // focus text input
+ $scope.args = {};
+
+ // find the user by barcode, the jump to the editor
+ $scope.submitBarcode = function(args) {
+
+ $scope.bcNotFound = null;
+ if (!args.barcode) return;
+
+ $scope.selectMe = false;
+
+ // lookup barcode
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ egCore.auth.token(), egCore.auth.user().ws_ou(),
+ 'actor', args.barcode)
+
+ .then(function(resp) { // get_barcodes
+
+ if (evt = egCore.evt.parse(resp)) {
+ console.error(evt.toString());
+ return;
+ }
+
+ if (!resp || !resp[0]) {
+ $scope.bcNotFound = args.barcode;
+ $scope.selectMe = true;
+ return;
+ }
+
+ // see if an opt-in request is needed
+ user_id = resp[0].id;
+ $location.path($location.path() + '/' + user_id);
+ });
+ }
+
+}])
+
+.controller('UserPermsCtrl',
+ ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+ var user_id = $routeParams.user_id;
+
+ var url = $location.absUrl().replace(
+ /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+ url += '?usr=' + encodeURIComponent(user_id);
+
+ // user_edit does not load the session via cookie. It uses URL
+ // params or xulG instead. Pass via xulG.
+ $scope.funcs = {
+ ses : egCore.auth.token(),
+ on_patron_save : function() {
+ $scope.funcs.reload();
+ }
+ }
+
+ $scope.user_perms_url = url;
+}])
--- /dev/null
+/**
+ * App to drive the base page.
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egWorkstationAdmin',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider',
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/);
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ $routeProvider.when('/admin/workstation/print/config', {
+ templateUrl: './admin/workstation/t_print_config',
+ controller: 'PrintConfigCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/admin/workstation/print/templates', {
+ templateUrl: './admin/workstation/t_print_templates',
+ controller: 'PrintTemplatesCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/admin/workstation/stored_prefs', {
+ templateUrl: './admin/workstation/t_stored_prefs',
+ controller: 'StoredPrefsCtrl',
+ resolve : resolver
+ });
+
+
+ // default page
+ $routeProvider.otherwise({
+ templateUrl : './admin/workstation/t_splash',
+ controller : 'SplashCtrl',
+ resolve : resolver
+ });
+}])
+
+.controller('SplashCtrl',
+ ['$scope','$window','$location','egCore','egConfirmDialog',
+function($scope , $window , $location , egCore , egConfirmDialog) {
+
+ var allWorkstations = [];
+ var permMap = {};
+ $scope.contextOrg = egCore.org.get(egCore.auth.user().ws_ou());
+
+ egCore.perm.hasPermAt('REGISTER_WORKSTATION', true)
+ .then(function(orgList) {
+
+ // hide orgs in the context org selector where this login
+ // does not have the reg_ws perm
+ $scope.wsOrgHidden = function(id) {
+ return orgList.indexOf(id) == -1;
+ }
+ $scope.userHasRegPerm =
+ orgList.indexOf($scope.contextOrg.id()) > -1;
+ });
+
+ // fetch the stored WS info
+ egCore.hatch.getItem('eg.workstation.all')
+ .then(function(all) {
+ allWorkstations = all || [];
+ $scope.workstations =
+ allWorkstations.map(function(w) { return w.name });
+ return egCore.hatch.getItem('eg.workstation.default');
+ })
+ .then(function(def) {
+ $scope.defaultWS = def;
+ $scope.activeWS = $scope.selectedWS = egCore.auth.workstation() || def;
+ });
+
+ $scope.getWSLabel = function(ws) {
+ return ws == $scope.defaultWS ?
+ egCore.strings.$replace(egCore.strings.DEFAULT_WS_LABEL, {ws:ws}) : ws;
+ }
+
+ $scope.setDefaultWS = function() {
+ egCore.hatch.setItem(
+ 'eg.workstation.default', $scope.selectedWS)
+ .then(function() { $scope.defaultWS = $scope.selectedWS });
+ }
+
+ // redirect the user to the login page using the current
+ // workstation as the workstation URL param
+ $scope.useWS = function() {
+ $window.location.href = $location
+ .path('/login')
+ .search({ws : $scope.selectedWS})
+ .absUrl();
+ }
+
+ $scope.registerWS = function() {
+ register_workstation(
+ $scope.newWSName,
+ $scope.contextOrg.shortname() + '-' + $scope.newWSName,
+ $scope.contextOrg.id()
+ );
+ }
+
+ function register_workstation(base_name, name, org_id, override) {
+
+ var method = 'open-ils.actor.workstation.register';
+ if (override) method += '.override';
+
+ egCore.net.request(
+ 'open-ils.actor', method, egCore.auth.token(), name, org_id)
+
+ .then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.log('register returned ' + evt.toString());
+
+ if (evt.textcode == 'WORKSTATION_NAME_EXISTS' && !override) {
+ egConfirmDialog.open(
+ egCore.strings.WS_EXISTS, base_name, {
+ ok : function() {
+ register_workstation(base_name, name, org_id, true);
+ },
+ cancel : function() {}
+ }
+ );
+
+ } else {
+ // TODO: provide permission error display
+ alert(evt.toString());
+ }
+ } else if (resp) {
+ $scope.workstations.push(name);
+
+ allWorkstations.push({
+ id : resp,
+ name : name,
+ owning_lib : org_id
+ });
+
+ egCore.hatch.setItem(
+ 'eg.workstation.all', allWorkstations)
+ .then(function() {
+ if (allWorkstations.length == 1) {
+ // first one registerd, also mark it as the default
+ $scope.selectedWS = name;
+ $scope.setDefaultWS();
+ }
+ });
+ }
+ });
+ }
+
+ $scope.wsOrgChanged = function(org) {
+ $scope.contextOrg = org;
+ }
+
+ // ---------------------
+ // Hatch Configs
+ $scope.hatchURL = egCore.hatch.hatchURL();
+ $scope.hatchRequired =
+ egCore.hatch.getLocalItem('eg.hatch.required');
+
+ $scope.updateHatchRequired = function() {
+ egCore.hatch.setLocalItem(
+ 'eg.hatch.required', $scope.hatchRequired);
+ }
+
+ $scope.updateHatchURL = function() {
+ egCore.hatch.setLocalItem(
+ 'eg.hatch.url', $scope.hatchURL);
+ }
+}])
+
+.controller('PrintConfigCtrl',
+ ['$scope','egCore',
+function($scope , egCore) {
+ console.log('PrintConfigCtrl');
+
+ $scope.actionPending = false;
+ $scope.isTestView = false;
+
+ $scope.setContext = function(ctx) {
+ $scope.context = ctx;
+ $scope.isTestView = false;
+ $scope.actionPending = false;
+ }
+ $scope.setContext('default');
+
+ $scope.getPrinterByAttr = function(attr, value) {
+ var printer;
+ angular.forEach($scope.printers, function(p) {
+ if (p[attr] == value) printer = p;
+ });
+ return printer;
+ }
+
+ $scope.currentPrinter = function() {
+ if ($scope.printConfig && $scope.printConfig[$scope.context]) {
+ return $scope.getPrinterByAttr(
+ 'name', $scope.printConfig[$scope.context].printer
+ );
+ }
+ }
+
+ // fetch info on all remote printers
+ egCore.hatch.getPrinters()
+ .then(function(printers) {
+ $scope.printers = printers;
+ $scope.defaultPrinter =
+ $scope.getPrinterByAttr('is-default', true);
+ })
+ .then(function() { return egCore.hatch.getPrintConfig() })
+ .then(function(config) {
+ $scope.printConfig = config;
+
+ var pname = '';
+ if ($scope.defaultPrinter) {
+ pname = $scope.defaultPrinter.name;
+
+ } else if ($scope.printers.length == 1) {
+ // if the OS does not report a default printer, but only
+ // one printer is available, treat it as the default.
+ pname = $scope.printers[0].name;
+ }
+
+ // apply the default printer to every context which has
+ // no printer configured.
+ angular.forEach(
+ ['default','receipt','label','mail','offline'],
+ function(ctx) {
+ if (!$scope.printConfig[ctx]) {
+ $scope.printConfig[ctx] = {
+ context : ctx,
+ printer : pname
+ }
+ }
+ }
+ );
+ });
+
+ $scope.printerConfString = function() {
+ if ($scope.printConfigError) return $scope.printConfigError;
+ if (!$scope.printConfig) return;
+ if (!$scope.printConfig[$scope.context]) return;
+ return JSON.stringify(
+ $scope.printConfig[$scope.context], undefined, 2);
+ }
+
+ $scope.resetConfig = function() {
+ $scope.actionPending = true;
+ $scope.printConfigError = null;
+ $scope.printConfig[$scope.context] = {
+ context : $scope.context
+ }
+
+ if ($scope.defaultPrinter) {
+ $scope.printConfig[$scope.context].printer =
+ $scope.defaultPrinter.name;
+ }
+
+ egCore.hatch.setPrintConfig($scope.printConfig)
+ .finally(function() {$scope.actionPending = false});
+ }
+
+ $scope.configurePrinter = function() {
+ $scope.printConfigError = null;
+ $scope.actionPending = true;
+ egCore.hatch.configurePrinter(
+ $scope.context,
+ $scope.printConfig[$scope.context].printer
+ )
+ .then(
+ function(config) {$scope.printConfig = config},
+ function(error) {$scope.printConfigError = error}
+ )
+ .finally(function() {$scope.actionPending = false});
+ }
+
+ $scope.setPrinter = function(name) {
+ $scope.printConfig[$scope.context].printer = name;
+ }
+
+ // for testing
+ $scope.setContentType = function(type) { $scope.contentType = type }
+
+ $scope.testPrint = function(withDialog) {
+ if ($scope.contentType == 'text/plain') {
+ egCore.print.print({
+ context : $scope.context,
+ content_type : $scope.contentType,
+ content : $scope.textPrintContent,
+ show_dialog : withDialog
+ });
+ } else {
+ egCore.print.print({
+ context : $scope.context,
+ content_type : $scope.contentType,
+ content : $scope.htmlPrintContent,
+ scope : {
+ value1 : 'Value One',
+ value2 : 'Value Two',
+ date_value : '2015-02-04T14:04:34-0400'
+ },
+ show_dialog : withDialog
+ });
+ }
+ }
+
+ $scope.setContentType('text/plain');
+
+}])
+
+.controller('PrintTemplatesCtrl',
+ ['$scope','$q','egCore',
+function($scope , $q , egCore) {
+
+ $scope.print = {
+ template_name : 'bills_current',
+ template_output : ''
+ };
+
+ // print preview scope data
+ // TODO: consider moving the template-specific bits directly
+ // into the templates or storing template- specific script files
+ // alongside the templates.
+ // NOTE: A lot of this data can be shared across templates.
+ var seed_user = {
+ first_given_name : 'Slow',
+ second_given_name : 'Joe',
+ family_name : 'Jones',
+ card : {
+ barcode : '30393830393'
+ }
+ }
+ var seed_addr = {
+ street1 : '123 Apple Rd',
+ street2 : 'Suite B',
+ city : 'Anywhere',
+ state : 'XX',
+ country : 'US',
+ post_code : '12345'
+ }
+
+ var seed_record = {
+ title : 'Traveling Pants!!',
+ author : 'Jane Jones',
+ isbn : '1231312123'
+ };
+
+ var seed_copy = {
+ barcode : '33434322323'
+ }
+
+ var one_hold = {
+ behind_desk : 'f',
+ phone_notify : '111-222-3333',
+ sms_notify : '111-222-3333',
+ email_notify : 'user@example.org',
+ request_time : new Date().toISOString()
+ }
+
+
+ $scope.preview_scope = {
+ //bills
+ transactions : [
+ {
+ id : 1,
+ xact_start : new Date().toISOString(),
+ summary : {
+ xact_type : 'circulation',
+ last_billing_type : 'Overdue materials',
+ total_owed : 1.50,
+ last_payment_note : 'Test Note 1',
+ total_paid : 0.50,
+ balance_owed : 1.00
+ }
+ }, {
+ id : 2,
+ xact_start : new Date().toISOString(),
+ summary : {
+ xact_type : 'circulation',
+ last_billing_type : 'Overdue materials',
+ total_owed : 2.50,
+ last_payment_note : 'Test Note 2',
+ total_paid : 0.50,
+ balance_owed : 2.00
+ }
+ }
+ ],
+
+ circulations : [
+ {
+ due_date : new Date().toISOString(),
+ target_copy : seed_copy,
+ title : seed_record.title
+ },
+ ],
+
+ previous_balance : 8.45,
+ payment_total : 2.00,
+ payment_applied : 2.00,
+ new_balance : 6.45,
+ amount_voided : 0,
+ change_given : 0,
+ payment_type : 'cash_payment',
+ payment_note : 'Here is a payment note',
+ note : {
+ create_date : new Date().toISOString(),
+ title : 'Test Note Title',
+ usr : seed_user,
+ value : 'This patron is super nice!'
+ },
+
+ transit : {
+ dest : {
+ name : 'Library X',
+ shortname : 'LX',
+ holds_address : seed_addr
+ },
+ target_copy : seed_copy
+ },
+ title : seed_record.title,
+ author : seed_record.author,
+ patron : egCore.idl.toHash(egCore.auth.user()),
+ address : seed_addr,
+ hold : one_hold,
+ holds : [
+ {hold : one_hold, title : 'Some Title 1', author : 'Some Author 1'},
+ {hold : one_hold, title : 'Some Title 2', author : 'Some Author 2'},
+ {hold : one_hold, title : 'Some Title 3', author : 'Some Author 3'}
+ ]
+ }
+
+ $scope.preview_scope.payments = [
+ {amount : 1.00, xact : $scope.preview_scope.transactions[0]},
+ {amount : 1.00, xact : $scope.preview_scope.transactions[1]}
+ ]
+ $scope.preview_scope.payments[0].xact.title = 'Hali Bote Azikaban de tao fan';
+ $scope.preview_scope.payments[0].xact.copy_barcode = '334343434';
+ $scope.preview_scope.payments[1].xact.title = seed_record.title;
+ $scope.preview_scope.payments[1].xact.copy_barcode = seed_copy.barcode;
+
+ // today, staff, current_location, etc.
+ egCore.print.fleshPrintScope($scope.preview_scope);
+
+ $scope.template_changed = function() {
+ $scope.print.load_failed = false;
+ egCore.print.getPrintTemplate($scope.print.template_name)
+ .then(
+ function(html) {
+ $scope.print.template_content = html;
+ console.log('set template content');
+ },
+ function() {
+ $scope.print.template_content = '';
+ $scope.print.load_failed = true;
+ }
+ );
+ }
+
+ $scope.save_locally = function() {
+ egCore.hatch.storePrintTemplate(
+ $scope.print.template_name,
+ $scope.print.template_content
+ );
+ }
+
+ $scope.template_changed(); // load the default
+}])
+
+//
+.directive('egPrintTemplateOutput', ['$compile',function($compile) {
+ return function(scope, element, attrs) {
+ scope.$watch(
+ function(scope) {
+ return scope.$eval(attrs.content);
+ },
+ function(value) {
+ // create an isolate scope and copy the print context
+ // data into the new scope.
+ // TODO: see also print security concerns in egHatch
+ var result = element.html(value);
+ var context = scope.$eval(attrs.context);
+ var print_scope = scope.$new(true);
+ angular.forEach(context, function(val, key) {
+ print_scope[key] = val;
+ })
+ $compile(element.contents())(print_scope);
+ }
+ );
+ };
+}])
+
+.controller('StoredPrefsCtrl',
+ ['$scope','$q','egCore','egConfirmDialog',
+function($scope , $q , egCore , egConfirmDialog) {
+ console.log('StoredPrefsCtrl');
+
+ $scope.setContext = function(ctx) {
+ $scope.context = ctx;
+ }
+ $scope.setContext('local');
+
+ // grab the edit perm
+ $scope.userHasDeletePerm = false;
+ egCore.perm.hasPermHere('ADMIN_WORKSTATION')
+ .then(function(bool) { $scope.userHasDeletePerm = bool });
+
+ // fetch the keys
+
+ function refreshKeys() {
+ $scope.keys = {local : [], remote : []};
+
+ egCore.hatch.getRemoteKeys().then(
+ function(keys) { $scope.keys.remote = keys.sort() })
+
+ // local calls are non-async
+ $scope.keys.local = egCore.hatch.getLocalKeys();
+ }
+ refreshKeys();
+
+ $scope.selectKey = function(key) {
+ $scope.currentKey = key;
+ $scope.currentKeyContent = null;
+
+ if ($scope.context == 'local') {
+ $scope.currentKeyContent = egCore.hatch.getLocalItem(key);
+ } else {
+ egCore.hatch.getRemoteItem(key)
+ .then(function(content) {
+ $scope.currentKeyContent = content
+ });
+ }
+ }
+
+ $scope.getCurrentKeyContent = function() {
+ return JSON.stringify($scope.currentKeyContent, null, 2);
+ }
+
+ $scope.removeKey = function(key) {
+ egConfirmDialog.open(
+ egCore.strings.PREFS_REMOVE_KEY_CONFIRM, '',
+ { deleteKey : key,
+ ok : function() {
+ if ($scope.context == 'local') {
+ egCore.hatch.removeLocalItem(key);
+ refreshKeys();
+ } else {
+ egCore.hatch.removeItem(key)
+ .then(function() { refreshKeys() });
+ }
+ },
+ cancel : function() {} // user canceled, nothing to do
+ }
+ );
+ }
+}])
--- /dev/null
+/**
+ * App to drive the base page.
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(
+ ['$routeProvider','$locationProvider',
+function($routeProvider , $locationProvider) {
+ $locationProvider.html5Mode(true);
+
+ /**
+ * Route resolvers allow us to run async commands
+ * before the page controller is instantiated.
+ */
+ var resolver = {delay : ['egCore',
+ function(egCore) {return egCore.startup.go()}]};
+
+ $routeProvider.when('/login', {
+ templateUrl: './t_login',
+ controller: 'LoginCtrl',
+ resolve : resolver
+ });
+
+ // default page
+ $routeProvider.otherwise({
+ templateUrl : './t_splash',
+ controller : 'SplashCtrl',
+ resolve : resolver
+ });
+}])
+
+/**
+ * Login controller.
+ * Reads the login form and submits the login request
+ */
+.controller('LoginCtrl',
+ /* inject services into our controller. Spelling them
+ * out like this allows the auto-magic injector to work
+ * even if the code has been minified */
+ ['$scope','$location','$window','egCore',
+ function($scope , $location , $window , egCore) {
+ $scope.focusMe = true;
+
+ // if the user is already logged in, jump to splash page
+ if (egCore.auth.user()) $location.path('/');
+
+ egCore.hatch.getItem('eg.workstation.all')
+ .then(function(all) {
+ if (all && all.length) {
+ $scope.workstations = all.map(function(a) { return a.name });
+
+ if (ws = $location.search().ws) {
+ // user requested a workstation via URL
+ var match = all.filter(
+ function(w) {return ws == w.name} )[0];
+
+ if (match) {
+ // requested WS registered on this client
+ $scope.args = {workstation : match.name};
+ } else {
+ // the requested WS is not registered on this client
+ $scope.wsNotRegistered = true;
+ }
+ } else {
+ // no workstation requested; use the default
+ egCore.hatch.getItem('eg.workstation.default')
+ .then(function(ws) {
+ $scope.args = {workstation : ws}
+ });
+ }
+ }
+ })
+
+ $scope.login = function(args) {
+ $scope.loginFailed = false;
+
+ if (!args) args = {}; // see FF note below
+
+ if (!args.username) {
+ /*
+ Issues with form autofill / auto-complete
+ https://github.com/angular/angular.js/issues/1460
+ http://timothy.userapp.io/post/63412334209/form-autocomplete-and-remember-password-with-angularjs
+ For now, since FF will save the values, we should
+ honor them, even if it's hacky. */
+ args.username = document.getElementById("login-username").value;
+ args.password = document.getElementById("login-password").value;
+ }
+
+ if (! (args.username && args.password) ) return;
+
+ args.type = 'staff';
+ egCore.auth.login(args).then(
+
+ function() {
+ // after login, send the user back to the originally
+ // requested page or, if none, the home page.
+ // TODO: this is a little hinky because it causes 2
+ // redirects if no route_to is defined. Improve.
+ $window.location.href =
+ $location.search().route_to ||
+ $location.path('/').absUrl()
+ },
+ function() {
+ $scope.args.password = '';
+ $scope.loginFailed = true;
+ $scope.focusMe = true;
+ }
+ );
+ }
+ }
+])
+
+/**
+ * Splash page dynamic content.
+ */
+.controller('SplashCtrl', ['$scope',
+ function($scope) {
+ console.log('SplashCtrl');
+ }
+]);
+
--- /dev/null
+{
+ "name": "evergreen-staff-client",
+ "version": "0.0.1",
+ "authors": [
+ "Bill Erickson <berick@esilibrary.com>"
+ ],
+ "description": "Evergreen HTML Staff Client",
+ "keywords": [
+ "evergreen"
+ ],
+ "license": "GPL",
+ "homepage": "http://evergreen-ils.org",
+ "ignore": [
+ "**/.*",
+ "node_modules",
+ "bower_components",
+ "test",
+ "tests"
+ ],
+ "devDependencies": {
+ "bootstrap": "~3.1.1",
+ "angular": "~1.2.16",
+ "angular-route": "~1.2.16",
+ "angular-mocks": "~1.2.16",
+ "angular-bootstrap": "~0.11.0"
+ },
+ "dependencies": {
+ "angular-hotkeys": "chieffancypants/angular-hotkeys#~1.2.0"
+ }
+}
--- /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', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ $routeProvider.when('/cat/bucket/record/search/:id', {
+ templateUrl: './cat/bucket/record/t_search',
+ controller: 'SearchCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/bucket/record/search', {
+ templateUrl: './cat/bucket/record/t_search',
+ controller: 'SearchCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/bucket/record/pending/:id', {
+ templateUrl: './cat/bucket/record/t_pending',
+ controller: 'PendingCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/bucket/record/pending', {
+ templateUrl: './cat/bucket/record/t_pending',
+ controller: 'PendingCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/bucket/record/view/:id', {
+ templateUrl: './cat/bucket/record/t_view',
+ controller: 'ViewCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/bucket/record/view', {
+ templateUrl: './cat/bucket/record/t_view',
+ controller: 'ViewCtrl',
+ resolve : resolver
+ });
+
+ // default page / bucket view
+ $routeProvider.otherwise({redirectTo : '/cat/bucket/record/view'});
+})
+
+/**
+ * bucketSvc allows us to communicate between the search,
+ * pending, and view controllers. It also allows us to cache
+ * data for each so that data reloads are not needed on every
+ * tab click (i.e. route persistence).
+ */
+.factory('bucketSvc', ['$q','egCore', function($q, egCore) {
+
+ var service = {
+ allBuckets : [], // un-fleshed user buckets
+ queryString : '', // last run query
+ queryRecords : [], // last run query results
+ currentBucket : null, // currently viewed bucket
+
+ // per-page list collections
+ searchList : [],
+ pendingList : [],
+ viewList : [],
+
+ // fetches all staff/biblio buckets for the authenticated user
+ // this function may only be called after startup.
+ fetchUserBuckets : function(force) {
+ if (this.allBuckets.length && !force) return;
+ var self = this;
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.retrieve_by_class.authoritative',
+ egCore.auth.token(), egCore.auth.user().id(),
+ 'biblio', 'staff_client'
+ ).then(function(buckets) { self.allBuckets = buckets });
+ },
+
+ createBucket : function(name, desc) {
+ var deferred = $q.defer();
+ var bucket = new egCore.idl.cbreb();
+ bucket.owner(egCore.auth.user().id());
+ bucket.name(name);
+ bucket.description(desc || '');
+ bucket.btype('staff_client');
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.create',
+ egCore.auth.token(), 'biblio', bucket
+ ).then(function(resp) {
+ if (resp) {
+ if (typeof resp == 'object') {
+ console.error('bucket create error: ' + js2JSON(resp));
+ deferred.reject();
+ } else {
+ deferred.resolve(resp);
+ }
+ }
+ });
+
+ return deferred.promise;
+ },
+
+ // edit the current bucket. since we edit the
+ // local object, there's no need to re-fetch.
+ editBucket : function(args) {
+ var bucket = service.currentBucket;
+ bucket.name(args.name);
+ bucket.description(args.desc);
+ bucket.pub(args.pub);
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.update',
+ egCore.auth.token(), 'biblio', bucket
+ );
+ }
+ }
+
+ // returns 1 if full refresh is needed
+ // returns 2 if list refresh only is needed
+ service.bucketRefreshLevel = function(id) {
+ if (!service.currentBucket) return 1;
+ if (service.bucketNeedsRefresh) {
+ service.bucketNeedsRefresh = false;
+ service.currentBucket = null;
+ return 1;
+ }
+ if (service.currentBucket.id() != id) return 1;
+ return 2;
+ }
+
+ // returns a promise, resolved with bucket, rejected if bucket is
+ // not fetch-able
+ service.fetchBucket = function(id) {
+ var refresh = service.bucketRefreshLevel(id);
+ if (refresh == 2) return $q.when(service.currentBucket);
+
+ var deferred = $q.defer();
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.flesh.authoritative',
+ egCore.auth.token(), 'biblio', id
+ ).then(function(bucket) {
+ var evt = egCore.evt.parse(bucket);
+ if (evt) {
+ console.debug(evt);
+ deferred.reject(evt);
+ return;
+ }
+ service.currentBucket = bucket;
+ deferred.resolve(bucket);
+ });
+
+ return deferred.promise;
+ }
+
+ // deletes a single container item from a bucket by container item ID.
+ // promise is rejected on failure
+ service.detachRecord = function(itemId) {
+ var deferred = $q.defer();
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.item.delete',
+ egCore.auth.token(), 'biblio', itemId
+ ).then(function(resp) {
+ var evt = egCore.evt.parse(resp);
+ if (evt) {
+ console.error(evt);
+ deferred.reject(evt);
+ return;
+ }
+ console.log('detached bucket item ' + itemId);
+ deferred.resolve(resp);
+ });
+
+ return deferred.promise;
+ }
+
+ // delete bucket by ID.
+ // resolved w/ response on successful delete,
+ // rejected otherwise.
+ service.deleteBucket = function(id) {
+ var deferred = $q.defer();
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.full_delete',
+ egCore.auth.token(), 'biblio', id
+ ).then(function(resp) {
+ var evt = egCore.evt.parse(resp);
+ if (evt) {
+ console.error(evt);
+ deferred.reject(evt);
+ return;
+ }
+ deferred.resolve(resp);
+ });
+ return deferred.promise;
+ }
+
+ return service;
+}])
+
+/**
+ * Top-level controller.
+ * Hosts functions needed by all controllers.
+ */
+.controller('RecordBucketCtrl',
+ ['$scope','$location','$q','$timeout','$modal',
+ '$window','egCore','bucketSvc',
+function($scope, $location, $q, $timeout, $modal,
+ $window, egCore, bucketSvc) {
+
+ $scope.bucketSvc = bucketSvc;
+ $scope.bucket = function() { return bucketSvc.currentBucket }
+
+ // tabs: search, pending, view
+ $scope.setTab = function(tab) {
+ $scope.tab = tab;
+
+ // for bucket selector; must be called after route resolve
+ bucketSvc.fetchUserBuckets();
+ };
+
+ $scope.loadBucketFromMenu = function(item, bucket) {
+ if (bucket) return $scope.loadBucket(bucket.id());
+ }
+
+ $scope.loadBucket = function(id) {
+ $location.path(
+ '/cat/bucket/record/' +
+ $scope.tab + '/' + encodeURIComponent(id));
+ }
+
+ $scope.addToBucket = function(recs) {
+ if (recs.length == 0) return;
+ bucketSvc.bucketNeedsRefresh = true;
+
+ angular.forEach(recs,
+ function(rec) {
+ var item = new egCore.idl.cbrebi();
+ item.bucket(bucketSvc.currentBucket.id());
+ item.target_biblio_record_entry(rec.id);
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.container.item.create',
+ egCore.auth.token(), 'biblio', item
+ ).then(function(resp) {
+
+ // HACK: add the IDs of the added items so that the size
+ // of the view list will grow (and update any UI looking at
+ // the list size). The data stored is inconsistent, but since
+ // we are forcing a bucket refresh on the next rendering of
+ // the view pane, the list will be repaired.
+ bucketSvc.currentBucket.items().push(resp);
+ });
+ }
+ );
+ }
+
+ $scope.openCreateBucketDialog = function() {
+ $modal.open({
+ templateUrl: './cat/bucket/record/t_bucket_create',
+ controller:
+ ['$scope', '$modalInstance', function($scope, $modalInstance) {
+ $scope.focusMe = true;
+ $scope.ok = function(args) { $modalInstance.close(args) }
+ $scope.cancel = function () { $modalInstance.dismiss() }
+ }]
+ }).result.then(function (args) {
+ if (!args || !args.name) return;
+ bucketSvc.createBucket(args.name, args.desc).then(
+ function(id) {
+ if (!id) return;
+ bucketSvc.viewList = [];
+ bucketSvc.allBuckets = []; // reset
+ bucketSvc.currentBucket = null;
+ $location.path(
+ '/cat/bucket/record/' + $scope.tab + '/' + id);
+ }
+ );
+ });
+ }
+
+ $scope.openEditBucketDialog = function() {
+ $modal.open({
+ templateUrl: './cat/bucket/record/t_bucket_edit',
+ controller:
+ ['$scope', '$modalInstance', function($scope, $modalInstance) {
+ $scope.focusMe = true;
+ $scope.args = {
+ name : bucketSvc.currentBucket.name(),
+ desc : bucketSvc.currentBucket.description(),
+ pub : bucketSvc.currentBucket.pub() == 't'
+ };
+ $scope.ok = function(args) {
+ if (!args) return;
+ $scope.actionPending = true;
+ args.pub = args.pub ? 't' : 'f';
+ // close the dialog after edit has completed
+ bucketSvc.editBucket(args).then(
+ function() { $modalInstance.close() });
+ }
+ $scope.cancel = function () { $modalInstance.dismiss() }
+ }]
+ })
+ }
+
+
+ // opens the delete confirmation and deletes the current
+ // bucket if the user confirms.
+ $scope.openDeleteBucketDialog = function() {
+ $modal.open({
+ templateUrl: './cat/bucket/record/t_bucket_delete',
+ controller :
+ ['$scope', '$modalInstance', function($scope, $modalInstance) {
+ $scope.bucket = function() { return bucketSvc.currentBucket }
+ $scope.ok = function() { $modalInstance.close() }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }]
+ }).result.then(function () {
+ bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
+ .then(function() {
+ bucketSvc.allBuckets = [];
+ $location.path('/cat/bucket/record/view');
+ });
+ });
+ }
+
+ // retrieves the requested bucket by ID
+ $scope.openSharedBucketDialog = function() {
+ $modal.open({
+ templateUrl: './cat/bucket/record/t_load_shared',
+ controller :
+ ['$scope', '$modalInstance', function($scope, $modalInstance) {
+ $scope.focusMe = true;
+ $scope.ok = function(args) {
+ if (args && args.id) {
+ $modalInstance.close(args.id)
+ }
+ }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }]
+ }).result.then(function(id) {
+ // RecordBucketCtrl $scope is not inherited by the
+ // modal, so we need to call loadBucket from the
+ // promise resolver.
+ $scope.loadBucket(id);
+ });
+ }
+
+ // opens the record export dialog
+ $scope.openExportBucketDialog = function() {
+ $modal.open({
+ templateUrl: './cat/bucket/record/t_bucket_export',
+ controller :
+ ['$scope', '$modalInstance', function($scope, $modalInstance) {
+ $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
+ $scope.ok = function(args) { $modalInstance.close(args) }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }]
+ }).result.then(function (args) {
+ if (!args) return;
+ args.containerid = bucketSvc.currentBucket.id();
+
+ var url = '/exporter?containerid=' + args.containerid +
+ '&format=' + args.format + '&encoding=' + args.encoding;
+
+ if (args.holdings) url += '&holdings=1';
+
+ // TODO: improve auth cookie handling so this isn't necessary.
+ // today the cookie path is too specific (/eg/staff) for non-staff
+ // UIs to access it. See services/auth.js
+ url += '&ses=' + egCore.auth.token();
+
+ $timeout(function() { $window.open(url) });
+ });
+ }
+}])
+
+.controller('SearchCtrl',
+ ['$scope','$routeParams','egCore','bucketSvc',
+function($scope, $routeParams, egCore , bucketSvc) {
+
+ $scope.setTab('search');
+ $scope.focusMe = true;
+ var idQueryHash = {};
+
+ function generateQuery() {
+ if (bucketSvc.queryRecords.length)
+ return {id : bucketSvc.queryRecords};
+ else
+ return null;
+ }
+
+ $scope.gridControls = {
+ setQuery : function() {return generateQuery()},
+ setSort : function() {return ['id']}
+ }
+
+ // add selected items directly to the pending list
+ $scope.addToPending = function(recs) {
+ angular.forEach(recs, function(rec) {
+ if (bucketSvc.pendingList.filter( // remove dupes
+ function(r) {return r.id == rec.id}).length) return;
+ bucketSvc.pendingList.push(rec);
+ });
+ }
+
+ $scope.search = function() {
+ $scope.searchList = [];
+ $scope.searchInProgress = true;
+ bucketSvc.queryRecords = [];
+
+ egCore.net.request(
+ 'open-ils.search',
+ 'open-ils.search.biblio.multiclass.query', {
+ limit : 500 // meh
+ }, bucketSvc.queryString, true
+ ).then(function(resp) {
+ bucketSvc.queryRecords =
+ resp.ids.map(function(id){return id[0]});
+ $scope.gridControls.setQuery(generateQuery());
+ })['finally'](function() {
+ $scope.searchInProgress = false;
+ });
+ }
+
+ if ($routeParams.id &&
+ (!bucketSvc.currentBucket ||
+ bucketSvc.currentBucket.id() != $routeParams.id)) {
+ // user has accessed this page cold with a bucket ID.
+ // fetch the bucket for display, then set the totalCount
+ // (also for display), but avoid fully fetching the bucket,
+ // since it's premature, in this UI.
+ bucketSvc.fetchBucket($routeParams.id);
+ }
+}])
+
+.controller('PendingCtrl',
+ ['$scope','$routeParams','bucketSvc','egGridDataProvider',
+function($scope, $routeParams, bucketSvc , egGridDataProvider) {
+ $scope.setTab('pending');
+
+ var provider = egGridDataProvider.instance({});
+ provider.get = function(offset, count) {
+ return provider.arrayNotifier(
+ bucketSvc.pendingList, offset, count);
+ }
+ $scope.gridDataProvider = provider;
+
+ $scope.resetPendingList = function() {
+ bucketSvc.pendingList = [];
+ }
+
+
+ if ($routeParams.id &&
+ (!bucketSvc.currentBucket ||
+ bucketSvc.currentBucket.id() != $routeParams.id)) {
+ // user has accessed this page cold with a bucket ID.
+ // fetch the bucket for display, then set the totalCount
+ // (also for display), but avoid fully fetching the bucket,
+ // since it's premature, in this UI.
+ bucketSvc.fetchBucket($routeParams.id);
+ }
+}])
+
+.controller('ViewCtrl',
+ ['$scope','$q','$routeParams','bucketSvc',
+function($scope, $q , $routeParams, bucketSvc) {
+
+ $scope.setTab('view');
+ $scope.bucketId = $routeParams.id;
+
+ var query;
+ $scope.gridControls = {
+ setQuery : function(q) {
+ if (q) query = q;
+ return query;
+ }
+ };
+
+ function drawBucket() {
+ return bucketSvc.fetchBucket($scope.bucketId).then(
+ function(bucket) {
+ var ids = bucket.items().map(
+ function(i){return i.target_biblio_record_entry()}
+ );
+ if (ids.length) {
+ $scope.gridControls.setQuery({id : ids});
+ } else {
+ $scope.gridControls.setQuery({});
+ }
+ }
+ );
+ }
+
+ $scope.detachRecords = function(records) {
+ var promises = [];
+ angular.forEach(records, function(rec) {
+ var item = bucketSvc.currentBucket.items().filter(
+ function(i) {
+ return (i.target_biblio_record_entry() == rec.id)
+ }
+ );
+ if (item.length)
+ promises.push(bucketSvc.detachRecord(item[0].id()));
+ });
+
+ bucketSvc.bucketNeedsRefresh = true;
+ return $q.all(promises).then(drawBucket);
+ }
+
+ // fetch the bucket; on error show the not-allowed message
+ if ($scope.bucketId)
+ drawBucket()['catch'](function() { $scope.forbidden = true });
+}])
--- /dev/null
+/**
+ * TPAC Frame App
+ *
+ * currently, this app doesn't use routes for each sub-ui, because
+ * reloading the catalog each time is sloooow. better so far to
+ * swap out divs w/ ng-if / ng-show / ng-hide as needed.
+ *
+ */
+
+angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay :
+ ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+ $routeProvider.when('/cat/catalog/index', {
+ templateUrl: './cat/catalog/t_catalog',
+ controller: 'CatalogCtrl',
+ resolve : resolver
+ });
+
+ // create some catalog page-specific mappings
+ $routeProvider.when('/cat/catalog/record/:record_id', {
+ templateUrl: './cat/catalog/t_catalog',
+ controller: 'CatalogCtrl',
+ resolve : resolver
+ });
+
+ // create some catalog page-specific mappings
+ $routeProvider.when('/cat/catalog/record/:record_id/:record_tab', {
+ templateUrl: './cat/catalog/t_catalog',
+ controller: 'CatalogCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/cat/catalog/index'});
+})
+
+
+/**
+ * */
+.controller('CatalogCtrl',
+ ['$scope','$routeParams','$location','$q','egCore','egHolds',
+ 'egGridDataProvider','egHoldGridActions',
+function($scope , $routeParams , $location , $q , egCore , egHolds,
+ egGridDataProvider , egHoldGridActions) {
+
+ // set record ID on page load if available...
+ $scope.record_id = $routeParams.record_id;
+
+ // also set it when the iframe changes to a new record
+ $scope.handle_page = function(url) {
+
+ if (!url || url == 'about:blank') {
+ // nothing loaded. If we already have a record ID, leave it.
+ return;
+ }
+
+ var match = url.match(/\/+opac\/+record\/+(\d+)/);
+ if (match) {
+ $scope.record_id = match[1];
+
+ // force the record_id to show up in the page.
+ // not sure why a $digest isn't occuring here.
+ try { $scope.$apply() } catch(E) {}
+ } else {
+ delete $scope.record_id;
+ }
+ }
+
+ // xulG catalog handlers
+ $scope.handlers = { }
+
+ // ------------------------------------------------------------------
+ // Holds
+ var provider = egGridDataProvider.instance({});
+ $scope.hold_grid_data_provider = provider;
+ $scope.grid_actions = egHoldGridActions;
+ $scope.hold_grid_controls = {};
+
+ var hold_ids = []; // current list of holds
+ function fetchHolds(offset, count) {
+ var ids = hold_ids.slice(offset, offset + count);
+ return egHolds.fetch_holds(ids).then(null, null,
+ function(hold_data) {
+ return hold_data;
+ }
+ );
+ }
+
+ provider.get = function(offset, count) {
+ if ($scope.record_tab != 'holds') return $q.when();
+ var deferred = $q.defer();
+ hold_ids = []; // no caching ATM
+
+ // fetch the IDs
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.holds.retrieve_all_from_title',
+ egCore.auth.token(), $scope.record_id,
+ {pickup_lib : $scope.pickup_ou.id()}
+ ).then(
+ function(hold_data) {
+ angular.forEach(hold_data, function(list, type) {
+ hold_ids = hold_ids.concat(list);
+ });
+ fetchHolds(offset, count).then(
+ deferred.resolve, null, deferred.notify);
+ }
+ );
+
+ return deferred.promise;
+ }
+
+ $scope.detail_view = function(action, user_data, items) {
+ if (h = items[0]) {
+ $scope.detail_hold_id = h.hold.id();
+ }
+ }
+
+ $scope.list_view = function(items) {
+ $scope.detail_hold_id = null;
+ }
+
+ // refresh the list of record holds when the pickup lib is changed.
+ $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+ $scope.pickup_ou_changed = function(org) {
+ $scope.pickup_ou = org;
+ provider.refresh();
+ }
+
+ $scope.print_holds = function() {
+ var holds = [];
+ angular.forEach($scope.hold_grid_controls.allItems(), function(item) {
+ holds.push({
+ hold : egCore.idl.toHash(item.hold),
+ patron_last : item.patron_last,
+ patron_alias : item.patron_alias,
+ patron_barcode : item.patron_barcode,
+ copy : egCore.idl.toHash(item.copy),
+ volume : egCore.idl.toHash(item.volume),
+ title : item.mvr.title(),
+ author : item.mvr.author()
+ });
+ });
+
+ egCore.print.print({
+ context : 'receipt',
+ template : 'holds_for_bib',
+ scope : {holds : holds}
+ });
+ }
+
+ $scope.mark_hold_transfer_dest = function() {
+ egCore.hatch.setLocalItem(
+ 'eg.circ.hold.title_transfer_target', $scope.record_id);
+ }
+
+ // UI presents this option as "all holds"
+ $scope.transfer_holds_to_marked = function() {
+ var hold_ids = $scope.hold_grid_controls.allItems().map(
+ function(hold_data) {return hold_data.hold.id()});
+ egHolds.transfer_to_marked_title(hold_ids);
+ }
+
+ // ------------------------------------------------------------------
+ // Initialize the selected tab
+
+ function init_cat_url() {
+ // Set the initial catalog URL. This only happens once.
+ // The URL is otherwise generated through user navigation.
+ if ($scope.catalog_url) return;
+
+ var url = $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+ // A record ID in the path indicates a request for the record-
+ // specific page.
+ if ($routeParams.record_id) {
+ url = url.replace(/advanced/, '/record/' + $scope.record_id);
+ }
+
+ $scope.catalog_url = url;
+ }
+
+ $scope.set_record_tab = function(tab) {
+ $scope.record_tab = tab;
+
+ switch(tab) {
+
+ case 'catalog':
+ init_cat_url();
+ break;
+
+ case 'holds':
+ $scope.detail_hold_record_id = $scope.record_id;
+ // refresh the holds grid
+ provider.refresh();
+ break;
+ }
+ }
+
+ var tab = $routeParams.record_tab || 'catalog';
+ $scope.set_record_tab(tab);
+
+}])
+
--- /dev/null
+/**
+ * Item Display
+ */
+
+angular.module('egItemStatus',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ // search page shows the list view by default
+ $routeProvider.when('/cat/item/search', {
+ templateUrl: './cat/item/t_list',
+ controller: 'ListCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/item/:id', {
+ templateUrl: './cat/item/t_view',
+ controller: 'ViewCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/cat/item/:id/:tab', {
+ templateUrl: './cat/item/t_view',
+ controller: 'ViewCtrl',
+ resolve : resolver
+ });
+
+ // default page / bucket view
+ $routeProvider.otherwise({redirectTo : '/cat/item/search'});
+})
+
+.factory('itemSvc',
+ ['egCore',
+function(egCore) {
+
+ var service = {
+ copies : [], // copy barcode search results
+ index : 0 // search grid index
+ };
+
+ service.flesh = {
+ flesh : 3,
+ flesh_fields : {
+ acp : ['call_number','location','status','location'],
+ acn : ['record','prefix','suffix'],
+ bre : ['simple_record','creator','editor']
+ },
+ select : {
+ // avoid fleshing MARC on the bre
+ // note: don't add simple_record.. not sure why
+ bre : ['id','tcn_value','creator','editor'],
+ }
+ }
+
+ // resolved with the last received copy
+ service.fetch = function(barcode, id, noListDupes) {
+ var promise;
+
+ if (barcode) {
+ promise = egCore.pcrud.search('acp',
+ {barcode : barcode, deleted : 'f'}, service.flesh);
+ } else {
+ promise = egCore.pcrud.retrieve('acp', id, service.flesh);
+ }
+
+ var lastRes;
+ return promise.then(
+ function() {return lastRes},
+ null, // error
+
+ // notify reads the stream of copies, one at a time.
+ function(copy) {
+
+ var flatCopy;
+ if (noListDupes) {
+ // use the existing copy if possible
+ flatCopy = service.copies.filter(
+ function(c) {return c.id == copy.id()})[0];
+ }
+
+ if (!flatCopy) {
+ flatCopy = egCore.idl.toHash(copy, true);
+ flatCopy.index = service.index++;
+ service.copies.unshift(flatCopy);
+ }
+
+ return lastRes = {
+ copy : copy,
+ index : flatCopy.index
+ }
+ }
+ );
+ }
+
+ return service;
+}])
+
+/**
+ * Search bar along the top of the page.
+ * Parent scope for list and detail views
+ */
+.controller('SearchCtrl',
+ ['$scope','$location','egCore','egGridDataProvider','itemSvc',
+function($scope , $location , egCore , egGridDataProvider , itemSvc) {
+ $scope.args = {}; // search args
+
+ // sub-scopes (search / detail-view) apply their version
+ // of retrieval function to $scope.context.search
+ // and display toggling via $scope.context.toggleDisplay
+ $scope.context = {
+ selectBarcode : true
+ };
+
+ $scope.toggleView = function($event) {
+ $scope.context.toggleDisplay();
+ $event.preventDefault(); // avoid form submission
+ }
+}])
+
+/**
+ * List view - grid stuff
+ */
+.controller('ListCtrl',
+ ['$scope','$q','$location','$timeout','egCore','egGridDataProvider','itemSvc',
+function($scope , $q , $location , $timeout , egCore , egGridDataProvider , itemSvc) {
+ $scope.context.page = 'list';
+
+ /*
+ var provider = egGridDataProvider.instance();
+ provider.get = function(offset, count) {
+ }
+ */
+
+ $scope.gridDataProvider = egGridDataProvider.instance({
+ get : function(offset, count) {
+ //return provider.arrayNotifier(itemSvc.copies, offset, count);
+ return this.arrayNotifier(itemSvc.copies, offset, count);
+ }
+ });
+
+ // If a copy was just displayed in the detail view, ensure it's
+ // focused in the list view.
+ var selected = false;
+ var copyGrid = $scope.gridControls = {
+ itemRetrieved : function(item) {
+ if (selected || !itemSvc.copy) return;
+ if (itemSvc.copy.id() == item.id) {
+ copyGrid.selectItems([item.index]);
+ selected = true;
+ }
+ }
+ };
+
+ $scope.$watch('barcodesFromFile', function(newVal, oldVal) {
+ if (newVal && newVal != oldVal) {
+ $scope.args.barcode = '';
+ var barcodes = [];
+
+ angular.forEach(newVal.split(/\n/), function(line) {
+ if (!line) return;
+ // scrub any trailing spaces or commas from the barcode
+ line = line.replace(/(.*?)($|\s.*|,.*)/,'$1');
+ barcodes.push(line);
+ });
+
+ itemSvc.fetch(barcodes).then(
+ function() {
+ copyGrid.refresh();
+ copyGrid.selectItems([itemSvc.copies[0].index]);
+ }
+ );
+ }
+ });
+
+ $scope.context.search = function(args) {
+ if (!args.barcode) return;
+ $scope.context.itemNotFound = false;
+ itemSvc.fetch(args.barcode).then(function(res) {
+ if (res) {
+ copyGrid.refresh();
+ copyGrid.selectItems([res.index]);
+ $scope.args.barcode = '';
+ } else {
+ $scope.context.itemNotFound = true;
+ }
+ $scope.context.selectBarcode = true;
+ })
+ }
+
+ $scope.context.toggleDisplay = function() {
+ var item = copyGrid.selectedItems()[0];
+ if (item)
+ $location.path('/cat/item/' + item.id);
+ }
+
+ $scope.context.show_triggered_events = function() {
+ var item = copyGrid.selectedItems()[0];
+ if (item)
+ $location.path('/cat/item/' + item.id + '/triggered_events');
+ }
+
+}])
+
+/**
+ * Detail view -- shows one copy
+ */
+.controller('ViewCtrl',
+ ['$scope','$q','$location','$routeParams','egCore','itemSvc','egBilling',
+function($scope , $q , $location , $routeParams , egCore , itemSvc , egBilling) {
+ var copyId = $routeParams.id;
+ $scope.tab = $routeParams.tab || 'summary';
+ $scope.context.page = 'detail';
+
+ // use the cached record info
+ if (itemSvc.copy)
+ $scope.summaryRecord = itemSvc.copy.call_number().record();
+
+ function loadCopy(barcode) {
+ $scope.context.itemNotFound = false;
+
+ // Avoid re-fetching the same copy while jumping tabs.
+ // In addition to being quicker, this helps to avoid flickering
+ // of the top panel which is always visible in the detail view.
+ //
+ // 'barcode' represents the loading of a new item - refetch it
+ // regardless of whether it matches the current item.
+ if (!barcode && itemSvc.copy && itemSvc.copy.id() == copyId) {
+ $scope.copy = itemSvc.copy;
+ $scope.recordId = itemSvc.copy.call_number().record().id();
+ return $q.when();
+ }
+
+ delete $scope.copy;
+ delete itemSvc.copy;
+
+ var deferred = $q.defer();
+ itemSvc.fetch(barcode, copyId, true).then(function(res) {
+ $scope.context.selectBarcode = true;
+
+ if (!res) {
+ copyId = null;
+ $scope.context.itemNotFound = true;
+ deferred.reject(); // avoid propagation of data fetch calls
+ return;
+ }
+
+ var copy = res.copy;
+ itemSvc.copy = copy;
+
+
+ $scope.copy = copy;
+ $scope.recordId = copy.call_number().record().id();
+ $scope.summaryRecord = itemSvc.copy.call_number().record();
+ $scope.args.barcode = '';
+
+ // locally flesh org units
+ copy.circ_lib(egCore.org.get(copy.circ_lib()));
+ copy.call_number().owning_lib(
+ egCore.org.get(copy.call_number().owning_lib()));
+
+ var r = copy.call_number().record();
+ if (r.owner()) r.owner(egCore.org.get(r.owner()));
+
+ // make boolean for auto-magic true/false display
+ angular.forEach(
+ ['ref','opac_visible','holdable','floating','circulate'],
+ function(field) { copy[field](Boolean(copy[field]() == 't')) }
+ );
+
+ // finally, if this is a different copy, redirect.
+ // Note that we flesh first since the copy we just
+ // fetched will be used after the redirect.
+ if (copyId && copyId != copy.id()) {
+ // if a new barcode is scanned in the detail view,
+ // update the url to match the ID of the new copy
+ $location.path('/cat/item/' + copy.id() + '/' + $scope.tab);
+ deferred.reject(); // avoid propagation of data fetch calls
+ return;
+ }
+ copyId = copy.id();
+
+ deferred.resolve();
+ });
+
+ return deferred.promise;
+ }
+
+ // if loadPrev load the two most recent circulations
+ function loadCurrentCirc(loadPrev) {
+ delete $scope.circ;
+ delete $scope.circ_summary;
+ delete $scope.prev_circ_summary;
+ if (!copyId) return;
+
+ egCore.pcrud.search('circ',
+ {target_copy : copyId},
+ { flesh : 2,
+ flesh_fields : {
+ circ : [
+ 'usr',
+ 'workstation',
+ 'checkin_workstation',
+ 'duration_rule',
+ 'max_fine_rule',
+ 'recurring_fine_rule'
+ ],
+ au : ['card']
+ },
+ order_by : {circ : 'xact_start desc'},
+ limit : 1
+ }
+
+ ).then(null, null, function(circ) {
+ $scope.circ = circ;
+
+ // load the chain for this circ
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.renewal_chain.retrieve_by_circ.summary',
+ egCore.auth.token(), $scope.circ.id()
+ ).then(function(summary) {
+ $scope.circ_summary = summary.summary;
+ });
+
+ if (!loadPrev) return;
+
+ // load the chain for the previous circ, plus the user
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.prev_renewal_chain.retrieve_by_circ.summary',
+ egCore.auth.token(), $scope.circ.id()
+
+ ).then(null, null, function(summary) {
+ $scope.prev_circ_summary = summary.summary;
+
+ egCore.pcrud.retrieve('au', summary.usr,
+ {flesh : 1, flesh_fields : {au : ['card']}})
+
+ .then(function(user) {
+ $scope.prev_circ_usr = user;
+ });
+ });
+ });
+ }
+
+ var maxHistory;
+ function fetchMaxCircHistory() {
+ if (maxHistory) return $q.when(maxHistory);
+ return egCore.org.settings(
+ 'circ.item_checkout_history.max')
+ .then(function(set) {
+ maxHistory = set['circ.item_checkout_history.max'] || 4;
+ return maxHistory;
+ });
+ }
+
+ $scope.addBilling = function(circ) {
+ egBilling.showBillDialog({
+ xact_id : circ.id(),
+ patron : circ.usr()
+ });
+ }
+
+ function loadCircHistory() {
+ $scope.circ_list = [];
+
+ var copy_org =
+ itemSvc.copy.call_number().id() == -1 ?
+ itemSvc.copy.circ_lib().id() :
+ itemSvc.copy.call_number().owning_lib().id()
+
+ // there is an extra layer of permissibility over circ
+ // history views
+ egCore.perm.hasPermAt('VIEW_COPY_CHECKOUT_HISTORY', true)
+ .then(function(orgIds) {
+
+ if (orgIds.indexOf(copy_org) == -1) {
+ console.log('User is not allowed to view circ history');
+ return $q.when(0);
+ }
+
+ return fetchMaxCircHistory();
+
+ }).then(function(count) {
+
+ egCore.pcrud.search('circ',
+ {target_copy : copyId},
+ { flesh : 2,
+ flesh_fields : {
+ circ : [
+ 'usr',
+ 'workstation',
+ 'checkin_workstation',
+ 'recurring_fine_rule'
+ ],
+ au : ['card']
+ },
+ order_by : {circ : 'xact_start desc'},
+ limit : count
+ }
+
+ ).then(null, null, function(circ) {
+
+ // flesh circ_lib locally
+ circ.circ_lib(egCore.org.get(circ.circ_lib()));
+ circ.checkin_lib(egCore.org.get(circ.checkin_lib()));
+ $scope.circ_list.push(circ);
+ });
+ });
+ }
+
+
+ function loadCircCounts() {
+
+ delete $scope.circ_counts;
+ $scope.total_circs = 0;
+ $scope.total_circs_this_year = 0;
+ $scope.total_circs_prev_year = 0;
+ if (!copyId) return;
+
+ egCore.pcrud.search('circbyyr',
+ {copy : copyId}, null, {atomic : true})
+
+ .then(function(counts) {
+ $scope.circ_counts = counts;
+
+ angular.forEach(counts, function(count) {
+ $scope.total_circs += Number(count.count());
+ });
+
+ var this_year = counts.filter(function(c) {
+ return c.year() == new Date().getFullYear();
+ });
+
+ $scope.total_circs_this_year =
+ this_year.length ? this_year[0].count() : 0;
+
+ var prev_year = counts.filter(function(c) {
+ return c.year() == new Date().getFullYear() - 1;
+ });
+
+ $scope.total_circs_prev_year =
+ prev_year.length ? prev_year[0].count() : 0;
+
+ });
+ }
+
+ function loadHolds() {
+ delete $scope.hold;
+ if (!copyId) return;
+
+ egCore.pcrud.search('ahr',
+ { current_copy : copyId,
+ cancel_time : null,
+ fulfillment_time : null,
+ capture_time : {'<>' : null}
+ }, {
+ flesh : 2,
+ flesh_fields : {
+ ahr : ['requestor', 'usr'],
+ au : ['card']
+ }
+ }
+ ).then(null, null, function(hold) {
+ $scope.hold = hold;
+ hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+ if (hold.current_shelf_lib()) {
+ hold.current_shelf_lib(
+ egCore.org.get(hold.current_shelf_lib()));
+ }
+ hold.behind_desk(Boolean(hold.behind_desk() == 't'));
+ });
+ }
+
+ function loadTransits() {
+ delete $scope.transit;
+ delete $scope.hold_transit;
+ if (!copyId) return;
+
+ egCore.pcrud.search('atc',
+ {target_copy : copyId},
+ {order_by : {atc : 'source_send_time DESC'}}
+
+ ).then(null, null, function(transit) {
+ $scope.transit = transit;
+ transit.source(egCore.org.get(transit.source()));
+ transit.dest(egCore.org.get(transit.dest()));
+ })
+ }
+
+
+ // we don't need all data on all tabs, so fetch what's needed when needed.
+ function loadTabData() {
+ switch($scope.tab) {
+ case 'summary':
+ loadCurrentCirc();
+ loadCircCounts();
+ break;
+
+ case 'circs':
+ loadCurrentCirc(true);
+ break;
+
+ case 'circ_list':
+ loadCircHistory();
+ break;
+
+ case 'holds':
+ loadHolds()
+ loadTransits();
+ break;
+
+ case 'triggered_events':
+ var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+ url += '?copy_id=' + encodeURIComponent(copyId);
+ $scope.triggered_events_url = url;
+ $scope.funcs = {};
+ }
+ }
+
+ $scope.context.toggleDisplay = function() {
+ $location.path('/cat/item/search');
+ }
+
+ // handle the barcode scan box, which will replace our current copy
+ $scope.context.search = function(args) {
+ loadCopy(args.barcode).then(loadTabData);
+ }
+
+ $scope.context.show_triggered_events = function() {
+ $location.path('/cat/item/' + copyId + '/triggered_events');
+ }
+
+ loadCopy().then(loadTabData);
+}])
--- /dev/null
+angular.module('egItemMissingPieces',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('MissingPiecesCtrl',
+ ['$scope','$q','$window','$location','egCore','egConfirmDialog','egAlertDialog','egCirc',
+function($scope , $q , $window , $location , egCore , egConfirmDialog , egAlertDialog , egCirc) {
+
+ $scope.selectMe = true; // focus text input
+ $scope.args = {};
+
+ function get_copy(barcode) {
+
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ egCore.auth.token(), egCore.auth.user().ws_ou(),
+ 'asset', barcode)
+
+ .then(function(resp) { // get_barcodes
+
+ if (evt = egCore.evt.parse(resp)) {
+ console.error(evt.toString());
+ return $q.reject();
+ }
+
+ if (!resp || !resp[0]) {
+ $scope.bcNotFound = barcode;
+ $scope.selectMe = true;
+ return $q.reject();
+ }
+
+ return egCore.pcrud.search('acp', {id : resp[0].id}, {
+ flesh : 3,
+ flesh_fields : {
+ acp : ['call_number'],
+ acn : ['record'],
+ bre : ['simple_record']
+ },
+ select : {
+ // avoid fleshing MARC on the bre
+ // note: don't add simple_record.. not sure why
+ bre : ['id']
+ }
+ })
+ })
+ }
+
+ function mark_missing_pieces(copy) {
+
+ egConfirmDialog.open(
+ egCore.strings.CONFIRM_MARK_MISSING_TITLE,
+ egCore.strings.CONFIRM_MARK_MISSING_BODY, {
+ barcode : copy.barcode(),
+ title : copy.call_number().record().simple_record().title()
+
+ }).result.then(function() {
+
+ // kick off mark missing
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.mark_item_missing_pieces',
+ egCore.auth.token(), copy.id()
+ )
+
+ }).then(function(resp) {
+ var evt = egCore.evt.parse(resp); // should always produce event
+
+ if (evt.textcode == 'ACTION_CIRCULATION_NOT_FOUND') {
+ return egAlertDialog.open(
+ egCore.strings.CIRC_NOT_FOUND, {barcode : copy.barcode()});
+ }
+
+ var payload = evt.payload;
+
+ // TODO: open copy editor inline? new tab?
+
+ // print the missing pieces slip
+ var promise = $q.when();
+ if (payload.slip) {
+ // wait for completion, since it may spawn a confirm dialog
+ promise = egCore.print.print({
+ context : 'default',
+ content_type : 'text/html',
+ content : payload.slip.template_output().data()
+ });
+ }
+
+ if (payload.letter) {
+ $scope.letter = payload.letter.template_output().data();
+ }
+
+ // apply patron penalty
+ if (payload.circ) {
+ promise.then(function() {
+ egCirc.create_penalty(payload.circ.usr())
+ });
+ }
+
+ });
+ }
+
+ $scope.print_letter = function() {
+ egCore.print.print({
+ context : 'mail',
+ content_type : 'text/plain',
+ content : $scope.letter
+ });
+ }
+
+ // find the item by barcode, then proceed w/ missing pieces
+ $scope.submitBarcode = function(args) {
+
+ $scope.bcNotFound = null;
+ if (!args.barcode) return;
+
+ $scope.selectMe = false;
+ $scope.letter = null;
+
+ get_copy(args.barcode).then(mark_missing_pieces);
+ }
+
+}])
+
--- /dev/null
+/**
+ * Item Display
+ */
+
+angular.module('egItemReplaceBarcode',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('ReplaceItemBarcodeCtrl',
+ ['$scope','egCore',
+function($scope , egCore) {
+ egCore.startup.go();
+
+ $scope.focusBarcode = true;
+
+ $scope.updateBarcode = function() {
+ $scope.copyNotFound = false;
+ $scope.updateOK = false;
+
+ egCore.pcrud.search('acp',
+ {deleted : 'f', barcode : $scope.barcode1})
+ .then(function(copy) {
+
+ if (!copy) {
+ $scope.focusBarcode = true;
+ $scope.copyNotFound = true;
+ return;
+ }
+
+ $scope.copyId = copy.id();
+ copy.barcode($scope.barcode2);
+
+ egCore.pcrud.update(copy).then(function(stat) {
+ $scope.updateOK = stat;
+ $scope.focusBarcode = true;
+ });
+ });
+ }
+}]);
+
--- /dev/null
+/**
+ * Simple directive for rending the HTML view of a bib record.
+ *
+ * <eg-record-html record-id="myRecordIdScopeVariable"></eg-record-id>
+ *
+ * The value of myRecordIdScopeVariable is watched internally and the
+ * record is updated to match.
+ */
+angular.module('egCoreMod')
+
+.directive('egRecordHtml', function() {
+ return {
+ restrict : 'AE',
+ scope : {recordId : '='},
+ link : function(scope, element, attrs) {
+ scope.element = angular.element(element);
+
+ // kill refs to destroyed DOM elements
+ element.bind("$destroy", function() {
+ delete scope.element;
+ });
+ },
+ controller :
+ ['$scope','egCore',
+ function($scope , egCore) {
+
+ function loadRecordHtml() {
+ egCore.net.request(
+ 'open-ils.search',
+ 'open-ils.search.biblio.record.html',
+ $scope.recordId
+ ).then(function(html) {
+ if (!html) return;
+
+ // Remove those pesky non-i8n labels / actions.
+ // Note: for printing, use the browser print page
+ // option. The end result is the same.
+ html = html.replace(
+ /<button onclick="window.print(.*?)<\/button>/,'');
+ html = html.replace(/<title>(.*?)<\/title>/,'');
+
+ // remove reference to nonexistant CSS file
+ html = html.replace(/<link(.*?)\/>/,'');
+
+ $scope.element.html(html);
+ });
+ }
+
+ $scope.$watch('recordId',
+ function(newVal, oldVal) {
+ if (newVal && newVal !== oldVal) {
+ loadRecordHtml();
+ }
+ }
+ );
+
+ if ($scope.recordId)
+ loadRecordHtml();
+ }
+ ]
+ }
+})
+
+/*
+ * A record='foo' attribute is required as a storage location of the
+ * retrieved record
+ */
+.directive('egRecordSummary', function() {
+ return {
+ restrict : 'AE',
+ scope : {
+ recordId : '=',
+ record : '='
+ },
+ templateUrl : './cat/share/t_record_summary',
+ controller :
+ ['$scope','egCore',
+ function($scope , egCore) {
+
+ function loadRecord() {
+ egCore.pcrud.retrieve('bre', $scope.recordId, {
+ flesh : 1,
+ flesh_fields : {
+ bre : ['simple_record','creator','editor']
+ }
+ }).then(function(rec) {
+ rec.owner(egCore.org.get(rec.owner()));
+ $scope.record = rec;
+ });
+ }
+
+ $scope.$watch('recordId',
+ function(newVal, oldVal) {
+ if (newVal && newVal !== oldVal) {
+ loadRecord();
+ }
+ }
+ );
+
+
+ if ($scope.recordId)
+ loadRecord();
+ }
+ ]
+ }
+})
--- /dev/null
+angular.module('egCheckinApp', ['ngRoute', 'ui.bootstrap',
+ 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay :
+ ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+ $routeProvider.when('/circ/checkin/checkin', {
+ templateUrl: './circ/checkin/t_checkin',
+ controller: 'CheckinCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/checkin/capture', {
+ templateUrl: './circ/checkin/t_checkin',
+ controller: 'CheckinCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/checkin/checkin'});
+})
+
+.factory('checkinSvc', [function() {
+ var service = {};
+ service.checkins = [];
+ return service;
+}])
+
+
+/**
+ * Manages checkin
+ */
+.controller('CheckinCtrl',
+ ['$scope','$q','$window','$location','egCore','checkinSvc','egGridDataProvider','egCirc',
+function($scope , $q , $window , $location , egCore , checkinSvc , egGridDataProvider , egCirc) {
+
+ $scope.focusMe = true;
+ $scope.checkins = checkinSvc.checkins;
+ var today = new Date();
+ $scope.checkinArgs = {backdate : today}
+ $scope.using_hatch = egCore.hatch.usingHatch();
+ $scope.modifiers = {};
+ $scope.fine_total = 0;
+ $scope.is_capture = $location.path().match(/capture$/);
+ var suppress_popups = false;
+ $scope.grid_persist_key = $scope.is_capture ?
+ 'circ.checkin.capture' : 'circ.checkin.checkin';
+
+ egCore.org.settings([
+ 'ui.circ.suppress_checkin_popups' // add other settings as needed
+ ]).then(function(set) {
+ suppress_popups = set['ui.circ.suppress_checkin_popups'];
+ });
+
+ // checkin & hold capture modifiers
+ var modifiers = [
+ 'void_overdues',
+ 'clear_expired',
+ 'hold_as_transit',
+ 'manual_float',
+ 'no_precat_alert',
+ 'retarget_holds',
+ 'retarget_holds_all'
+ ];
+
+ if ($scope.is_capture) {
+ // in hold capture mode, some values are forced, regardless
+ // of stored preferences.
+ $scope.modifiers.noop = false;
+ $scope.modifiers.auto_print_holds_transits = true;
+ } else {
+ modifiers.push('noop'); // AKA suppress holds and transits
+ modifiers.push('auto_print_holds_transits');
+ }
+
+ // set modifiers from stored preferences
+ angular.forEach(modifiers, function(mod) {
+ egCore.hatch.getItem('eg.circ.checkin.' + mod)
+ .then(function(val) { if (val) $scope.modifiers[mod] = true });
+ });
+
+ // set / unset a checkin modifier
+ // when set, store the preference
+ $scope.toggle_mod = function(mod) {
+ if ($scope.modifiers[mod]) {
+ $scope.modifiers[mod] = false;
+ egCore.hatch.removeItem('eg.circ.checkin.' + mod);
+ } else {
+ $scope.modifiers[mod] = true;
+ egCore.hatch.setItem('eg.circ.checkin.' + mod, true);
+ }
+ }
+
+
+ // ensure the backdate is not in the future
+ // note: input type=date max=foo not yet supported anywhere
+ $scope.$watch('checkinArgs.backdate', function(newval) {
+ if (newval && newval > today)
+ $scope.checkinArgs.backdate = today;
+ });
+
+ $scope.is_backdate = function() {
+ return $scope.checkinArgs.backdate < today;
+ }
+
+ var checkinGrid = $scope.gridControls = {};
+
+ $scope.gridDataProvider = egGridDataProvider.instance({
+ get : function(offset, count) {
+ return this.arrayNotifier($scope.checkins, offset, count);
+ }
+ });
+
+ // turns the various inputs (form args, modifiers, etc.) into
+ // checkin params and options.
+ function compile_checkin_args(args) {
+ var params = angular.copy(args);
+
+ if (params.backdate) {
+ params.backdate =
+ params.backdate.toISOString().replace(/T.*/,'');
+
+ // a backdate of 'today' is not really a backdate
+ if (params.backdate == $scope.max_backdate)
+ delete params.backdate;
+ }
+
+ angular.forEach(['noop','void_overdues',
+ 'clear_expired','hold_as_transit','manual_float'],
+ function(opt) {
+ if ($scope.modifiers[opt]) params[opt] = true;
+ }
+ );
+
+ if ($scope.modifiers.retarget_holds) {
+ if ($scope.modifiers.retarget_holds_all) {
+ params.retarget_mode = 'retarget.all';
+ } else {
+ params.retarget_mode = 'retarget';
+ }
+ }
+
+ var options = {
+ check_barcode : $scope.strict_barcode,
+ no_precat_alert : $scope.modifiers.no_precat_alert,
+ auto_print_holds_transits :
+ $scope.modifiers.auto_print_holds_transits,
+ suppress_popups : suppress_popups
+ };
+
+ return {params : params, options: options};
+ }
+
+ $scope.checkin = function(args) {
+
+ var compiled = compile_checkin_args(args);
+ args.copy_barcode = ''; // reset UI for next scan
+ $scope.focusMe = true;
+ delete $scope.alert;
+ delete $scope.billable_amount;
+ delete $scope.billable_barcode;
+
+ var params = compiled.params;
+ var options = compiled.options;
+
+ if (!params.copy_barcode) return;
+ delete $scope.alert;
+
+ var row_item = {
+ index : checkinSvc.checkins.length,
+ copy_barcode : params.copy_barcode
+ };
+
+ // track the item in the grid before sending the request
+ checkinSvc.checkins.unshift(row_item);
+ checkinGrid.refresh();
+
+ egCirc.checkin(params, options).then(
+ function(final_resp) {
+
+ row_item.evt = final_resp.evt;
+ angular.forEach(final_resp.data, function(val, key) {
+ row_item[key] = val;
+ });
+
+ if (row_item.mbts) {
+ var amt = Number(row_item.mbts.balance_owed());
+ if (amt != 0) {
+ $scope.billable_barcode = row_item.copy_barcode;
+ $scope.billable_amount = amt;
+ $scope.fine_total =
+ ($scope.fine_total * 100 + amt * 100) / 100;
+ }
+ }
+
+ if (final_resp.evt.textcode == 'NO_CHANGE') {
+ $scope.alert =
+ {already_checked_in : final_resp.evt.copy_barcode};
+ }
+
+ if ($scope.trim_list && checkinSvc.checkins.length > 20)
+ checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+ },
+ function() {
+ // Checkin was rejected somewhere along the way.
+ // Remove the copy from the grid since there was no action.
+ // note: since checkins are unshifted onto the array, the
+ // index value does not (generally) match the array position.
+ var pos = -1;
+ angular.forEach(checkinSvc.checkins, function(ci, idx) {
+ if (ci.index == row_item.index) pos = idx;
+ });
+ checkinSvc.checkin.splice(pos, 1);
+
+ })['finally'](function() {
+
+ // when all is said and done, refresh the grid and refocus
+ checkinGrid.refresh();
+ $scope.focusMe = true;
+ });
+ }
+
+ $scope.print_receipt = function() {
+ var print_data = {checkins : []}
+
+ if (checkinSvc.checkins.length == 0) return $q.when();
+
+ angular.forEach(checkinSvc.checkins, function(checkin) {
+
+ var checkin = {
+ copy : egCore.idl.toHash(checkin.acp) || {},
+ call_number : egCore.idl.toHash(checkin.acn) || {},
+ copy_barcode : checkin.copy_barcode,
+ title : checkin.title,
+ author : checkin.author
+ }
+
+ print_data.checkins.push(checkin);
+ });
+
+ return egCore.print.print({
+ template : 'checkin',
+ scope : print_data,
+ show_dialog : $scope.show_print_dialog
+ });
+ }
+
+
+ // --- context menu actions
+ //
+ $scope.fetchLastCircPatron = function(items) {
+ var checkin = items[0];
+ if (!checkin || !checkin.acp) return;
+
+ egCirc.last_copy_circ(checkin.acp.id())
+ .then(function(circ) {
+
+ if (circ) {
+ // jump to the patron UI (separate app)
+ $window.location.href = $location
+ .path('/circ/patron/' + circ.usr() + '/checkout')
+ .absUrl();
+ return;
+ }
+
+ $scope.alert = {item_never_circed : checkin.acp.barcode()};
+ });
+ }
+
+ $scope.showBackdateDialog = function(items) {
+ var circ_ids = [];
+
+ angular.forEach(items, function(item) {
+ if (item.circ) circ_ids.push(item.circ.id());
+ });
+
+ if (circ_ids.length) {
+ egCirc.backdate_dialog(circ_ids).then(function(result) {
+ angular.forEach(items, function(item) {
+ item.circ.checkin_time(result.backdate);
+ })
+ });
+ // TODO: support grid row styling
+ checkinGrid.refresh();
+ }
+ }
+
+ $scope.showMarkDamaged = function(items) {
+ var copy_ids = [];
+ angular.forEach(items, function(item) {
+ if (item.acp) copy_ids.push(item.acp.id());
+ });
+
+ if (copy_ids.length) {
+ egCirc.mark_damaged(copy_ids).then(function() {
+ // update grid items?
+ });
+ }
+ }
+
+ $scope.abortTransit = function(items) {
+ var transit_ids = [];
+ angular.forEach(items, function(item) {
+ if (item.transit) transit_ids.push(item.transit.id());
+ });
+
+ egCirc.abort_transits(transit_ids).then(function() {
+ // update grid items?
+ });
+ }
+
+}])
+
--- /dev/null
+angular.module('egHoldsApp',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay :
+ ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+ $routeProvider.when('/circ/holds/shelf', {
+ templateUrl: './circ/holds/t_shelf',
+ controller: 'HoldsShelfCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/holds/shelf/:hold_id', {
+ templateUrl: './circ/holds/t_shelf',
+ controller: 'HoldsShelfCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/holds/pull', {
+ templateUrl: './circ/holds/t_pull',
+ controller: 'HoldsPullListCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/holds/pull/:hold_id', {
+ templateUrl: './circ/holds/t_pull',
+ controller: 'HoldsPullListCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/holds/shelf'});
+})
+
+.factory('holdUiSvc', function() {
+ return {
+ holds : [] // cache
+ }
+})
+
+.controller('HoldsShelfCtrl',
+ ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egHoldGridActions','egCirc','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egHoldGridActions , egCirc , egGridDataProvider) {
+ $scope.detail_hold_id = $routeParams.hold_id;
+
+ var hold_ids = [];
+ var holds = [];
+ var clear_mode = false;
+ $scope.gridControls = {};
+ $scope.grid_actions = egHoldGridActions;
+
+ function fetch_holds(offset, count) {
+ var ids = hold_ids.slice(offset, offset + count);
+ return egHolds.fetch_holds(ids).then(null, null,
+ function(hold_data) {
+ holds.push(hold_data);
+ return hold_data; // to the grid
+ }
+ );
+ }
+
+ var provider = egGridDataProvider.instance({});
+ $scope.gridDataProvider = provider;
+
+ function refresh_page() {
+ holds = [];
+ hold_ids = [];
+ provider.refresh();
+ }
+ // called after any egHoldGridActions action occurs
+ $scope.grid_actions.refresh = refresh_page;
+
+ provider.get = function(offset, count) {
+
+ // see if we have the requested range cached
+ if (holds[offset]) {
+ return provider.arrayNotifier(patronSvc.holds, offset, count);
+ }
+
+ // see if we have the holds IDs for this range already loaded
+ if (hold_ids[offset]) {
+ return fetch_holds(offset, count);
+ }
+
+ var deferred = $q.defer();
+ hold_ids = [];
+ holds = [];
+
+ var method = 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve.authoritative.atomic';
+ if (clear_mode)
+ method = 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve.atomic';
+
+ egCore.net.request(
+ 'open-ils.circ', method,
+ egCore.auth.token(), $scope.pickup_ou.id()
+
+ ).then(function(ids) {
+ if (!ids.length) {
+ deferred.resolve();
+ return;
+ }
+
+ hold_ids = ids;
+ fetch_holds(offset, count)
+ .then(deferred.resolve, null, deferred.notify);
+ });
+
+ return deferred.promise;
+ }
+
+ // re-draw the grid when user changes the org selector
+ $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+ $scope.$watch('pickup_ou', function(newVal, oldVal) {
+ if (newVal && newVal != oldVal)
+ refresh_page();
+ });
+
+ $scope.detail_view = function(action, user_data, items) {
+ if (h = items[0]) {
+ $location.path('/circ/holds/shelf/' + h.hold.id());
+ }
+ }
+
+ $scope.list_view = function(items) {
+ $location.path('/circ/holds/shelf');
+ }
+
+ // when the detail hold is fetched (and updated), update the bib
+ // record summary display record id.
+ $scope.set_hold = function(hold_data) {
+ $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+ }
+
+ // manage active vs. clearable holds display
+ var clearing = false; // true if actively clearing holds (below)
+ $scope.is_clearing = function() { return clearing };
+ $scope.active_mode = function() {return !clear_mode}
+ $scope.clear_mode = function() {return clear_mode}
+ $scope.show_clearable = function() { clear_mode = true; refresh_page() }
+ $scope.show_active = function() { clear_mode = false; refresh_page() }
+ $scope.disable_clear = function() { return clearing || !clear_mode }
+
+ // udpate the in-grid hold with the clear-shelf cached response info.
+ function handle_clear_cache_resp(resp) {
+ if (!angular.isArray(resp)) resp = [resp];
+ angular.forEach(resp, function(info) {
+ if (info.action) {
+ var grid_item = holds.filter(function(item) {
+ return item.hold.id() == info.hold_details.id
+ })[0];
+
+ // there will be no grid item if the hold is off-page
+ if (grid_item) {
+ grid_item.post_clear =
+ egCore.strings['CLEAR_SHELF_ACTION_' + info.action];
+ }
+ }
+ });
+ }
+
+ $scope.clear_holds = function() {
+ clearing = true;
+ $scope.clear_progress = {max : 0, value : 0};
+
+ // we want to see all processed holds, so (effectively) remove
+ // the grid limit.
+ $scope.gridControls.setLimit(1000);
+
+ // initiate clear shelf and grab cache key
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.clear_shelf.process',
+ egCore.auth.token(), $scope.pickup_ou.id()
+
+ // request responses from the clear shelf cache
+ ).then(
+
+ // clear shelf done; fetch the cached results.
+ function(resp) {
+ clearing = false;
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.clear_shelf.get_cache',
+ egCore.auth.token(), resp.cache_key
+ ).then(null, null, handle_clear_cache_resp);
+ },
+
+ null,
+
+ // handle streamed clear_shelf progress updates
+ function(resp) {
+ if (resp.maximum)
+ $scope.clear_progress.max = resp.maximum;
+ if (resp.progress)
+ $scope.clear_progress.value = resp.progress;
+ }
+
+ );
+ }
+
+ refresh_page();
+
+}])
+
+.controller('HoldsPullListCtrl',
+ ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egCirc','egGridDataProvider','egHoldGridActions','holdUiSvc',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egCirc , egGridDataProvider , egHoldGridActions , holdUiSvc) {
+ $scope.detail_hold_id = $routeParams.hold_id;
+
+ var provider = egGridDataProvider.instance({});
+ $scope.gridDataProvider = provider;
+
+ $scope.grid_actions = egHoldGridActions;
+ $scope.grid_actions.refresh = function() {
+ holdUiSvc.holds = [];
+ provider.refresh();
+ }
+
+ provider.get = function(offset, count) {
+
+ if (holdUiSvc.holds[offset]) {
+ return provider.arrayNotifier(holdUiSvc.holds, offset, count);
+ }
+
+ var deferred = $q.defer();
+ var recv_index = 0;
+
+ // fetch the IDs
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold_pull_list.fleshed.stream',
+ egCore.auth.token(), count, offset
+ ).then(
+ deferred.resolve, null,
+ function(hold_data) {
+ egHolds.local_flesh(hold_data);
+ holdUiSvc.holds[offset + recv_index++] = hold_data;
+ deferred.notify(hold_data);
+ }
+ );
+
+ return deferred.promise;
+ }
+
+ $scope.detail_view = function(action, user_data, items) {
+ if (h = items[0]) {
+ $location.path('/circ/holds/pull/' + h.hold.id());
+ }
+ }
+
+ $scope.list_view = function(items) {
+ $location.path('/circ/holds/pull');
+ }
+
+ // when the detail hold is fetched (and updated), update the bib
+ // record summary display record id.
+ $scope.set_hold = function(hold_data) {
+ $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+ }
+
+ // By default, this action is hidded from the UI, but leaving it
+ // here in case it's needed in the future
+ $scope.print_list_alt = function() {
+ var url = '/opac/extras/circ/alt_holds_print.html';
+ var win = $window.open(url, '_blank');
+ win.ses = function() {return egCore.auth.token()};
+ win.open();
+ win.focus();
+ }
+
+ $scope.print_list_progress = null;
+ $scope.print_full_list = function() {
+ var print_holds = [];
+ $scope.print_list_loading = true;
+ $scope.print_list_progress = 0;
+
+ // collect the full list of holds
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold_pull_list.fleshed.stream',
+ egCore.auth.token(), 10000, 0
+ ).then(
+ function() {
+ console.debug('printing ' + print_holds.length + ' holds');
+
+ // holds fetched, send to print
+ egCore.print.print({
+ context : 'default',
+ template : 'hold_pull_list',
+ scope : {holds : print_holds}
+ });
+ },
+ null,
+ function(hold_data) {
+ $scope.print_list_progress++;
+ egHolds.local_flesh(hold_data);
+ print_holds.push(hold_data);
+ hold_data.title = hold_data.mvr.title();
+ hold_data.author = hold_data.mvr.author();
+ hold_data.hold = egCore.idl.toHash(hold_data.hold);
+ hold_data.copy = egCore.idl.toHash(hold_data.copy);
+ hold_data.volume = egCore.idl.toHash(hold_data.volume);
+ hold_data.part = egCore.idl.toHash(hold_data.part);
+ }
+ ).finally(function() {
+ $scope.print_list_loading = false;
+ $scope.print_list_progress = null;
+ });
+ }
+
+}])
+
--- /dev/null
+angular.module('egInHouseUseApp',
+ ['ngRoute', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+
+})
+
+.controller('InHouseUseCtrl',
+ ['$scope','egCore','egGridDataProvider','egConfirmDialog',
+function($scope, egCore, egGridDataProvider , egConfirmDialog) {
+
+ var countCap;
+ var countMax;
+
+ egCore.startup.go().then(function() {
+
+ // grab our non-cat types after startup
+ egCore.pcrud.search('cnct',
+ {owning_lib :
+ egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+ null, {atomic : true}
+ ).then(function(list) {
+ egCore.env.absorbList(list, 'cnct');
+ $scope.nonCatTypes = list
+ });
+
+ // org settings for max and warning in-house-use counts
+
+ egCore.org.settings([
+ 'ui.circ.in_house_use.entry_cap',
+ 'ui.circ.in_house_use.entry_warn'
+ ]).then(function(set) {
+ countWarn = set['ui.circ.in_house_use.entry_warn'] || 20;
+ $scope.countMax = countMax =
+ set['ui.circ.in_house_use.entry_cap'] || 99;
+ });
+ });
+
+ $scope.useFocus = true;
+ $scope.args = {noncat_type : 'barcode', num_uses : 1};
+ var checkouts = [];
+
+ var provider = egGridDataProvider.instance({});
+ provider.get = function(offset, count) {
+ return provider.arrayNotifier(checkouts, offset, count);
+ }
+ $scope.gridDataProvider = provider;
+
+ // currently selected non-cat type
+ $scope.selectedNcType = function() {
+ if (!egCore.env.cnct) return null; // too soon
+ var type = egCore.env.cnct.map[$scope.args.noncat_type];
+ return type ? type.name() : null;
+ }
+
+ $scope.checkout = function(args) {
+ $scope.copyNotFound = false;
+
+ var coArgs = {
+ count : args.num_uses,
+ 'location' : egCore.auth.user().ws_ou()
+ };
+
+ if (args.noncat_type == 'barcode') {
+
+ egCore.pcrud.search('acp',
+ {barcode : args.barcode, deleted : 'f'},
+ { flesh : 3,
+ flesh_fields : {
+ acp : ['call_number','location'],
+ acn : ['record'],
+ bre : ['simple_record']
+ },
+ select : { bre : ['id'] } // avoid fleshing MARC
+ }
+ ).then(function(copy) {
+
+ if (!copy) {
+ $scope.copyNotFound = true;
+ return;
+ }
+
+ coArgs.copyid = copy.id();
+
+ performCheckout(
+ 'open-ils.circ.in_house_use.create',
+ coArgs, {copy:copy}
+ );
+ });
+
+ } else {
+ coArgs.non_cat_type = args.noncat_type;
+ performCheckout(
+ 'open-ils.circ.non_cat_in_house_use.create',
+ coArgs, {title : $scope.selectedNcType()}
+ );
+ }
+ }
+
+ function performCheckout(method, args, data) {
+
+ // FIXME: make this API stream
+ egCore.net.request(
+ 'open-ils.circ', method, egCore.auth.token(), args
+
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) return alert(evt);
+
+ var item = {num_uses : resp.length};
+ item.copy = data.copy;
+ item.title = data.title ||
+ data.copy.call_number().record().simple_record().title();
+ item.index = checkouts.length;
+
+ checkouts.unshift(item);
+ provider.refresh();
+ });
+ }
+
+}])
--- /dev/null
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap',
+ 'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ // data loaded at startup which only requires an authtoken goes
+ // here. this allows the requests to be run in parallel instead of
+ // waiting until startup has completed.
+ var resolver = {delay : ['egCore','egUser', function(egCore , egUser) {
+
+ // fetch the org settings we care about during egStartup
+ // and toss them into egCore.env as egCore.env.aous[name] = value.
+ // note: only load settings here needed by all tabs; load tab-
+ // specific settings from within their respective controllers
+ egCore.env.classLoaders.aous = function() {
+ return egCore.org.settings([
+ 'circ.obscure_dob',
+ 'ui.circ.show_billing_tab_on_bills',
+ 'circ.patron_expires_soon_warning',
+ 'ui.circ.items_out.lost',
+ 'ui.circ.items_out.longoverdue',
+ 'ui.circ.items_out.claimsreturned'
+ ]).then(function(settings) {
+ // local settings are cached within egOrg. Caching them
+ // again in egEnv just simplifies the syntax for access.
+ egCore.env.aous = settings;
+ });
+ }
+
+ // local stat cats are displayed in the summary bar on each page.
+ egCore.env.classLoaders.actsc = function() {
+ return egCore.pcrud.search('actsc',
+ {owner : egCore.org.ancestors(
+ egCore.auth.user().ws_ou(), true)},
+ {}, {atomic : true}
+ ).then(function(cats) {
+ egCore.env.absorbList(cats, 'actsc');
+ });
+ }
+
+ egCore.env.loadClasses.push('aous');
+ egCore.env.loadClasses.push('actsc');
+
+ // app-globally modify the default flesh fields for
+ // fleshed user retrieval.
+ if (egUser.defaultFleshFields.indexOf('profile') == -1) {
+ egUser.defaultFleshFields = egUser.defaultFleshFields.concat([
+ 'profile',
+ 'net_access_level',
+ 'ident_type',
+ 'ident_type2',
+ 'cards'
+ ]);
+ }
+
+ return egCore.startup.go()
+ }]};
+
+ $routeProvider.when('/circ/patron/search', {
+ templateUrl: './circ/patron/t_search',
+ controller: 'PatronSearchCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/bcsearch', {
+ templateUrl: './circ/patron/t_bcsearch',
+ controller: 'PatronBarcodeSearchCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/credentials', {
+ templateUrl: './circ/patron/t_credentials',
+ controller: 'PatronVerifyCredentialsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/last', {
+ templateUrl: './circ/patron/t_last_patron',
+ controller: 'PatronFetchLastCtrl',
+ resolve : resolver
+ });
+
+ // the following require a patron ID
+
+ $routeProvider.when('/circ/patron/:id/alerts', {
+ templateUrl: './circ/patron/t_alerts',
+ controller: 'PatronAlertsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/checkout', {
+ templateUrl: './circ/patron/t_checkout',
+ controller: 'PatronCheckoutCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/items_out', {
+ templateUrl: './circ/patron/t_items_out',
+ controller: 'PatronItemsOutCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/holds', {
+ templateUrl: './circ/patron/t_holds',
+ controller: 'PatronHoldsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/holds/create', {
+ templateUrl: './circ/patron/t_holds_create',
+ controller: 'PatronHoldsCreateCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/holds/:hold_id', {
+ templateUrl: './circ/patron/t_holds',
+ controller: 'PatronHoldsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/hold/:hold_id', {
+ templateUrl: './circ/patron/t_hold_details',
+ controller: 'PatronHoldDetailsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/bills', {
+ templateUrl: './circ/patron/t_bills',
+ controller: 'PatronBillsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/bill/:xact_id', {
+ templateUrl: './circ/patron/t_xact_details',
+ controller: 'XactDetailsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/bill_history/:history_tab', {
+ templateUrl: './circ/patron/t_bill_history',
+ controller: 'BillHistoryCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/messages', {
+ templateUrl: './circ/patron/t_messages',
+ controller: 'PatronMessagesCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/edit', {
+ templateUrl: './circ/patron/t_edit',
+ controller: 'PatronEditCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/credentials', {
+ templateUrl: './circ/patron/t_credentials',
+ controller: 'PatronVerifyCredentialsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/notes', {
+ templateUrl: './circ/patron/t_notes',
+ controller: 'PatronNotesCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/triggered_events', {
+ templateUrl: './circ/patron/t_triggered_events',
+ controller: 'PatronTriggeredEventsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/edit_perms', {
+ templateUrl: './circ/patron/t_edit_perms',
+ controller: 'PatronPermsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/group', {
+ templateUrl: './circ/patron/t_group',
+ controller: 'PatronGroupCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/:id/stat_cats', {
+ templateUrl: './circ/patron/t_stat_cats',
+ controller: 'PatronStatCatsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
+})
+
+/**
+ * Patron service
+ */
+.factory('patronSvc',
+ ['$q','$timeout','$location','egCore','egUser','$locale',
+function($q , $timeout , $location , egCore, egUser , $locale) {
+
+ var service = {
+ // cached patron search results
+ patrons : [],
+
+ // currently selected patron object
+ current : null,
+
+ // patron circ stats (overdues, fines, holds)
+ patron_stats : null,
+
+ // event types manually overridden, which should always be
+ // overridden for checkouts to this patron for this instance of
+ // the interface.
+ checkout_overrides : {},
+ };
+
+ // when we change the default patron, we need to clear out any
+ // data collected on that patron
+ service.resetPatronLists = function() {
+ service.checkouts = [];
+ service.items_out = []
+ service.items_out_ids = [];
+ service.holds = [];
+ service.hold_ids = [];
+ service.checkout_overrides = {};
+ service.patron_stats = null;
+ service.hasAlerts = false;
+ service.alertsShown = false;
+ service.patronExpired = false;
+ service.patronExpiresSoon = false;
+ service.retrievedWithInactive = false;
+ }
+ service.resetPatronLists(); // initialize
+
+ // shortcut to force-reload the current primary
+ service.refreshPrimary = function() {
+ if (!service.current) return $q.when();
+ return service.setPrimary(service.current.id(), null, true);
+ }
+
+ // sets the primary display user, fetching data as necessary.
+ service.setPrimary = function(id, user, force) {
+ var user_id = id ? id : (user ? user.id() : null);
+
+ console.debug('setting primary user to: ' + user_id);
+
+ if (!user_id) return $q.reject();
+
+ // when loading a new patron, update the last patron setting
+ if (!service.current || service.current.id() != user_id)
+ egCore.hatch.setLocalItem('eg.circ.last_patron', user_id);
+
+ // avoid running multiple retrievals for the same patron, which
+ // can happen during dbl-click by maintaining a single running
+ // data retrieval promise
+ if (service.primaryUserPromise) {
+ if (service.primaryUserId == user_id) {
+ return service.primaryUserPromise.promise;
+ } else {
+ service.primaryUserPromise = null;
+ }
+ }
+
+ service.primaryUserPromise = $q.defer();
+ service.primaryUserId = user_id;
+
+ service.getPrimary(id, user, force)
+ .then(function() {
+ var p = service.primaryUserPromise;
+ service.primaryUserId = null;
+ // clear before resolution just to be safe.
+ service.primaryUserPromise = null;
+ p.resolve();
+ });
+
+ return service.primaryUserPromise.promise;
+ }
+
+ service.getPrimary = function(id, user, force) {
+
+ if (user) {
+ if (!force && service.current &&
+ service.current.id() == user.id()) {
+ if (service.patron_stats) {
+ return $q.when();
+ } else {
+ return service.fetchUserStats();
+ }
+ }
+
+ service.resetPatronLists();
+ service.current = user;
+ service.localFlesh(user);
+ return service.fetchUserStats();
+
+ } else if (id) {
+ if (!force && service.current && service.current.id() == id) {
+ if (service.patron_stats) {
+ return $q.when();
+ } else {
+ return service.fetchUserStats();
+ }
+ }
+
+ service.resetPatronLists();
+
+ return egUser.get(id).then(
+ function(user) {
+ service.current = user;
+ service.localFlesh(user);
+ return service.fetchUserStats();
+ },
+ function(err) {
+ console.error(
+ "unable to fetch user "+id+': '+js2JSON(err))
+ }
+ );
+ } else {
+
+ // reset with no patron
+ service.resetPatronLists();
+ service.current = null;
+ service.patron_stats = null;
+ return $q.when();
+ }
+ }
+
+ // flesh some additional user fields locally
+ service.localFlesh = function(user) {
+ if (!angular.isObject(typeof user.home_ou()))
+ user.home_ou(egCore.org.get(user.home_ou()));
+
+ angular.forEach(
+ user.standing_penalties(),
+ function(penalty) {
+ if (!angular.isObject(penalty.org_unit()))
+ penalty.org_unit(egCore.org.get(penalty.org_unit()));
+ }
+ );
+
+ // stat_cat_entries == stat_cat_entry_user_map
+ angular.forEach(user.stat_cat_entries(), function(map) {
+ if (angular.isObject(map.stat_cat())) return;
+ // At page load, we only retrieve org-visible stat cats.
+ // For the common case, ignore entries for remote stat cats.
+ var cat = egCore.env.actsc.map[map.stat_cat()];
+ if (cat) {
+ map.stat_cat(cat);
+ cat.owner(egCore.org.get(cat.owner()));
+ }
+ });
+ }
+
+ // resolves to true if the patron account has expired or will
+ // expire soon, based on YAOUS circ.patron_expires_soon_warning
+ // note: returning a promise is no longer strictly necessary
+ // (no more async activity) if the calling function is changed too.
+ service.testExpire = function() {
+
+ var expire = Date.parse(service.current.expire_date());
+ if (expire < new Date()) {
+ return $q.when(service.patronExpired = true);
+ }
+
+ var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
+ if (Number(soon)) {
+ var preExpire = new Date();
+ preExpire.setDate(preExpire.getDate() + Number(soon));
+ if (expire < preExpire)
+ return $q.when(service.patronExpiresSoon = true);
+ }
+
+ return $q.when(false);
+ }
+
+ // resolves to true if there is any aspect of the patron account
+ // which should produce a message in the alerts panel
+ service.checkAlerts = function() {
+
+ if (service.hasAlerts) // already checked
+ return $q.when(true);
+
+ var deferred = $q.defer();
+ var p = service.current;
+
+ if (service.alert_penalties.length ||
+ p.alert_message() ||
+ p.active() == 'f' ||
+ p.barred() == 't' ||
+ service.patron_stats.holds.ready) {
+
+ service.hasAlerts = true;
+ }
+
+ // see if the user was retrieved with an inactive card
+ if (bc = $location.search().card) {
+ var card = p.cards().filter(
+ function(c) { return c.barcode() == bc })[0];
+
+ if (card && card.active() == 'f') {
+ service.hasAlerts = true;
+ service.retrievedWithInactive = true;
+ }
+ }
+
+ // regardless of whether we know of alerts, we still need
+ // to test/fetch the expire data for display
+ service.testExpire().then(function(bool) {
+ if (bool) service.hasAlerts = true;
+ deferred.resolve(service.hasAlerts);
+ });
+
+ return deferred.promise;
+ }
+
+ service.fetchGroupFines = function() {
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.usergroup.members.balance_owed',
+ egCore.auth.token(), service.current.usrgroup()
+ ).then(function(list) {
+ var total = 0;
+ angular.forEach(list, function(u) {
+ total += 100 * Number(u.balance_owed)
+ });
+ service.patron_stats.fines.group_balance_owed = total / 100;
+ });
+ }
+
+ service.getUserStats = function(id) {
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.opac.vital_stats.authoritative',
+ egCore.auth.token(), id
+ ).then(
+ function(stats) {
+ // force numeric to ensure correct boolean handling in templates
+ stats.fines.balance_owed = Number(stats.fines.balance_owed);
+ stats.checkouts.overdue = Number(stats.checkouts.overdue);
+ stats.checkouts.claims_returned =
+ Number(stats.checkouts.claims_returned);
+ stats.checkouts.lost = Number(stats.checkouts.lost);
+ stats.checkouts.out = Number(stats.checkouts.out);
+ stats.checkouts.total_out =
+ stats.checkouts.out + stats.checkouts.overdue;
+ return stats;
+ }
+ );
+ }
+
+
+ // grab additional circ info
+ service.fetchUserStats = function() {
+ return service.getUserStats(service.current.id())
+ .then(function(stats) {
+ service.patron_stats = stats
+ service.alert_penalties = service.current.standing_penalties()
+ .filter(function(pen) {
+ return pen.standing_penalty().staff_alert() == 't'
+ });
+
+ service.summary_stat_cats = [];
+ angular.forEach(service.current.stat_cat_entries(),
+ function(map) {
+ if (angular.isObject(map.stat_cat()) &&
+ map.stat_cat().usr_summary() == 't') {
+ service.summary_stat_cats.push(map);
+ }
+ }
+ );
+
+ return service.fetchGroupFines();
+ });
+ }
+
+ // Avoid using parens [e.g. (1.23)] to indicate negative numbers,
+ // which is the Angular default.
+ // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
+ // FIXME: This change needs to be moved into a project-wide collection
+ // of locale overrides.
+ $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
+ $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
+
+ return service;
+}])
+
+/**
+ * Manages tabbed patron view.
+ * This is the parent scope of all patron tab scopes.
+ *
+ * */
+.controller('PatronCtrl',
+ ['$scope','$q','$location','$filter','egCore','egUser','patronSvc',
+function($scope, $q, $location , $filter, egCore, egUser, patronSvc) {
+
+ // returns true if a redirect occurs
+ function redirectToAlertPanel() {
+
+ $scope.alert_penalties =
+ function() {return patronSvc.alert_penalties}
+
+ if (patronSvc.alertsShown) return false;
+ patronSvc.alertsShown = true;
+
+ // if the patron has any unshown alerts, show them now
+ if (patronSvc.hasAlerts &&
+ !$location.path().match(/alerts$/)) {
+
+ $location
+ .path('/circ/patron/' + patronSvc.current.id() + '/alerts')
+ .search('card', null);
+ return true;
+ }
+
+ // no alert required. If the patron has fines and the show-bills
+ // OUS is applied, direct to the bills page.
+ if ($scope.patron_stats().fines.balance_owed > 0 // TODO: != 0 ?
+ && egCore.env.aous['ui.circ.show_billing_tab_on_bills']
+ && !$location.path().match(/bills$/)) {
+
+ $location
+ .path('/circ/patron/' + patronSvc.current.id() + '/bills')
+ .search('card', null);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // called after each route-specified controller is instantiated.
+ // this doubles as a way to inform the top-level controller that
+ // egStartup.go() has completed, which means we are clear to
+ // fetch the patron, etc.
+ $scope.initTab = function(tab, patron_id) {
+ console.log('init tab ' + tab);
+ $scope.tab = tab;
+ $scope.aous = egCore.env.aous;
+
+ if (patron_id) {
+ $scope.patron_id = patron_id;
+ return patronSvc.setPrimary($scope.patron_id)
+ .then(function() {return patronSvc.checkAlerts()})
+ .then(redirectToAlertPanel);
+ }
+ return $q.when();
+ }
+
+ $scope.patron = function() { return patronSvc.current }
+ $scope.patron_stats = function() { return patronSvc.patron_stats }
+ $scope.summary_stat_cats = function() { return patronSvc.summary_stat_cats }
+
+ $scope.print_address = function(addr) {
+ egCore.print.print({
+ context : 'default',
+ template : 'patron_address',
+ scope : {
+ patron : egCore.idl.toHash(patronSvc.current),
+ address : egCore.idl.toHash(addr)
+ }
+ });
+ }
+
+ $scope.toggle_expand_summary = function() {
+ if ($scope.collapsePatronSummary) {
+ $scope.collapsePatronSummary = false;
+ egCore.hatch.removeItem('eg.circ.patron.summary.collapse');
+ } else {
+ $scope.collapsePatronSummary = true;
+ egCore.hatch.setItem('eg.circ.patron.summary.collapse', true);
+ }
+ }
+
+ // always expand the patron summary in the search UI, regardless
+ // of stored preference.
+ $scope.collapse_summary = function() {
+ return $scope.tab != 'search' && $scope.collapsePatronSummary;
+ }
+
+ egCore.hatch.getItem('eg.circ.patron.summary.collapse')
+ .then(function(val) {$scope.collapsePatronSummary = Boolean(val)});
+}])
+
+.controller('PatronBarcodeSearchCtrl',
+ ['$scope','$location','egCore','egConfirmDialog','egUser','patronSvc',
+function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
+ $scope.selectMe = true; // focus text input
+ patronSvc.setPrimary(); // clear the default user
+
+ // jump to the patron checkout UI
+ function loadPatron(user_id) {
+ $location
+ .path('/circ/patron/' + user_id + '/checkout')
+ .search('card', $scope.args.barcode);
+ }
+
+ // create an opt-in=yes response for the loaded user
+ function createOptIn(user_id) {
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.org_unit_opt_in.create',
+ egCore.auth.token(), user_id).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) return alert(evt);
+ loadPatron(user_id);
+ }
+ );
+ }
+
+ $scope.submitBarcode = function(args) {
+ $scope.bcNotFound = null;
+ if (!args.barcode) return;
+
+ // blur so next time it's set to true it will re-apply select()
+ $scope.selectMe = false;
+
+ var user_id;
+
+ // lookup barcode
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ egCore.auth.token(), egCore.auth.user().ws_ou(),
+ 'actor', args.barcode)
+
+ .then(function(resp) { // get_barcodes
+
+ if (evt = egCore.evt.parse(resp)) {
+ alert(evt); // FIXME
+ return;
+ }
+
+ if (!resp || !resp[0]) {
+ $scope.bcNotFound = args.barcode;
+ $scope.selectMe = true;
+ return;
+ }
+
+ // see if an opt-in request is needed
+ user_id = resp[0].id;
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.org_unit_opt_in.check',
+ egCore.auth.token(), user_id);
+
+ }).then(function(optInResp) { // opt_in_check
+
+ if (evt = egCore.evt.parse(optInResp)) {
+ alert(evt); // FIXME
+ return;
+ }
+
+ if (optInResp == 1) {
+ // opt-in handled or not needed
+ return loadPatron(user_id);
+ }
+
+ // opt-in needed, show the opt-in dialog
+ egUser.get(user_id, {useFields : []})
+
+ .then(function(user) { // retrieve user
+ egConfirmDialog.open(
+ egCore.strings.OPT_IN_DIALOG, '',
+ { org : egCore.org.get(user.home_ou()),
+ user : user,
+ ok : function() { createOptIn(user.id()) },
+ cancel : function() {}
+ }
+ );
+ })
+ });
+ }
+}])
+
+
+/**
+ * Manages patron search
+ */
+.controller('PatronSearchCtrl',
+ ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
+ '$filter','egUser', 'patronSvc','egGridDataProvider',
+function($scope, $q, $routeParams, $timeout, $window, $location, egCore,
+ $filter, egUser, patronSvc , egGridDataProvider) {
+
+ $scope.initTab('search');
+ $scope.focusMe = true;
+ $scope.searchArgs = {
+ // default to searching globally
+ home_ou : egCore.org.tree()
+ };
+
+ $scope.gridControls = {
+ activateItem : function(item) {
+ $location.path('/circ/patron/' + item.id() + '/checkout');
+ },
+ selectedItems : function() {return []}
+ }
+
+ // Handle URL-encoded searches
+ if ($location.search().search) {
+ patronSvc.urlSearch = {search : JSON2js($location.search().search)};
+
+ // why the double-JSON encoded sort?
+ patronSvc.urlSearch.sort =
+ JSON2js(patronSvc.urlSearch.search.search_sort);
+ delete patronSvc.urlSearch.search.search_sort;
+ }
+
+ var propagate;
+ if (patronSvc.lastSearch) {
+ propagate = patronSvc.lastSearch.search;
+ } else if (patronSvc.urlSearch) {
+ propagate = patronSvc.urlSearch.search;
+ }
+
+ if (propagate) {
+ // populate the search form with our cached / preexisting search info
+ angular.forEach(propagate, function(val, key) {
+ $scope.searchArgs[key] = val.value;
+ });
+ }
+
+ var provider = egGridDataProvider.instance({});
+
+ $scope.$watch(
+ function() {return $scope.gridControls.selectedItems()},
+ function(list) {
+ if (list[0])
+ patronSvc.setPrimary(null, list[0]);
+ },
+ true
+ );
+
+ provider.get = function(offset, count) {
+ var deferred = $q.defer();
+
+ var fullSearch;
+ if (patronSvc.urlSearch) {
+ fullSearch = patronSvc.urlSearch;
+ // enusre the urlSearch only runs once.
+ delete patronSvc.urlSearch;
+
+ } else {
+
+ var search = compileSearch($scope.searchArgs);
+ if (Object.keys(search) == 0) return $q.when();
+
+ var home_ou = search.home_ou;
+ delete search.home_ou;
+ var inactive = search.inactive;
+ delete search.inactive;
+
+ fullSearch = {
+ search : search,
+ sort : compileSort(),
+ inactive : inactive,
+ home_ou : home_ou,
+ };
+ }
+
+ fullSearch.count = count;
+ fullSearch.offset = offset;
+
+ if (patronSvc.lastSearch) {
+ // search repeated, return the cached results
+ if (angular.equals(fullSearch, patronSvc.lastSearch)) {
+ console.log('patron search returning ' +
+ patronSvc.patrons.length + ' cached results');
+
+ // notify has to happen after returning the promise
+ $timeout(
+ function() {
+ angular.forEach(patronSvc.patrons, function(user) {
+ deferred.notify(user);
+ });
+ deferred.resolve();
+ }
+ );
+ return deferred.promise;
+ }
+ }
+
+ patronSvc.lastSearch = fullSearch;
+
+ if (fullSearch.search.id) {
+ // search by user id performs a direct ID lookup
+ var userId = fullSearch.search.id.value;
+ $timeout(
+ function() {
+ egUser.get(userId).then(function(user) {
+ patronSvc.localFlesh(user);
+ patronSvc.patrons = [user];
+ deferred.notify(user);
+ deferred.resolve();
+ });
+ }
+ );
+ return deferred.promise;
+ }
+
+ patronSvc.patrons = [];
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.patron.search.advanced.fleshed',
+ egCore.auth.token(),
+ fullSearch.search,
+ fullSearch.count,
+ fullSearch.sort,
+ fullSearch.inactive,
+ fullSearch.home_ou,
+ egUser.defaultFleshFields,
+ fullSearch.offset
+
+ ).then(
+ function() { deferred.resolve() },
+ null, // onerror
+ function(user) {
+ patronSvc.localFlesh(user); // inline
+ patronSvc.patrons.push(user);
+ deferred.notify(user);
+ }
+ );
+
+ return deferred.promise;
+ };
+
+ $scope.patronSearchGridProvider = provider;
+
+ if (egCore.env.pgt) {
+ $scope.profiles = egCore.env.pgt.list;
+ } else {
+ egCore.pcrud.search('pgt', {parent : null},
+ {flesh : -1, flesh_fields : {pgt : ['children']}}
+ ).then(
+ function(tree) {
+ egCore.env.absorbTree(tree, 'pgt')
+ $scope.profiles = egCore.env.pgt.list;
+ }
+ );
+ }
+
+ // determine the tree depth of the profile group
+ $scope.pgt_depth = function(grp) {
+ var d = 0;
+ while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+ return d;
+ }
+
+ $scope.applyShowExtras = function($event, bool) {
+ if (bool) {
+ $scope.showExtras = true;
+ egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
+ } else {
+ $scope.showExtras = false;
+ egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
+ }
+ $event.preventDefault();
+ }
+
+ egCore.hatch.getItem('eg.prefs.circ.patron.search.showExtras')
+ .then(function(val) {$scope.showExtras = val});
+
+ // map form arguments into search params
+ function compileSearch(args) {
+ var search = {};
+ angular.forEach(args, function(val, key) {
+ if (!val) return;
+ if (key == 'profile' && args.profile) {
+ search.profile = {value : args.profile.id(), group : 0};
+ } else if (key == 'home_ou' && args.home_ou) {
+ search.home_ou = args.home_ou.id(); // passed separately
+ } else if (key == 'inactive') {
+ search.inactive = val;
+ } else {
+ search[key] = {value : val, group : 0};
+ }
+ if (key.match(/phone|ident/)) {
+ search[key].group = 2;
+ } else {
+ if (key.match(/street|city|state|post_code/)) {
+ search[key].group = 1;
+ } else if (key == 'card') {
+ search[key].group = 3
+ }
+ }
+ });
+
+ return search;
+ }
+
+ function compileSort() {
+
+ if (!provider.sort.length) {
+ return [ // default
+ "family_name ASC",
+ "first_given_name ASC",
+ "second_given_name ASC",
+ "dob DESC"
+ ];
+ }
+
+ var sort = [];
+ angular.forEach(
+ provider.sort,
+ function(sortdef) {
+ if (angular.isObject(sortdef)) {
+ var name = Object.keys(sortdef)[0];
+ var dir = sortdef[name];
+ sort.push(name + ' ' + dir);
+ } else {
+ sort.push(sortdef);
+ }
+ }
+ );
+
+ return sort;
+ }
+
+ // search form submit action; tells the results grid to
+ // refresh itself.
+ $scope.search = function(args) { // args === $scope.searchArgs
+ if (args && Object.keys(args).length)
+ $scope.gridControls.refresh();
+ }
+
+ // TODO: move this into the (forthcoming) grid row activate action
+ $scope.onPatronDblClick = function($event, user) {
+ $location.path('/circ/patron/' + user.id() + '/checkout');
+ }
+
+ if (patronSvc.urlSearch) {
+ // force the grid to load the url-based search on page load
+ provider.refresh();
+ }
+
+}])
+
+/**
+ * Manages messages
+ */
+.controller('PatronMessagesCtrl',
+ ['$scope','$q','$routeParams','egCore','$modal','patronSvc','egCirc',
+function($scope , $q , $routeParams, egCore , $modal , patronSvc , egCirc) {
+ $scope.initTab('messages', $routeParams.id);
+ var usr_id = $routeParams.id;
+
+ // setup date filters
+ var start = new Date(); // now - 1 year
+ start.setFullYear(start.getFullYear() - 1),
+ $scope.dates = {
+ start_date : start,
+ end_date : new Date()
+ }
+
+ function date_range() {
+ var start = $scope.dates.start_date.toISOString().replace(/T.*/,'');
+ var end = $scope.dates.end_date.toISOString().replace(/T.*/,'');
+ var today = new Date().toISOString().replace(/T.*/,'');
+ if (end == today) end = 'now';
+ return [start, end];
+ }
+
+ // grid queries
+
+ var activeGrid = $scope.activeGridControls = {
+ setSort : function() {
+ return ['set_date'];
+ },
+ setQuery : function() {
+ return {
+ usr : usr_id,
+ '-or' : [
+ {stop_date : null},
+ {stop_date : {'>' : 'now'}}
+ ]
+ }
+ }
+ }
+
+ var archiveGrid = $scope.archiveGridControls = {
+ setSort : function() {
+ return ['set_date'];
+ },
+ setQuery : function() {
+ return {
+ usr : usr_id,
+ stop_date : {'<=' : 'now'},
+ set_date : {between : date_range()}
+ };
+ }
+ };
+
+ $scope.removePenalty = function(selected) {
+ // the grid stores flattened penalties. Fetch penalty objects first
+
+ var ids = selected.map(function(s){ return s.id });
+ egCore.pcrud.search('ausp',
+ {id : ids}, {},
+ {atomic : true, authoritative : true}
+
+ // then delete them
+ ).then(function(penalties) {
+ return egCore.pcrud.remove(penalties);
+
+ // then refresh the grid
+ }).then(function() {
+ activeGrid.refresh();
+ });
+ }
+
+ $scope.archivePenalty = function(selected) {
+ // the grid stores flattened penalties. Fetch penalty objects first
+
+ var ids = selected.map(function(s){ return s.id });
+ egCore.pcrud.search('ausp',
+ {id : ids}, {},
+ {atomic : true, authoritative : true}
+
+ // then delete them
+ ).then(function(penalties) {
+ angular.forEach(penalties, function(p){ p.stop_date('now') });
+ return egCore.pcrud.update(penalties);
+
+ // then refresh the grid
+ }).then(function() {
+ activeGrid.refresh();
+ archiveGrid.refresh();
+ });
+ }
+
+ // leverage egEnv for caching
+ function fetchPenaltyTypes() {
+ if (egCore.env.csp)
+ return $q.when(egCore.env.csp.list);
+ return egCore.pcrud.search(
+ // id <= 100 are reserved for system use
+ 'csp', {id : {'>': 100}}, {}, {atomic : true})
+ .then(function(penalties) {
+ egCore.env.absorbList(penalties, 'csp');
+ return penalties;
+ });
+ }
+
+ $scope.createPenalty = function() {
+ egCirc.create_penalty(usr_id).then(function() {
+ activeGrid.refresh();
+ // force a refresh of the user, since they may now
+ // have blocking penalties, etc.
+ patronSvc.setPrimary(patronSvc.current.id(), null, true);
+ });
+ }
+
+ $scope.editPenalty = function(selected) {
+ if (selected.length == 0) return;
+
+ // grab the penalty from the user object
+ var penalty = patronSvc.current.standing_penalties().filter(
+ function(p) {return p.id() == selected[0].id})[0];
+
+ egCirc.edit_penalty(penalty).then(function() {
+ activeGrid.refresh();
+ // force a refresh of the user, since they may now
+ // have blocking penalties, etc.
+ patronSvc.setPrimary(patronSvc.current.id(), null, true);
+ });
+ }
+}])
+
+
+/**
+ * Link to patron edit UI
+ */
+.controller('PatronEditCtrl',
+ ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope, $routeParams, $location , egCore , patronSvc) {
+ $scope.initTab('edit', $routeParams.id);
+
+ var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+ url += '?usr=' + encodeURIComponent($routeParams.id);
+
+ $scope.funcs = {
+ on_save : function() {
+ patronSvc.refreshPrimary();
+ }
+ }
+
+ $scope.patron_edit_url = url;
+}])
+
+/**
+ * Credentials tester
+ */
+.controller('PatronVerifyCredentialsCtrl',
+ ['$scope','$routeParams','$location','egCore',
+function($scope, $routeParams , $location , egCore) {
+ $scope.verified = null;
+ $scope.focusMe = true;
+
+ // called with a patron, pre-populate the form args
+ $scope.initTab('other', $routeParams.id).then(
+ function() {
+ if ($scope.patron()) {
+ $scope.prepop = true;
+ $scope.username = $scope.patron().usrname();
+ $scope.barcode = $scope.patron().card().barcode();
+ }
+ }
+ );
+
+ // verify login credentials
+ $scope.verify = function() {
+ $scope.verified = null;
+ $scope.notFound = false;
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.verify_user_password',
+ egCore.auth.token(), $scope.barcode,
+ $scope.username, hex_md5($scope.password || '')
+
+ ).then(function(resp) {
+ $scope.focusMe = true;
+ if (evt = egCore.evt.parse(resp)) {
+ alert(evt);
+ } else if (resp == 1) {
+ $scope.verified = true;
+ } else {
+ $scope.verified = false;
+ }
+ });
+ }
+
+ // load the main patron UI for the provided username or barcode
+ $scope.load = function($event) {
+ $scope.notFound = false;
+ $scope.verified = null;
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+ egCore.auth.token(), $scope.barcode, $scope.username
+
+ ).then(function(resp) {
+
+ if (Number(resp)) {
+ $location.path('/circ/patron/' + resp + '/checkout');
+ return;
+ }
+
+ // something went wrong...
+ $scope.focusMe = true;
+ if (evt = egCore.evt.parse(resp)) {
+ if (evt.textcode == 'ACTOR_USR_NOT_FOUND') {
+ $scope.notFound = true;
+ return;
+ }
+ return alert(evt);
+ } else {
+ alert(resp);
+ }
+ });
+
+ // load() button sits within the verify form.
+ // avoid submitting the verify() form action on load()
+ $event.preventDefault();
+ }
+}])
+
+.controller('PatronAlertsCtrl',
+ ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope, $routeParams , $location , egCore , patronSvc) {
+
+ $scope.initTab('other', $routeParams.id)
+ .then(function() {
+ $scope.patronExpired = patronSvc.patronExpired;
+ $scope.patronExpiresSoon = patronSvc.patronExpiresSoon;
+ $scope.retrievedWithInactive = patronSvc.retrievedWithInactive;
+ });
+
+}])
+
+.controller('PatronNotesCtrl',
+ ['$scope','$routeParams','$location','egCore','patronSvc','$modal',
+function($scope, $routeParams , $location , egCore , patronSvc , $modal) {
+ $scope.initTab('other', $routeParams.id);
+ var usr_id = $routeParams.id;
+
+ // fetch the notes
+ function refreshPage() {
+ $scope.notes = [];
+ egCore.pcrud.search('aun',
+ {usr : usr_id},
+ {flesh : 1, flesh_fields : {aun : ['creator']}},
+ {authoritative : true})
+ .then(null, null, function(note) {
+ $scope.notes.push(note);
+ });
+ }
+
+ // open the new-note dialog and create the note
+ $scope.newNote = function() {
+ $modal.open({
+ templateUrl: './circ/patron/t_new_note_dialog',
+ controller:
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.focusNote = true;
+ $scope.args = {};
+ $scope.ok = function(count) { $modalInstance.close($scope.args) }
+ $scope.cancel = function () { $modalInstance.dismiss() }
+ }],
+ }).result.then(
+ function(args) {
+ if (!args.value) return;
+ var note = new egCore.idl.aun();
+ note.usr(usr_id);
+ note.title(args.title);
+ note.value(args.value);
+ note.pub(args.pub ? 't' : 'f');
+ note.creator(egCore.auth.user().id());
+ egCore.pcrud.create(note).then(function() {refreshPage()});
+ }
+ );
+ }
+
+ // delete the selected note
+ $scope.deleteNote = function(note) {
+ egCore.pcrud.remove(note).then(function() {refreshPage()});
+ }
+
+ // print the selected note
+ $scope.printNote = function(note) {
+ var hash = egCore.idl.toHash(note);
+ hash.usr = egCore.idl.toHash($scope.patron());
+ egCore.print.print({
+ context : 'default',
+ template : 'patron_note',
+ scope : {note : hash}
+ });
+ }
+
+ // perform the initial note fetch
+ refreshPage();
+}])
+
+.controller('PatronGroupCtrl',
+ ['$scope','$routeParams','$q','$window','$location','egCore',
+ 'patronSvc','$modal','egPromptDialog','egConfirmDialog',
+function($scope, $routeParams , $q , $window , $location , egCore ,
+ patronSvc , $modal , egPromptDialog , egConfirmDialog) {
+
+ var usr_id = $routeParams.id;
+
+ $scope.totals = {owed : 0, total_out : 0, overdue : 0}
+
+ var grid = $scope.gridControls = {
+ activateItem : function(item) {
+ $location.path('/circ/patron/' + item.id + '/checkout');
+ },
+ itemRetrieved : function(item) {
+
+ if (item.id == patronSvc.current.id()) {
+ item.stats = patronSvc.patron_stats;
+
+ } else {
+ // flesh stats for other group members
+ patronSvc.getUserStats(item.id).then(function(stats) {
+ item.stats = stats;
+ $scope.totals.total_out += stats.checkouts.total_out;
+ $scope.totals.overdue += stats.checkouts.overdue;
+ });
+ }
+ },
+ setSort : function() {
+ return ['create_date'];
+ }
+ }
+
+ $scope.initTab('other', $routeParams.id)
+ .then(function(redirect) {
+ // if we are redirecting to the alerts page, avoid updating the
+ // grid query.
+ if (redirect) return;
+ // let initTab() fetch the user first so we can know the usrgroup
+
+ grid.setQuery({
+ usrgroup : patronSvc.current.usrgroup(),
+ deleted : 'f'
+ });
+ $scope.totals.owed = patronSvc.patron_stats.fines.group_balance_owed;
+ });
+
+ $scope.removeFromGroup = function(selected) {
+ var promises = [];
+ angular.forEach(selected, function(user) {
+ console.debug('removing user ' + user.id + ' from group');
+
+ promises.push(
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.usergroup.new',
+ egCore.auth.token(), user.id, true
+ )
+ );
+ });
+
+ $q.all(promises).then(function() {grid.refresh()});
+ }
+
+ function addUserToGroup(user) {
+ user.usrgroup(patronSvc.current.usrgroup());
+ user.ischanged(true);
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.patron.update',
+ egCore.auth.token(), user
+
+ ).then(function() {grid.refresh()});
+ }
+
+ // fetch each user ("selected" has flattened users)
+ // update the usrgroup, then update the user object
+ // After all updates are complete, refresh the grid.
+ function moveUsersToGroup(target_user, selected) {
+ var promises = [];
+
+ angular.forEach(selected, function(user) {
+ promises.push(
+ egCore.pcrud.retrieve('au', user.id)
+ .then(function(u) {
+ u.usrgroup(target_user.usrgroup());
+ u.ischanged(true);
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.patron.update',
+ egCore.auth.token(), u
+ );
+ })
+ );
+ });
+
+ $q.all(promises).then(function() {grid.refresh()});
+ }
+
+ function showMoveToGroupConfirm(barcode, selected) {
+
+ // find the user
+ egCore.pcrud.search('ac', {barcode : barcode})
+
+ // fetch the fleshed user
+ .then(function(card) {
+
+ if (!card) return; // TODO: warn user
+
+ egCore.pcrud.retrieve('au', card.usr())
+ .then(function(user) {
+ user.card(card);
+ $modal.open({
+ templateUrl: './circ/patron/t_move_to_group_dialog',
+ controller: [
+ '$scope','$modalInstance',
+ function($scope , $modalInstance) {
+ $scope.user = user;
+ $scope.outbound = Boolean(selected);
+ $scope.ok =
+ function(count) { $modalInstance.close() }
+ $scope.cancel =
+ function () { $modalInstance.dismiss() }
+ }
+ ]
+ }).result.then(function() {
+ if (selected) {
+ moveUsersToGroup(user, selected);
+ } else {
+ addUserToGroup(user);
+ }
+ });
+ });
+ });
+ }
+
+ // selected == move selected patrons to another patron's group
+ // !selected == patron from a different group moves into our group
+ function moveToGroup(selected) {
+ egPromptDialog.open(
+ egCore.strings.GROUP_ADD_USER, '',
+ {ok : function(value) {
+ if (value)
+ showMoveToGroupConfirm(value, selected);
+ }}
+ );
+ }
+
+ $scope.moveToGroup = function() { moveToGroup() };
+ $scope.moveToAnotherGroup = function(selected) { moveToGroup(selected) };
+
+ $scope.cloneUser = function(selected) {
+ if (!selected.length) return;
+ var url = $location.absUrl().replace(
+ /\/patron\/.*/,
+ '/patron/register/clone/' + selected[0].id);
+ $window.open(url, '_blank').focus();
+ }
+
+ $scope.retrieveSelected = function(selected) {
+ if (!selected.length) return;
+ var url = $location.absUrl().replace(
+ /\/patron\/.*/,
+ '/patron/' + selected[0].id + '/checkout');
+ $window.open(url, '_blank').focus();
+ }
+
+}])
+
+.controller('PatronStatCatsCtrl',
+ ['$scope','$routeParams','$q','egCore','patronSvc',
+function($scope, $routeParams , $q , egCore , patronSvc) {
+ $scope.initTab('other', $routeParams.id)
+ .then(function(redirect) {
+ // Entries for org-visible stat cats are fleshed. Any others
+ // have to be fleshed within.
+
+ var to_flesh = {};
+ angular.forEach(patronSvc.current.stat_cat_entries(),
+ function(entry) {
+ if (!angular.isObject(entry.stat_cat())) {
+ to_flesh[entry.stat_cat()] = entry;
+ }
+ }
+ );
+
+ if (!Object.keys(to_flesh).length) return;
+
+ egCore.pcrud.search('actsc', {id : Object.keys(to_flesh)})
+ .then(null, null, function(cat) { // stream
+ cat.owner(egCore.org.get(cat.owner())); // owner flesh
+ to_flesh[cat.id()].stat_cat(cat);
+ });
+ });
+}])
+
+.controller('PatronFetchLastCtrl',
+ ['$scope','$location','egCore',
+function($scope , $location , egCore) {
+
+ var id = egCore.hatch.getLocalItem('eg.circ.last_patron');
+ if (id) return $location.path('/circ/patron/' + id + '/checkout');
+
+ $scope.no_last = true;
+}])
+
+.controller('PatronTriggeredEventsCtrl',
+ ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope, $routeParams, $location , egCore , patronSvc) {
+ $scope.initTab('other', $routeParams.id);
+
+ var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+ url += '?patron_id=' + encodeURIComponent($routeParams.id);
+
+ $scope.triggered_events_url = url;
+ $scope.funcs = {};
+}])
+
+.controller('PatronPermsCtrl',
+ ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+ $scope.initTab('other', $routeParams.id);
+
+ var url = $location.absUrl().replace(
+ /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+ url += '?usr=' + encodeURIComponent($routeParams.id);
+
+ // user_edit does not load the session via cookie. It uses URL
+ // params or xulG instead. Pass via xulG.
+ $scope.funcs = {
+ ses : egCore.auth.token(),
+ on_patron_save : function() {
+ $scope.funcs.reload();
+ }
+ }
+
+ $scope.user_perms_url = url;
+}])
+
--- /dev/null
+
+/* Billing Service */
+
+angular.module('egPatronApp')
+
+.factory('billSvc',
+ ['$q','egCore','patronSvc',
+function($q , egCore , patronSvc) {
+
+ var service = {};
+
+ // fetch org unit settings specific to the bills display
+ service.fetchBillSettings = function() {
+ if (service.settings) return $q.when(service.settings);
+ return egCore.org.settings(
+ ['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']
+ ).then(function(s) {return service.settings = s});
+ }
+
+ // user billing summary
+ service.fetchSummary = function() {
+ return egCore.pcrud.retrieve(
+ 'mous', service.userId, {}, {authoritative : true})
+ .then(function(summary) {return service.summary = summary})
+ }
+
+ service.applyPayment = function(type, payments, note) {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.money.payment',
+ egCore.auth.token(), {
+ userid : service.userId,
+ note : note || '',
+ payment_type : type,
+ payments : payments,
+ patron_credit : 0
+ },
+ patronSvc.current.last_xact_id()
+ ).then(function(resp) {
+ console.debug('payments: ' + js2JSON(resp));
+ if (evt = egCore.evt.parse(resp))
+ return alert(evt);
+
+ // payment API returns the update xact id so we can track it
+ // for future payments without having to refresh the user.
+ patronSvc.current.last_xact_id(resp.last_xact_id);
+ return resp.payments;
+ });
+ }
+
+ service.fetchBills = function(xact_id) {
+ var bills = [];
+ return egCore.pcrud.search('mb',
+ {xact : xact_id}, null,
+ {authoritative : true}
+ ).then(
+ function() {return bills},
+ null,
+ function(bill) {bills.push(bill); return bill}
+ );
+ }
+
+ // TODO: no longer needed?
+ service.fetchPayments = function(xact_id) {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.money.payment.retrieve.all.authoritative',
+ egCore.auth.token(), xact_id
+ );
+ }
+
+ service.voidBills = function(bill_ids) {
+ return egCore.net.requestWithParamList(
+ 'open-ils.circ',
+ 'open-ils.circ.money.billing.void',
+ [egCore.auth.token()].concat(bill_ids)
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) return alert(evt);
+ return resp;
+ });
+ }
+
+ service.updateBillNotes = function(note, ids) {
+ return egCore.net.requestWithParamList(
+ 'open-ils.circ',
+ 'open-ils.circ.money.billing.note.edit',
+ [egCore.auth.token(), note].concat(ids)
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) return alert(evt);
+ return resp;
+ });
+ }
+
+ service.updatePaymentNotes = function(note, ids) {
+ return egCore.net.requestWithParamList(
+ 'open-ils.circ',
+ 'open-ils.circ.money.payment.note.edit',
+ [egCore.auth.token(), note].concat(ids)
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) return alert(evt);
+ return resp;
+ });
+ }
+
+ return service;
+}])
+
+
+/**
+ * Manages Bills
+ */
+.controller('PatronBillsCtrl',
+ ['$scope','$q','$routeParams','egCore','egConfirmDialog','$location',
+ 'egGridDataProvider','billSvc','patronSvc','egPromptDialog','$modal',
+ 'egBilling',
+function($scope , $q , $routeParams , egCore , egConfirmDialog , $location,
+ egGridDataProvider , billSvc , patronSvc , egPromptDialog , $modal,
+ egBilling) {
+
+ $scope.initTab('bills', $routeParams.id);
+ billSvc.userId = $routeParams.id;
+
+ // set up some defaults
+ $scope.payment_amount = 0;
+ $scope.session_voided = 0;
+ $scope.payment_type = 'cash_payment';
+ $scope.focus_payment = true;
+ $scope.annotate_payment = false;
+ $scope.receipt_count = 1;
+ $scope.receipt_on_pay = false;
+
+ // pre-define list-returning funcs in case we access them
+ // before the grid instantiates
+ $scope.gridControls = {
+ focusRowSelector : false,
+ selectedItems : function(){return []},
+ allItems : function(){return []},
+ itemRetrieved : function(item) {
+ item.payment_pending = 0;
+ },
+ activateItem : function(item) {
+ $scope.showFullDetails([item]);
+ },
+ setQuery : function() {
+ return {
+ usr : billSvc.userId,
+ xact_finish : null,
+ 'summary.balance_owed' : {'<>' : 0}
+ }
+ },
+ setSort : function() {
+ return ['xact_start'];
+ }
+ }
+
+ billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+
+ // given a payment amount, determines how much of that is applied
+ // to selected transactions and how much is left over (change).
+ function pending_payment_info() {
+ var amt = $scope.payment_amount || 0;
+ if (amt >= $scope.owed_selected()) {
+ return {
+ payment : $scope.owed_selected(),
+ change : amt - $scope.owed_selected()
+ }
+ }
+ return {payment : amt, change : 0};
+ }
+
+ // calculates amount owed, billed, and paid for selected items
+ // TODO: move me to service
+ function selected_payment_info() {
+ var info = {owed : 0, billed : 0, paid : 0};
+ angular.forEach($scope.gridControls.selectedItems(), function(item) {
+ info.owed += Number(item['summary.balance_owed']) * 100;
+ info.billed += Number(item['summary.total_owed']) * 100;
+ info.paid += Number(item['summary.total_paid']) * 100;
+ });
+ info.owed /= 100;
+ info.billed /= 100;
+ info.paid /= 100;
+ return info;
+ }
+
+ $scope.pending_payment = function() {
+ return pending_payment_info().payment;
+ }
+ $scope.pending_change = function() {
+ return pending_payment_info().change;
+ }
+ $scope.owed_selected = function() {
+ return selected_payment_info().owed;
+ }
+ $scope.billed_selected = function() {
+ return selected_payment_info().billed;
+ }
+ $scope.paid_selected = function() {
+ return selected_payment_info().paid;
+ }
+ $scope.refunds_available = function() {
+ var amount = 0;
+ angular.forEach($scope.gridControls.allItems(), function(item) {
+ if (item['summary.balance_owed'] < 0)
+ amount += item['summary.balance_owed'] * 100;
+ });
+ return -(amount / 100);
+ }
+
+ // update the item.payment_pending value each time the user
+ // selects different transactions to pay against.
+ $scope.$watch(
+ function() {return $scope.gridControls.selectedItems()},
+ function() {updatePendingColumn()},
+ true
+ );
+
+ // update the item.payment_pending for each (selected)
+ // transaction any time the user-entered payment amount is modified
+ $scope.$watch('payment_amount', updatePendingColumn);
+
+ // updates the value of the payment_pending column in the grid.
+ // This has to be managed manually since the display value in the grid
+ // is derived from the value on the stored item and not the contents
+ // of our local scope variables.
+ function updatePendingColumn() {
+ // reset all to zero..
+ angular.forEach($scope.gridControls.allItems(),
+ function(item) {item.payment_pending = 0});
+
+ var payment_amount = $scope.pending_payment();
+
+ var selected = $scope.gridControls.selectedItems();
+ for (var i = 0; i < selected.length; i++) { // for/break
+ var item = selected[i];
+ var owed = Number(item['summary.balance_owed']);
+
+ if (payment_amount > owed) {
+ // pending payment exceeds balance of current item.
+ // pay the entire item.
+ item.payment_pending = owed;
+ payment_amount -= owed;
+
+ } else {
+ // balance owed on the current item matches or exceeds
+ // the pending payment. Apply the full remainder of
+ // the payment to this item.. and we're done.
+ item.payment_pending = payment_amount;
+ break;
+ }
+ }
+ }
+
+ // builds payment arrays ([xact_id, ammount]) for all transactions
+ // which have a pending payment amount.
+ function generatePayments() {
+ var payments = [];
+ angular.forEach($scope.gridControls.selectedItems(), function(item) {
+ if (item.payment_pending == 0) return;
+ payments.push([item.id, item.payment_pending]);
+ });
+ return payments;
+ }
+
+ function refreshDisplay() {
+ patronSvc.fetchUserStats();
+ billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+ $scope.payment_amount = 0;
+ $scope.gridControls.refresh();
+ }
+
+ // generates payments, collects user note if needed, and sends payment
+ // to server.
+ function sendPayment(note) {
+ var make_payments = generatePayments();
+ billSvc.applyPayment(
+ $scope.payment_type, make_payments, note)
+ .then(function(payment_ids) {
+
+ if ($scope.receipt_on_pay) {
+ printReceipt(
+ $scope.payment_type, payment_ids, make_payments, note);
+ }
+
+ refreshDisplay();
+ })
+ }
+
+ function printReceipt(type, payment_ids, payments_made, note) {
+ var payment_blobs = [];
+ angular.forEach(payments_made, function(payment) {
+ var xact_id = payment[0];
+
+ // find the original transaction in the grid..
+ var xact = $scope.gridControls.allItems().filter(
+ function(item) {return item.id == xact_id})[0];
+
+ payment_blobs.push({
+ xact : egCore.idl.flatToNestedHash(xact),
+ amount : payment[1]
+ });
+ });
+
+ console.log(js2JSON(payment_blobs[0]));
+
+ // page data not yet refreshed, capture data from current scope
+ var print_data = {
+ payment_note : note,
+ previous_balance : Number($scope.summary.balance_owed()),
+ payment_total : Number($scope.payment_amount),
+ payment_applied : $scope.pending_payment(),
+ amount_voided : Number($scope.session_voided),
+ change_given : $scope.pending_change(),
+ payments : payment_blobs,
+ current_location : egCore.idl.toHash(
+ egCore.org.get(egCore.auth.user().ws_ou()))
+ }
+
+ print_data.new_balance = (
+ print_data.previous_balance * 100 -
+ print_data.payment_applied * 100) / 100;
+
+ for (var i = 0; i < $scope.receipt_count; i++) {
+ egCore.print.print({
+ context : 'receipt',
+ template : 'bill_payment',
+ scope : print_data
+ });
+ }
+ }
+
+ $scope.showHistory = function() {
+ $location.path('/circ/patron/' +
+ patronSvc.current.id() + '/bill_history/transactions');
+ }
+
+ // For now, only adds billing to first selected item.
+ // Could do batches later if needed
+ $scope.addBilling = function(all) {
+ if (all[0]) {
+ egBilling.showBillDialog({
+ xact : egCore.idl.flatToNestedHash(all[0]),
+ patron : $scope.patron()
+ }).then(refreshDisplay);
+ }
+ }
+
+ $scope.showBillDialog = function($event) {
+ egBilling.showBillDialog({
+ patron : $scope.patron()
+ }).then(refreshDisplay);
+ }
+
+ // Select refunds adds all refunds to the existing selection.
+ // It does not /only/ select refunds
+ $scope.selectRefunds = function() {
+ var ids = $scope.gridControls.selectedItems().map(
+ function(i) { return i.id });
+ angular.forEach($scope.gridControls.allItems(), function(item) {
+ if (Number(item['summary.balance_owed']) < 0)
+ ids.push(item.id);
+ });
+ $scope.gridControls.selectItems(ids);
+ }
+
+ // -------------
+ // determine on initial page load when all of the grid rows should
+ // be selected.
+ var selectOnLoad = true;
+ billSvc.fetchBillSettings().then(function(s) {
+ if (s['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']) {
+ $scope.focus_payment = false; // de-focus the payment box
+ $scope.gridControls.focusRowSelector = true;
+ selectOnLoad = false;
+ // if somehow the grid finishes rendering before our settings
+ // arrive, manually de-select everything.
+ $scope.gridControls.selectItems([]);
+ }
+ });
+
+ $scope.gridControls.allItemsRetrieved = function() {
+ if (selectOnLoad) {
+ selectOnLoad = false; // only for initial controller load.
+ // select all non-refund items
+ $scope.gridControls.selectItems(
+ $scope.gridControls.allItems()
+ .filter(function(i) {return i['summary.balance_owed'] > 0})
+ .map(function(i){return i.id})
+ );
+ }
+ }
+ // -------------
+
+
+ $scope.printBills = function(selected) {
+ if (!selected.length) return;
+ // bills print receipt assumes nested hashes, but our grid
+ // stores flattener data. Fetch the selected xacts as
+ // fleshed pcrud objects and hashify.
+ // (Consider an alternate approach..)
+ var ids = selected.map(function(t){ return t.id });
+ var xacts = [];
+ egCore.pcrud.search('mbt',
+ {id : ids},
+ {flesh : 1, flesh_fields : {'mbt' : ['summary']}},
+ {authoritative : true}
+ ).then(
+ function() {
+ egCore.print.print({
+ context : 'receipt',
+ template : 'bills_current',
+ scope : {
+ transactions : xacts,
+ current_location : egCore.idl.toHash(
+ egCore.org.get(egCore.auth.user().ws_ou()))
+ }
+ });
+ },
+ null,
+ function(xact) {
+ xacts.push(egCore.idl.toHash(xact));
+ }
+ );
+ }
+
+ $scope.applyPayment = function() {
+ if ($scope.annotate_payment) {
+ egPromptDialog.open(
+ egCore.strings.ANNOTATE_PAYMENT_MSG, '',
+ {ok : function(value) {sendPayment(value)}}
+ );
+ } else {
+ sendPayment();
+ }
+ }
+
+ $scope.voidAllBillings = function(items) {
+ angular.forEach(items, function(item) {
+
+ billSvc.fetchBills(item.id).then(function(bills) {
+ var bill_ids = [];
+ var cents = 0;
+ angular.forEach(bills, function(b) {
+ if (b.voided() != 't') {
+ cents += b.amount() * 100;
+ bill_ids.push(b.id())
+ }
+ });
+
+ $scope.session_voided =
+ ($scope.session_voided * 100 + cents) / 100;
+
+ if (bill_ids.length == 0) {
+ // TODO: warn
+ return;
+ }
+
+ // TODO: alert of pending voiding
+
+ billSvc.voidBills(bill_ids).then(function() {
+ refreshDisplay();
+ });
+ });
+ });
+ }
+
+ // note this is functionally equivalent to selecting a neg. transaction
+ // then clicking Apply Payment -- this just adds a speed bump (ditto
+ // the XUL client).
+ $scope.refundXact = function(all) {
+ var items = all.filter(function(item) {
+ return item['summary.balance_owed'] < 0
+ });
+
+ if (items.length == 0) return;
+
+ var ids = items.map(function(item) {return item.id});
+
+ egConfirmDialog.open(
+ egCore.strings.CONFIRM_REFUND_PAYMENT, '',
+ { xactIds : ''+ids,
+ ok : function() {
+ // reset the received payment amount. this ensures
+ // we're not mingling payments with refunds.
+ $scope.payment_amount = 0;
+ }
+ }
+ );
+ }
+
+ // direct the user to the transaction details page
+ $scope.showFullDetails = function(all) {
+ if (all[0])
+ $location.path('/circ/patron/' +
+ patronSvc.current.id() + '/bill/' + all[0].id);
+ }
+
+ $scope.activateBill = function(xact) {
+ $scope.showFullDetails([xact]);
+ }
+
+}])
+
+/**
+ * Displays details of a single transaction
+ */
+.controller('XactDetailsCtrl',
+ ['$scope','$q','$routeParams','egCore','egGridDataProvider','patronSvc','billSvc','egPromptDialog','egBilling',
+function($scope, $q , $routeParams , egCore , egGridDataProvider , patronSvc , billSvc , egPromptDialog , egBilling) {
+
+ $scope.initTab('bills', $routeParams.id);
+ var xact_id = $routeParams.xact_id;
+
+ var xactGrid = $scope.xactGridControls = {
+ setQuery : function() { return {xact : xact_id} },
+ setSort : function() { return ['billing_ts'] }
+ }
+
+ var paymentGrid = $scope.paymentGridControls = {
+ setQuery : function() { return {xact : xact_id} },
+ setSort : function() { return ['payment_ts'] }
+ }
+
+ // -- actions
+ $scope.voidBillings = function(bill_list) {
+ var bill_ids = [];
+ angular.forEach(bill_list, function(b) {
+ if (b.voided != 't') bill_ids.push(b.id);
+ });
+
+ if (bill_ids.length == 0) {
+ // TODO: warn
+ return;
+ }
+
+ billSvc.voidBills(bill_ids).then(function() {
+
+ // refresh bills and summary data
+ // note: no need to update payments
+ patronSvc.fetchUserStats();
+
+ egBilling.fetchXact(xact_id).then(function(xact) {
+ $scope.xact = xact
+ });
+
+ xactGrid.refresh();
+ });
+ }
+
+ // batch-edit billing and payment notes, depending on 'type'
+ function editNotes(selected, type) {
+ var notes = selected.map(function(b){ return b.note }).join(',');
+ var ids = selected.map(function(b){ return b.id });
+
+ // show the note edit prompt
+ egPromptDialog.open(
+ egCore.strings.EDIT_BILL_PAY_NOTE, notes, {
+ ids : ''+ids,
+ ok : function(value) {
+
+ var func = 'updateBillNotes';
+ if (type == 'payment') func = 'updatePaymentNotes';
+
+ billSvc[func](value, ids).then(function() {
+ if (type == 'payment') {
+ paymentGrid.refresh();
+ } else {
+ xactGrid.refresh();
+ }
+ });
+ }
+ }
+ );
+ }
+
+ $scope.editBillNotes = function(selected) {
+ editNotes(selected, 'bill');
+ }
+
+ $scope.editPaymentNotes = function(selected) {
+ editNotes(selected, 'payment');
+ }
+
+ // -- retrieve our data
+ egBilling.fetchXact(xact_id).then(function(xact) {
+ $scope.xact = xact;
+
+ // set the title. only needs to be done on initial page load
+ if (xact.circulation()) {
+ if (xact.circulation().target_copy().call_number().id() == -1) {
+ $scope.title = xact.circulation().target_copy().dummy_title();
+ } else {
+ // TODO: shared bib service?
+ $scope.title = xact.circulation().target_copy()
+ .call_number().record().simple_record().title();
+ $scope.title_id = xact.circulation().target_copy()
+ .call_number().record().id();
+ }
+ }
+ });
+}])
+
+
+.controller('BillHistoryCtrl',
+ ['$scope','$q','$routeParams','egCore','patronSvc','billSvc','egPromptDialog','$location',
+function($scope, $q , $routeParams , egCore , patronSvc , billSvc , egPromptDialog , $location) {
+
+ $scope.initTab('bills', $routeParams.id);
+ billSvc.userId = $routeParams.id;
+ $scope.bill_tab = $routeParams.history_tab;
+ $scope.totals = {};
+
+ var start = new Date(); // now - 1 year
+ start.setFullYear(start.getFullYear() - 1),
+ $scope.dates = {
+ xact_start : start,
+ xact_finish : new Date()
+ }
+
+ $scope.date_range = function() {
+ var start = $scope.dates.xact_start.toISOString().replace(/T.*/,'');
+ var end = $scope.dates.xact_finish.toISOString().replace(/T.*/,'');
+ var today = new Date().toISOString().replace(/T.*/,'');
+ if (end == today) end = 'now';
+ return [start, end];
+ }
+}])
+
+
+.controller('BillXactHistoryCtrl',
+ ['$scope','$q','egCore','patronSvc','billSvc','egPromptDialog','$location','egBilling',
+function($scope, $q , egCore , patronSvc , billSvc , egPromptDialog , $location , egBilling) {
+
+ $scope.gridControls = {
+ selectedItems : function(){return []},
+ activateItem : function(item) {
+ $scope.showFullDetails([item]);
+ },
+ setQuery : function() {
+ // open-ils.actor.user.transactions.history.have_bill_or_payment
+ return {
+ '-or' : [
+ {'summary.balance_owed' : {'<>' : 0}},
+ {'summary.last_payment_ts' : {'<>' : null}}
+ ],
+ xact_start : {between : $scope.date_range()},
+ usr : billSvc.userId
+ }
+ }
+ }
+
+
+ // TODO; move me to service
+ function selected_payment_info() {
+ var info = {owed : 0, billed : 0, paid : 0};
+ angular.forEach($scope.gridControls.selectedItems(), function(item) {
+ info.owed += Number(item['summary.balance_owed']) * 100;
+ info.billed += Number(item['summary.total_owed']) * 100;
+ info.paid += Number(item['summary.total_paid']) * 100;
+ });
+ info.owed /= 100;
+ info.billed /= 100;
+ info.paid /= 100;
+ return info;
+ }
+
+ $scope.totals.selected_billed = function() {
+ return selected_payment_info().billed;
+ }
+ $scope.totals.selected_paid = function() {
+ return selected_payment_info().paid;
+ }
+
+ $scope.showFullDetails = function(all) {
+ if (all[0])
+ $location.path('/circ/patron/' +
+ patronSvc.current.id() + '/bill/' + all[0].id);
+ }
+
+ // For now, only adds billing to first selected item.
+ // Could do batches later if needed
+ $scope.addBilling = function(all) {
+ if (all[0]) {
+ egBilling.showBillDialog({
+ xact : egCore.idl.flatToNestedHash(all[0]),
+ patron : $scope.patron()
+ }).then(function() {
+ $scope.gridControls.refresh();
+ patronSvc.fetchUserStats();
+ })
+ }
+ }
+}])
+
+.controller('BillPaymentHistoryCtrl',
+ ['$scope','$q','egCore','patronSvc','billSvc','$location',
+function($scope, $q , egCore , patronSvc , billSvc , $location) {
+
+ $scope.gridControls = {
+ selectedItems : function(){return []},
+ activateItem : function(item) {
+ $scope.showFullDetails([item]);
+ },
+ setSort : function() {
+ return [{'payment_ts' : 'DESC'}, 'id'];
+ },
+ setQuery : function() {
+ return {
+ 'payment_ts' : {between : $scope.date_range()},
+ 'xact.usr' : billSvc.userId
+ }
+ }
+ }
+
+ $scope.showFullDetails = function(all) {
+ if (all[0])
+ $location.path('/circ/patron/' +
+ patronSvc.current.id() + '/bill/' + all[0]['xact.id']);
+ }
+
+ $scope.totals.selected_paid = function() {
+ var paid = 0;
+ angular.forEach($scope.gridControls.selectedItems(), function(payment) {
+ paid += Number(payment.amount) * 100;
+ });
+ return paid / 100;
+ }
+}])
+
+
+
--- /dev/null
+/**
+ * Checkout items to patrons
+ */
+
+angular.module('egPatronApp').controller('PatronCheckoutCtrl',
+
+ ['$scope','$q','$modal','$routeParams','egCore','egUser','patronSvc',
+ 'egGridDataProvider','$location','$timeout','egCirc',
+
+function($scope , $q , $modal , $routeParams , egCore , egUser , patronSvc ,
+ egGridDataProvider , $location , $timeout , egCirc) {
+
+ $scope.initTab('checkout', $routeParams.id);
+ $scope.focusMe = true;
+ $scope.checkouts = patronSvc.checkouts;
+ $scope.checkoutArgs = {
+ noncat_type : 'barcode',
+ due_date : new Date()
+ };
+
+ $scope.gridDataProvider = egGridDataProvider.instance({
+ get : function(offset, count) {
+ return this.arrayNotifier($scope.checkouts, offset, count);
+ }
+ });
+
+ $scope.disable_checkout = function() {
+ return (
+ !patronSvc.current ||
+ patronSvc.current.active() == 'f' ||
+ patronSvc.current.deleted() == 't' ||
+ patronSvc.current.card().active() == 'f'
+ );
+ }
+
+ $scope.using_hatch = egCore.hatch.usingHatch();
+
+ // avoid multiple, in-flight attempts on the same barcode
+ var pending_barcodes = {};
+
+ var printOnComplete = true;
+ egCore.org.settings([
+ 'circ.staff_client.do_not_auto_attempt_print'
+ ]).then(function(settings) {
+ printOnComplete = !Boolean(
+ settings['circ.staff_client.do_not_auto_attempt_print']);
+ });
+
+ egCirc.get_noncat_types().then(function(list) {
+ $scope.nonCatTypes = list;
+ });
+
+ $scope.selectedNcType = function() {
+ if (!egCore.env.cnct) return null; // too soon
+ var type = egCore.env.cnct.map[$scope.checkoutArgs.noncat_type];
+ return type ? type.name() : null;
+ }
+
+ $scope.checkout = function(args) {
+ var params = angular.copy(args);
+ params.patron_id = patronSvc.current.id();
+
+ if (args.sticky_date) {
+ params.due_date = args.due_date.toISOString();
+ } else {
+ delete params.due_date;
+ }
+ delete params.sticky_date;
+
+ if (params.noncat_type == 'barcode') {
+ if (!args.copy_barcode) return;
+
+ args.copy_barcode = ''; // reset UI input
+ params.noncat_type = ''; // "barcode"
+
+ if (pending_barcodes[params.copy_barcode]) {
+ console.log(
+ "Skipping checkout of redundant barcode "
+ + params.copy_barcode
+ );
+ return;
+ }
+
+ pending_barcodes[params.copy_barcode] = true;
+ send_checkout(params);
+
+ } else {
+ egCirc.noncat_dialog(params).then(function() {
+ send_checkout(params)
+ });
+ }
+
+ $scope.focusMe; // return focus to barcode input
+ }
+
+ function send_checkout(params) {
+
+ params.noncat_type = params.noncat ? params.noncat_type : '';
+
+ // populate the grid row before we send the request so that the
+ // order of actions is maintained and so the user gets an
+ // immediate reaction to their barcode input action.
+ var row_item = {
+ index : $scope.checkouts.length,
+ copy_barcode : params.copy_barcode,
+ noncat_type : params.noncat_type
+ };
+
+ $scope.checkouts.unshift(row_item);
+ $scope.gridDataProvider.refresh();
+
+ var options = {check_barcode : $scope.strict_barcode};
+
+ egCirc.checkout(params, options).then(
+ function(co_resp) {
+ // update stats locally so we don't have to fetch them w/
+ // each checkout.
+ patronSvc.patron_stats.checkouts.out++;
+ patronSvc.patron_stats.checkouts.total_out++;
+
+ // copy the response event into the original grid row item
+ // note: angular.copy clobbers the destination
+ row_item.evt = co_resp.evt;
+ angular.forEach(co_resp.data, function(val, key) {
+ row_item[key] = val;
+ });
+ munge_checkout_resp(co_resp, row_item);
+ },
+ function() {
+ // Circ was rejected somewhere along the way.
+ // Remove the copy from the grid since there was no action.
+ // note: since checkouts are unshifted onto the array, the
+ // index value does not (generally) match the array position.
+ var pos = -1;
+ angular.forEach($scope.checkouts, function(co, idx) {
+ if (co.index == row_item.index) pos = idx;
+ });
+ $scope.checkouts.splice(pos, 1);
+ $scope.gridDataProvider.refresh();
+ }
+
+ )['finally'](function() {
+
+ // regardless of the outcome of the circ, remove the
+ // barcode from the pending list.
+ if (params.copy_barcode)
+ delete pending_barcodes[params.copy_barcode];
+ });
+ }
+
+ // add some checkout-specific additions for display
+ function munge_checkout_resp(co_resp, row_item) {
+ var params = co_resp.params;
+ if (params.noncat) {
+ row_item.title = egCore.env.cnct.map[params.noncat_type].name();
+ row_item.noncat_count = params.noncat_count;
+ row_item.circ = new egCore.idl.circ();
+ row_item.circ.due_date(co_resp.evt.payload.noncat_circ.duedate());
+ }
+ }
+
+ $scope.print_receipt = function() {
+ var print_data = {circulations : []}
+
+ if ($scope.checkouts.length == 0) return $q.when();
+
+ angular.forEach($scope.checkouts, function(co) {
+ if (co.circ) {
+ print_data.circulations.push({
+ circ : egCore.idl.toHash(co.circ),
+ copy : egCore.idl.toHash(co.acp),
+ call_number : egCore.idl.toHash(co.acn),
+ title : co.title,
+ author : co.author
+ })
+ };
+ });
+
+ return egCore.print.print({
+ context : 'default',
+ template : 'checkout',
+ scope : print_data,
+ show_dialog : $scope.show_print_dialog
+ });
+ }
+
+ // Redirect the user to the barcode entry page to load a new patron.
+ // If configured to do so, print the receipt first
+ $scope.done = function() {
+ if (printOnComplete) {
+
+ $scope.print_receipt().then(function() {
+ $location.path('/circ/patron/bcsearch');
+ });
+
+ } else {
+ $location.path('/circ/patron/bcsearch');
+ }
+ }
+}])
+
--- /dev/null
+/**
+ * List of patron holds
+ */
+
+angular.module('egPatronApp').controller('PatronHoldsCtrl',
+
+ ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+ 'egGridDataProvider','egHolds','$window','$location','egCirc','egHoldGridActions',
+function($scope, $q, $routeParams, egCore, egUser, patronSvc,
+ egGridDataProvider , egHolds , $window , $location , egCirc, egHoldGridActions) {
+
+ $scope.initTab('holds', $routeParams.id);
+ $scope.holds_display = 'main';
+ $scope.detail_hold_id = $routeParams.hold_id;
+ $scope.grid_actions = egHoldGridActions;
+
+ function refresh_all() {
+ patronSvc.refreshPrimary();
+ patronSvc.holds = [];
+ patronSvc.hold_ids = [];
+ provider.refresh()
+ }
+ $scope.grid_actions.refresh = refresh_all;
+
+ $scope.show_main_list = function() {
+ // don't need a full reset_page() to swap tabs
+ $scope.holds_display = 'main';
+ patronSvc.holds = [];
+ patronSvc.hold_ids = [];
+ provider.refresh();
+ }
+
+ $scope.show_alt_list = function() {
+ // don't need a full reset_page() to swap tabs
+ $scope.holds_display = 'alt';
+ patronSvc.holds = [];
+ patronSvc.hold_ids = [];
+ provider.refresh();
+ }
+
+ var provider = egGridDataProvider.instance({});
+ $scope.gridDataProvider = provider;
+
+ function fetchHolds(offset, count) {
+ var ids = patronSvc.hold_ids.slice(offset, offset + count);
+ return egHolds.fetch_holds(ids).then(null, null,
+ function(hold_data) {
+ patronSvc.holds.push(hold_data);
+ return hold_data;
+ }
+ );
+ }
+
+ provider.get = function(offset, count) {
+
+ // see if we have the requested range cached
+ if (patronSvc.holds[offset]) {
+ return provider.arrayNotifier(patronSvc.holds, offset, count);
+ }
+
+ // see if we have the holds IDs for this range already loaded
+ if (patronSvc.hold_ids[offset]) {
+ return fetchHolds(offset, count);
+ }
+
+ var deferred = $q.defer();
+ patronSvc.hold_ids = [];
+
+ var method = 'open-ils.circ.holds.id_list.retrieve.authoritative';
+ if ($scope.holds_display == 'alt')
+ method = 'open-ils.circ.holds.canceled.id_list.retrieve.authoritative';
+
+ egCore.net.request(
+ 'open-ils.circ', method,
+ egCore.auth.token(), $scope.patron_id
+
+ ).then(function(hold_ids) {
+ if (!hold_ids.length) { deferred.resolve(); return; }
+
+ patronSvc.hold_ids = hold_ids;
+ fetchHolds(offset, count)
+ .then(deferred.resolve, null, deferred.notify);
+ });
+
+ return deferred.promise;
+ }
+
+ $scope.print = function() {
+ var holds = [];
+ angular.forEach(patronSvc.holds, function(item) {
+ holds.push({
+ hold : egCore.idl.toHash(item.hold),
+ copy : egCore.idl.toHash(item.copy),
+ volume : egCore.idl.toHash(item.volume),
+ title : item.mvr.title(),
+ author : item.mvr.author()
+ });
+ });
+
+ egCore.print.print({
+ context : 'receipt',
+ template : 'holds_for_patron',
+ scope : {holds : holds}
+ });
+ }
+
+ $scope.detail_view = function(action, user_data, items) {
+ if (h = items[0]) {
+ $location.path('/circ/patron/' +
+ $scope.patron_id + '/holds/' + h.hold.id());
+ }
+ }
+
+ $scope.list_view = function(items) {
+ $location.path('/circ/patron/' + $scope.patron_id + '/holds');
+ }
+
+ $scope.place_hold = function() {
+ $location.path($location.path() + '/create');
+ }
+
+ // when the detail hold is fetched (and updated), update the bib
+ // record summary display record id.
+ $scope.set_hold = function(hold_data) {
+ $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+ }
+
+}])
+
+
+.controller('PatronHoldsCreateCtrl',
+ ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope , $routeParams , $location , egCore , patronSvc) {
+
+ $scope.handlers = {
+ opac_hold_placed : function() {
+ // FIXME: this isn't getting called.. not sure why
+ patronSvc.fetchUserStats(); // update hold counts
+ }
+ }
+
+ $scope.initTab('holds', $routeParams.id).then(function(isAlert) {
+ if (isAlert) return;
+ // not guarenteed to have a barcode until init fetches the user
+ $scope.handlers.patron_barcode = patronSvc.current.card().barcode();
+ });
+
+ $scope.catalog_url =
+ $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+ $scope.handle_page = function(url) {
+ }
+
+}])
+
--- /dev/null
+/**
+ * List of patron items checked out
+ */
+
+angular.module('egPatronApp')
+
+.controller('PatronItemsOutCtrl',
+ ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+ 'egGridDataProvider','$modal','egCirc','egConfirmDialog','egBilling',
+function($scope, $q, $routeParams, egCore , egUser, patronSvc ,
+ egGridDataProvider , $modal , egCirc , egConfirmDialog , egBilling) {
+ $scope.initTab('items_out', $routeParams.id);
+
+ // cache of circ objects for grid display
+ patronSvc.items_out = [];
+
+ // main list of checked out items
+ $scope.main_list = [];
+
+ // list of alt circs (lost, etc.) and/or check-in with fines circs
+ $scope.alt_list = [];
+
+ // these are fetched during startup (i.e. .configure())
+ // By default, show lost/lo/cr items in the alt list
+ var display_lost = Number(
+ egCore.env.aous['ui.circ.items_out.lost']) || 2;
+ var display_lo = Number(
+ egCore.env.aous['ui.circ.items_out.longoverdue']) || 2;
+ var display_cr = Number(
+ egCore.env.aous['ui.circ.items_out.claimsreturned']) || 2;
+
+ var fetch_checked_in = true;
+ $scope.show_alt_circs = true;
+ if (display_lost & 4 && display_lo & 4 && display_cr & 4) {
+ // all special types are configured to be hidden once
+ // checked in, so there's no need to fetch checked-in circs.
+ fetch_checked_in = false;
+
+ if (display_lost & 1 && display_lo & 1 && display_cr & 1) {
+ // additionally, if all types are configured to display
+ // in the main list while checked out, nothing will
+ // ever appear in the alternate list, so we can hide
+ // the alternate list from the UI.
+ $scope.show_alt_circs = false;
+ }
+ }
+
+ $scope.items_out_display = 'main';
+ $scope.show_main_list = function() {
+ // don't need a full reset_page() to swap tabs
+ $scope.items_out_display = 'main';
+ patronSvc.items_out = [];
+ provider.refresh();
+ }
+
+ $scope.show_alt_list = function() {
+ // don't need a full reset_page() to swap tabs
+ $scope.items_out_display = 'alt';
+ patronSvc.items_out = [];
+ provider.refresh();
+ }
+
+ // Reload the user to pick up changes in items out, fines, etc.
+ // Reload circs since the contents of the main vs. alt list may
+ // have changed.
+ function reset_page() {
+ patronSvc.refreshPrimary();
+ patronSvc.items_out = [];
+ $scope.main_list = [];
+ $scope.alt_list = [];
+ provider.refresh()
+ }
+
+ var provider = egGridDataProvider.instance({});
+ $scope.gridDataProvider = provider;
+
+ function fetch_circs(id_list, offset, count) {
+ if (!id_list.length) return $q.when();
+
+ // fetch the lot of circs and stream the results back via notify
+ return egCore.pcrud.search('circ', {id : id_list},
+ { flesh : 4,
+ flesh_fields : {
+ circ : ['target_copy'],
+ acp : ['call_number'],
+ acn : ['record'],
+ bre : ['simple_record']
+ },
+ // avoid fetching the MARC blob by specifying which
+ // fields on the bre to select. More may be needed.
+ // note that fleshed fields are explicitly selected.
+ select : { bre : ['id'] },
+ limit : count,
+ offset : offset,
+ // we need an order-by to support paging
+ order_by : {circ : ['xact_start']}
+
+ }).then(null, null, function(circ) {
+ circ.circ_lib(egCore.org.get(circ.circ_lib())); // local fleshing
+
+ if (circ.target_copy().call_number().id() == -1) {
+ // dummy-up a record for precat items
+ circ.target_copy().call_number().record().simple_record({
+ title : function() {return circ.target_copy().dummy_title()},
+ author : function() {return circ.target_copy().dummy_author()},
+ isbn : function() {return circ.target_copy().dummy_isbn()}
+ })
+ }
+
+ patronSvc.items_out.push(circ); // toss it into the cache
+ return circ;
+ });
+ }
+
+ // decide which list each circ belongs to
+ function promote_circs(list, display_code, open) {
+ if (open) {
+ if (1 & display_code) { // bitflag 1 == top list
+ $scope.main_list = $scope.main_list.concat(list);
+ } else {
+ $scope.alt_list = $scope.alt_list.concat(list);
+ }
+ } else {
+ if (4 & display_code) return; // bitflag 4 == hide on checkin
+ $scope.alt_list = $scope.alt_list.concat(list);
+ }
+ }
+
+ // fetch IDs for circs we care about
+ function get_circ_ids() {
+ $scope.main_list = [];
+ $scope.alt_list = [];
+
+ // we can fetch these in parallel
+ var promise1 = egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.checked_out.authoritative',
+ egCore.auth.token(), $scope.patron_id
+ ).then(function(outs) {
+ $scope.main_list = outs.out.concat(outs.overdue);
+ promote_circs(outs.lost, display_lost, true);
+ promote_circs(outs.long_overdue, display_lo, true);
+ promote_circs(outs.claims_returned, display_cr, true);
+ });
+
+ // only fetched checked-in-with-bills circs if configured to display
+ var promise2 = !fetch_checked_in ? $q.when() : egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.checked_in_with_fines.authoritative',
+ egCore.auth.token(), $scope.patron_id
+ ).then(function(outs) {
+ promote_circs(outs.lost, display_lost);
+ promote_circs(outs.long_overdue, display_lo);
+ promote_circs(outs.claims_returned, display_cr);
+ });
+
+ return $q.all([promise1, promise2]);
+ }
+
+ provider.get = function(offset, count) {
+
+ var id_list = $scope[$scope.items_out_display + '_list'];
+
+ // see if we have the requested range cached
+ if (patronSvc.items_out[offset]) {
+ return provider.arrayNotifier(
+ patronSvc.items_out, offset, count);
+ }
+
+ // See if we have the circ IDs for this range already loaded.
+ // this would happen navigating to a subsequent page.
+ if (id_list[offset]) {
+ return fetch_circs(id_list, offset, count);
+ }
+
+ // avoid returning the request directly to the caller so the
+ // notify()'s from egCore.net.request don't leak into the
+ // final set of notifies (i.e. the real responses);
+
+ var deferred = $q.defer();
+ get_circ_ids().then(function() {
+
+ id_list = $scope[$scope.items_out_display + '_list'];
+
+ // relay the notified circs back to the grid through our promise
+ fetch_circs(id_list, offset, count).then(
+ deferred.resolve, null, deferred.notify);
+ });
+
+ return deferred.promise;
+ }
+
+
+ // true if circ is overdue, false otherwise
+ $scope.circIsOverdue = function(circ) {
+ // circ may not exist yet for rendered row
+ if (!circ) return false;
+
+ var date = new Date();
+ date.setTime(Date.parse(circ.due_date()));
+ return date < new Date();
+ }
+
+ $scope.edit_due_date = function(items) {
+ if (!items.length) return;
+
+ $modal.open({
+ templateUrl : './circ/patron/t_edit_due_date_dialog',
+ controller : [
+ '$scope','$modalInstance',
+ function($scope , $modalInstance) {
+
+ // if there is only one circ, default to the due date
+ // of that circ. Otherwise, default to today.
+ var due_date = items.length == 1 ?
+ Date.parse(items[0].due_date()) : new Date();
+
+ $scope.args = {
+ num_circs : items.length,
+ due_date : due_date
+ }
+
+ // Fire off the due-date updater for each circ.
+ // When all is done, close the dialog
+ $scope.ok = function(args) {
+ var due = args.due_date.toISOString().replace(/T.*/,'');
+ console.debug("applying due date of " + due);
+
+ var promises = [];
+ angular.forEach(items, function(circ) {
+ promises.push(
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.circulation.due_date.update',
+ egCore.auth.token(), circ.id(), due
+
+ ).then(function(new_circ) {
+ // update the grid circ with the canonical
+ // date from the modified circulation.
+ circ.due_date(new_circ.due_date());
+ })
+ );
+ });
+
+ $q.all(promises).then(function() {
+ $modalInstance.close();
+ provider.refresh();
+ });
+ }
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }
+ ]
+ });
+ }
+
+ $scope.print_receipt = function(items) {
+ if (items.length == 0) return $q.when();
+ var print_data = {circulations : []}
+
+ angular.forEach(patronSvc.items_out, function(circ) {
+ print_data.circulations.push({
+ circ : egCore.idl.toHash(circ),
+ copy : egCore.idl.toHash(circ.target_copy()),
+ call_number : egCore.idl.toHash(circ.target_copy().call_number()),
+ title : circ.target_copy().call_number().record().simple_record().title(),
+ author : circ.target_copy().call_number().record().simple_record().author(),
+ })
+ });
+
+ return egCore.print.print({
+ context : 'default',
+ template : 'items_out',
+ scope : print_data,
+ });
+ }
+
+ function batch_action_with_barcodes(items, action) {
+ if (!items.length) return;
+ var barcodes = items.map(function(circ)
+ { return circ.target_copy().barcode() });
+ action(barcodes).then(reset_page);
+ }
+ $scope.mark_lost = function(items) {
+ batch_action_with_barcodes(items, egCirc.mark_lost);
+ }
+ $scope.mark_claims_returned = function(items) {
+ batch_action_with_barcodes(items, egCirc.mark_claims_returned_dialog);
+ }
+ $scope.mark_claims_never_checked_out = function(items) {
+ batch_action_with_barcodes(items, egCirc.mark_claims_never_checked_out);
+ }
+
+ $scope.renew = function(items, msg) {
+ if (!items.length) return;
+ var barcodes = items.map(function(circ)
+ { return circ.target_copy().barcode() });
+
+ if (!msg) msg = egCore.strings.RENEW_ITEMS;
+
+ return egConfirmDialog.open(msg, barcodes.join(' '), {}).result
+ .then(function() {
+ function do_one() {
+ var bc = barcodes.pop();
+ if (!bc) { reset_page(); return }
+ // finally -> continue even when one fails
+ egCirc.renew({copy_barcode : bc}).finally(do_one);
+ }
+ do_one();
+ });
+ }
+
+ $scope.renew_all = function() {
+ var circs = patronSvc.items_out.filter(function(circ) {
+ return (
+ // all others will be rejected at the server
+ !circ.stop_fines() ||
+ circ.stop_fines() == 'MAXFINES'
+ );
+ });
+ $scope.renew(circs, egCore.strings.RENEW_ALL_ITEMS);
+ }
+
+ $scope.renew_with_date = function(items) {
+ if (!items.length) return;
+ var barcodes = items.map(function(circ)
+ { return circ.target_copy().barcode() });
+
+ return $modal.open({
+ templateUrl : './circ/patron/t_edit_due_date_dialog',
+ templateUrl : './circ/patron/t_renew_with_date_dialog',
+ controller : [
+ '$scope','$modalInstance',
+ function($scope , $modalInstance) {
+ $scope.args = {
+ barcodes : barcodes,
+ date : new Date()
+ }
+ $scope.cancel = function() {$modalInstance.dismiss()}
+
+ // Fire off the due-date updater for each circ.
+ // When all is done, close the dialog
+ $scope.ok = function() {
+ var due = $scope.args.date.toISOString().replace(/T.*/,'');
+ console.debug("renewing with due date: " + due);
+
+ function do_one() {
+ if (bc = barcodes.pop()) {
+ egCirc.renew({copy_barcode : bc, due_date : due})
+ .finally(do_one);
+ } else {
+ $modalInstance.close();
+ reset_page();
+ }
+ }
+ do_one(); // kick it off
+ }
+ }
+ ]
+ }).result;
+ }
+
+ $scope.checkin = function(items) {
+ if (!items.length) return;
+ var barcodes = items.map(function(circ)
+ { return circ.target_copy().barcode() });
+
+ return egConfirmDialog.open(
+ egCore.strings.CHECK_IN_CONFIRM, barcodes.join(' '), {
+
+ }).result.then(function() {
+ function do_one() {
+ if (bc = barcodes.pop()) {
+ egCirc.checkin({copy_barcode : bc})
+ .finally(do_one);
+ } else {
+ reset_page();
+ }
+ }
+ do_one(); // kick it off
+ });
+ }
+
+ $scope.add_billing = function(items) {
+ if (!items.length) return;
+ var circs = items.concat(); // don't pop from grid array
+ function do_one() {
+ var circ; // don't clobber window.circ!
+ if (circ = circs.pop()) {
+ egBilling.showBillDialog({
+ // let the dialog fetch the transaction, since it's
+ // not sufficiently fleshed here.
+ xact_id : circ.id(),
+ patron : patronSvc.current
+ }).finally(do_one);
+ } else {
+ reset_page();
+ }
+ }
+ do_one();
+ }
+
+}]);
+
--- /dev/null
+angular.module('egPendingPatronsApp',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay :
+ ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+ $routeProvider.when('/circ/patron/pending/list', {
+ templateUrl: './circ/patron/t_pending_list',
+ controller: 'PendingPatronsCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/patron/pending/list'});
+})
+
+.controller('PendingPatronsCtrl',
+ ['$scope','$q','$routeParams','$window','$location','egCore','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egGridDataProvider) {
+
+ console.log('HERE');
+
+ var pending_patrons = [];
+ var provider = egGridDataProvider.instance({});
+ $scope.grid_data_provider = provider;
+
+ function load_patron(item) {
+ if (angular.isArray(item)) item = item[0];
+ if (!item) return;
+ $window.open(
+ $location.path(
+ '/circ/patron/register/stage/' + item.user.usrname()).absUrl(),
+ '_blank'
+ ).focus();
+ }
+
+ $scope.load_patron = function(action, data, items) {
+ load_patron(items);
+ }
+
+ $scope.grid_controls = {
+ activateItem : load_patron
+ }
+
+ function refresh_page() {
+ pending_patrons = [];
+ provider.refresh();
+ }
+
+ provider.get = function(offset, count) {
+ var deferred = $q.defer();
+ var recv_index = 0;
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.stage.retrieve.by_org',
+ egCore.auth.token(), $scope.context_org.id()
+
+ ).then(
+ deferred.resolve, null,
+ function(user) {
+ user.id = user.user.row_id();
+ user.user.home_ou(egCore.org.get(user.user.home_ou()));
+
+ // only one (mailing) address is captured during patron
+ // self-registration
+ user.mailing_address = user.mailing_addresses[0];
+ pending_patrons[offset + recv_index++] = user;
+ deferred.notify(user);
+ }
+ );
+
+ return deferred.promise;
+ }
+
+ $scope.context_org = egCore.org.get(egCore.auth.user().ws_ou())
+ $scope.$watch('context_org', function(newVal, oldVal) {
+ if (newVal && newVal != oldVal) refresh_page();
+ });
+}])
+
--- /dev/null
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronRegApp', ['ui.bootstrap','ngRoute','egCoreMod'])
+
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+ var resolver = {delay :
+ ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+ $routeProvider.when('/circ/patron/register', {
+ template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+ controller: 'PatronRegCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/register/stage/:stage_username', {
+ template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+ controller: 'PatronRegCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/register/edit/:edit_id', {
+ template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+ controller: 'PatronRegCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/patron/register/clone/:clone_id', {
+ template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+ controller: 'PatronRegCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/patron/register'});
+})
+
+
+/**
+ * */
+.controller('PatronRegCtrl',
+ ['$scope','$routeParams','$location','egCore',
+function($scope , $routeParams , $location , egCore) {
+
+
+ var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+
+ // since we don't store auth cookies, pass the cookie via URL
+ url += '?ses=' + egCore.auth.token();
+
+ if ($routeParams.stage_username) {
+ url += '&stage=' + encodeURIComponent($routeParams.stage_username);
+ }
+
+ if ($routeParams.edit_id) {
+ url += '&usr=' + encodeURIComponent($routeParams.edit_id);
+ }
+
+ if ($routeParams.clone_id) {
+ url += '&clone=' + encodeURIComponent($routeParams.clone_id);
+ }
+
+ // pass the reg URL into the scope, thus into the
+ $scope.reg_url = url;
+}])
+
--- /dev/null
+/**
+ * Renewal
+ */
+
+angular.module('egRenewApp',
+ ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ $routeProvider.when('/circ/renew/renew', {
+ templateUrl: './circ/renew/t_renew',
+ controller: 'RenewCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.when('/circ/renew/renew', {
+ templateUrl: './circ/renew/t_renew',
+ controller: 'RenewCtrl',
+ resolve : resolver
+ });
+
+ $routeProvider.otherwise({redirectTo : '/circ/renew/renew'});
+})
+
+
+
+
+.controller('RenewCtrl',
+ ['$scope','$window','$location','egCore','egGridDataProvider','egCirc',
+function($scope , $window , $location , egCore , egGridDataProvider , egCirc) {
+
+ $scope.focusBarcode = true;
+ $scope.renewals = [];
+
+ var today = new Date();
+ $scope.renewalArgs = {due_date : today};
+
+ $scope.gridDataProvider = egGridDataProvider.instance({
+ get : function(offset, count) {
+ return this.arrayNotifier($scope.renewals, offset, count);
+ }
+ });
+
+ // avoid multiple, in-flight attempts on the same barcode
+ var pending_barcodes = {};
+
+ $scope.renew = function(args) {
+ var params = angular.copy(args);
+
+ if (args.sticky_date) {
+ params.due_date = args.due_date.toISOString();
+ } else {
+ delete params.due_date;
+ }
+ delete params.sticky_date;
+ if (!args.copy_barcode) return;
+
+ args.copy_barcode = ''; // reset UI input
+
+ if (pending_barcodes[params.copy_barcode]) {
+ console.log(
+ "Skipping renewals of redundant barcode "
+ + params.copy_barcode
+ );
+ return;
+ }
+
+ pending_barcodes[params.copy_barcode] = true;
+ send_renewal(params);
+
+ $scope.focusBarcode = true; // return focus to barcode input
+ }
+
+ function send_renewal(params) {
+
+ params.noncat_type = params.noncat ? params.noncat_type : '';
+
+ // populate the grid row before we send the request so that the
+ // order of actions is maintained and so the user gets an
+ // immediate reaction to their barcode input action.
+ var row_item = {
+ index : $scope.renewals.length,
+ copy_barcode : params.copy_barcode,
+ noncat_type : params.noncat_type
+ };
+
+ $scope.renewals.unshift(row_item);
+ $scope.gridDataProvider.refresh();
+
+ var options = {check_barcode : $scope.strict_barcode};
+
+ egCirc.renew(params, options).then(
+ function(final_resp) {
+
+ row_item.evt = final_resp.evt;
+ angular.forEach(final_resp.data, function(val, key) {
+ row_item[key] = val;
+ });
+
+ if (row_item.mbts) {
+ var amt = Number(row_item.mbts.balance_owed());
+ if (amt != 0) {
+ $scope.billable_barcode = row_item.copy_barcode;
+ $scope.billable_amount = amt;
+ $scope.fine_total =
+ ($scope.fine_total * 100 + amt * 100) / 100;
+ }
+ }
+
+ if ($scope.trim_list && checkinSvc.checkins.length > 20)
+ checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+
+ },
+ function() {
+ // Circ was rejected somewhere along the way.
+ // Remove the copy from the grid since there was no action.
+ // note: since renewals are unshifted onto the array, the
+ // index value does not (generally) match the array position.
+ var pos = -1;
+ angular.forEach($scope.renewals, function(co, idx) {
+ if (co.index == row_item.index) pos = idx;
+ });
+ $scope.renewals.splice(pos, 1);
+ $scope.gridDataProvider.refresh();
+ }
+
+ )['finally'](function() {
+
+ // regardless of the outcome of the circ, remove the
+ // barcode from the pending list.
+ if (params.copy_barcode)
+ delete pending_barcodes[params.copy_barcode];
+ });
+ }
+
+ $scope.fetchLastCircPatron = function(items) {
+ var renewal = items[0];
+ if (!renewal || !renewal.acp) return;
+
+ egCirc.last_copy_circ(renewal.acp.id())
+ .then(function(circ) {
+
+ if (circ) {
+ // jump to the patron UI (separate app)
+ $window.location.href = $location
+ .path('/circ/patron/' + circ.usr() + '/checkout')
+ .absUrl();
+ return;
+ }
+
+ $scope.alert = {item_never_circed : renewal.acp.barcode()};
+ });
+ }
+
+ $scope.showMarkDamaged = function(items) {
+ var copy_ids = [];
+ angular.forEach(items, function(item) {
+ if (item.acp) copy_ids.push(item.acp.id());
+ });
+
+ if (copy_ids.length) {
+ egCirc.mark_damaged(copy_ids).then(function() {
+ // update grid items?
+ });
+ }
+ }
+
+ $scope.showLastFewCircs = function(items) {
+ if (items.length && (copy = items[0].acp)) {
+ var url = $location.path(
+ '/cat/item/' + copy.id() + '/circ_list').absUrl();
+ $window.open(url, '_blank').focus();
+ }
+ }
+
+ $scope.abortTransit = function(items) {
+ var transit_ids = [];
+ angular.forEach(items, function(item) {
+ if (item.transit) transit_ids.push(item.transit.id());
+ });
+
+ egCirc.abort_transits(transit_ids).then(function() {
+ // update grid items?
+ });
+ }
+
+ $scope.print_receipt = function() {
+ var print_data = {circulations : []}
+
+ if ($scope.renewals.length == 0) return $q.when();
+
+ angular.forEach($scope.renewals, function(renewal) {
+ if (renewal.circ) {
+ print_data.circulations.push({
+ circ : egCore.idl.toHash(renewal.circ),
+ copy : egCore.idl.toHash(renewal.acp),
+ title : egCore.idl.toHash(renewal.title),
+ author : egCore.idl.toHash(renewal.author)
+ });
+ }
+ });
+
+ return egCore.print.print({
+ context : 'default',
+ template : 'renew',
+ scope : print_data,
+ });
+ }
+}])
+
--- /dev/null
+/**
+ * Shared services for patron billing.
+ *
+ */
+
+angular.module('egCoreMod')
+
+.factory('egBilling',
+ ['$modal','$q','egCore',
+function($modal , $q , egCore) {
+
+ var service = {};
+
+ // fetch a fleshed money.billable_xact
+ service.fetchXact = function(xact_id) {
+ return egCore.pcrud.retrieve('mbt', xact_id, {
+ flesh : 5,
+ flesh_fields : {
+ mbt : ['summary','circulation','grocery','reservation'],
+ circ: ['target_copy'],
+ acp : ['call_number','location','status','age_protect'],
+ acn : ['record'],
+ bre : ['simple_record']
+ },
+ select : {bre : ['id']}}, // avoid MARC
+ {authoritative : true}
+ );
+ }
+
+ // apply a patron billing. If no xact is provided, a grocery xact is
+ // created.
+ service.billPatron = function(args, xact) {
+ // apply a billing to an existing transaction
+ if (xact) return service.createBilling(xact.id, args);
+
+ // create a new grocery xact, then apply a billing
+ return service.createGroceryXact(args)
+ .then(function(xact_id) {
+ return service.createBilling(xact_id, args);
+ });
+ }
+
+ // create a new grocery xact
+ service.createGroceryXact = function(args) {
+ var groc = new egCore.idl.mg();
+ groc.billing_location(egCore.auth.user().ws_ou());
+ groc.note(args.note);
+ groc.usr(args.patron_id);
+
+ // create the xact
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.money.grocery.create',
+ egCore.auth.token(), groc
+
+ // create the billing on the new xact
+ ).then(function(xact_id) {
+ if (evt = egCore.evt.parse(xact_id))
+ return alert(evt);
+ return xact_id;
+ });
+ }
+
+ // fetch the org-focused billing types
+ // Cache on egEnv
+ service.fetchBillingTypes = function() {
+ if (egCore.env.cbt)
+ return $q.when(egCore.env.cbt.list);
+
+ return egCore.pcrud.search('cbt',
+ { // first 100 are reserved for system-generated bills
+ id : {'>' : 100},
+ owner : egCore.org.ancestors(
+ egCore.auth.user().ws_ou(), true)
+ },
+ {}, {atomic : true}
+ ).then(function(list) {
+ egCore.env.absorbList(list, 'cbt');
+ return list;
+ });
+ }
+
+ // create a patron billing
+ service.createBilling = function(xact_id, args) {
+ var bill = new egCore.idl.mb();
+ bill.xact(xact_id);
+ bill.amount(args.amount);
+ bill.btype(args.billingType);
+ bill.billing_type(egCore.env.cbt.map[args.billingType].name());
+ bill.note(args.note);
+
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.money.billing.create',
+ egCore.auth.token(), bill
+
+ // check the billing response
+ ).then(function(bill_id) {
+ if (evt = egCore.evt.parse(bill_id)) {
+ alert(evt);
+ } else {
+ return bill_id;
+ }
+ });
+ }
+
+
+ // Show the billing dialog.
+ // Allows users to select amount, billing type, and note.
+ // args:
+ // xact OR xact_id : if null, creates a grocery xact
+ // patron OR patron_id
+ service.showBillDialog = function(args) {
+
+ return $modal.open({
+ templateUrl: './circ/share/t_bill_patron_dialog',
+ controller:
+ ['$scope','$modalInstance','$timeout','billingTypes','xact','patron',
+ function($scope , $modalInstance , $timeout , billingTypes , xact , patron) {
+ console.debug('billing patron ' + patron.id());
+ $scope.focus = true;
+ if (xact && xact._isfieldmapper)
+ xact = egCore.idl.toHash(xact);
+ $scope.xact = xact;
+ $scope.patron = patron;
+ $scope.billingTypes = billingTypes;
+ $scope.location = egCore.org.get(egCore.auth.user().ws_ou()),
+ $scope.billArgs = {
+ billingType : 101, // default to stock Misc. billing type
+ xact : xact,
+ patron_id : patron.id()
+ }
+ $scope.ok = function(args) { $modalInstance.close(args) }
+ $scope.cancel = function () { $modalInstance.dismiss() }
+ $scope.updateDefaultPrice = function() {
+ var type = billingTypes.filter(function(t) {
+ return t.id() == $scope.billArgs.billingType })[0];
+ if (type.default_price() && !$scope.billArgs.amount)
+ $scope.billArgs.amount = Number(type.default_price());
+ }
+ }],
+ resolve : {
+ // if we don't already have them, fetch the billing types
+ billingTypes : function() {
+ return service.fetchBillingTypes();
+ },
+
+ xact : function() {
+ if (args.xact) return $q.when(args.xact);
+ if (args.xact_id) return service.fetchXact(args.xact_id);
+ return $q.when();
+ },
+
+ patron : function() {
+ if (args.patron) return $q.when(args.patron);
+ return egCore.pcrud.retrieve('au', args.patron_id,
+ {flesh : 1, flesh_fields : {au : ['card']}});
+ }
+
+ }
+ }).result.then(
+ function(args) {
+ // send the billing to the server using the arguments
+ // provided in the billing dialog, then refresh
+ return service.billPatron(args, args.xact);
+ }
+ );
+ }
+
+ return service;
+}]);
+
+
+
+
--- /dev/null
+/**
+ * Checkin, checkout, and renew
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCirc',
+
+ ['$modal','$q','egCore','egAlertDialog','egConfirmDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog) {
+
+ var service = {
+ // auto-override these events after the first override
+ auto_override_checkout_events : {},
+ };
+
+ service.reset = function() {
+ service.auto_override_checkout_events = {};
+ }
+
+ // these events can be overridden by staff during checkout
+ service.checkout_overridable_events = [
+ 'PATRON_EXCEEDS_OVERDUE_COUNT',
+ 'PATRON_EXCEEDS_CHECKOUT_COUNT',
+ 'PATRON_EXCEEDS_FINES',
+ 'PATRON_BARRED',
+ 'CIRC_EXCEEDS_COPY_RANGE',
+ 'ITEM_DEPOSIT_REQUIRED',
+ 'ITEM_RENTAL_FEE_REQUIRED',
+ 'PATRON_EXCEEDS_LOST_COUNT',
+ 'COPY_CIRC_NOT_ALLOWED',
+ 'COPY_NOT_AVAILABLE',
+ 'COPY_IS_REFERENCE',
+ 'COPY_ALERT_MESSAGE',
+ 'ITEM_ON_HOLDS_SHELF'
+ ]
+
+ // after the first override of any of these events,
+ // auto-override them in subsequent calls.
+ service.checkout_auto_override_after_first = [
+ 'PATRON_EXCEEDS_OVERDUE_COUNT',
+ 'PATRON_BARRED',
+ 'PATRON_EXCEEDS_LOST_COUNT',
+ 'PATRON_EXCEEDS_CHECKOUT_COUNT',
+ 'PATRON_EXCEEDS_FINES'
+ ]
+
+
+ // overridable during renewal
+ service.renew_overridable_events = [
+ 'PATRON_EXCEEDS_OVERDUE_COUNT',
+ 'PATRON_EXCEEDS_LOST_COUNT',
+ 'PATRON_EXCEEDS_CHECKOUT_COUNT',
+ 'PATRON_EXCEEDS_FINES',
+ 'CIRC_EXCEEDS_COPY_RANGE',
+ 'ITEM_DEPOSIT_REQUIRED',
+ 'ITEM_RENTAL_FEE_REQUIRED',
+ 'ITEM_DEPOSIT_PAID',
+ 'COPY_CIRC_NOT_ALLOWED',
+ 'COPY_IS_REFERENCE',
+ 'COPY_ALERT_MESSAGE',
+ 'COPY_NEEDED_FOR_HOLD',
+ 'MAX_RENEWALS_REACHED',
+ 'CIRC_CLAIMS_RETURNED'
+ ];
+
+ // these checkin events do not produce alerts when
+ // options.suppress_alerts is in effect.
+ service.checkin_suppress_overrides = [
+ 'COPY_BAD_STATUS',
+ 'PATRON_BARRED',
+ 'PATRON_INACTIVE',
+ 'PATRON_ACCOUNT_EXPIRED',
+ 'ITEM_DEPOSIT_PAID',
+ 'CIRC_CLAIMS_RETURNED',
+ 'COPY_ALERT_MESSAGE',
+ 'COPY_STATUS_LOST',
+ 'COPY_STATUS_LONG_OVERDUE',
+ 'COPY_STATUS_MISSING',
+ 'PATRON_EXCEEDS_FINES'
+ ]
+
+ // these events can be overridden by staff during checkin
+ service.checkin_overridable_events =
+ service.checkin_suppress_overrides.concat([
+ 'TRANSIT_CHECKIN_INTERVAL_BLOCK'
+ ])
+
+ // Performs a checkout.
+ // Returns a promise resolved with the original params and options
+ // and the final checkout event (e.g. in the case of override).
+ // Rejected if the checkout cannot be completed.
+ //
+ // params : passed directly as arguments to the server API
+ // options : non-parameter controls. e.g. "override", "check_barcode"
+ service.checkout = function(params, options) {
+ if (!options) options = {};
+
+ console.debug('egCirc.checkout() : '
+ + js2JSON(params) + ' : ' + js2JSON(options));
+
+ var promise = options.check_barcode ?
+ service.test_barcode(params.copy_barcode) : $q.when();
+
+ // avoid re-check on override, etc.
+ delete options.check_barcode;
+
+ return promise.then(function() {
+
+ var method = 'open-ils.circ.checkout.full';
+ if (options.override) method += '.override';
+
+ return egCore.net.request(
+ 'open-ils.circ', method, egCore.auth.token(), params
+
+ ).then(function(evt) {
+
+ if (angular.isArray(evt)) evt = evt[0];
+
+ return service.flesh_response_data('checkout', evt, params, options)
+ .then(function() {
+ return service.handle_checkout_resp(evt, params, options);
+ })
+ .then(function(final_resp) {
+ return service.munge_resp_data(final_resp)
+ })
+ });
+ });
+ }
+
+ // Performs a renewal.
+ // Returns a promise resolved with the original params and options
+ // and the final checkout event (e.g. in the case of override)
+ // Rejected if the renewal cannot be completed.
+ service.renew = function(params, options) {
+ if (!options) options = {};
+
+ console.debug('egCirc.renew() : '
+ + js2JSON(params) + ' : ' + js2JSON(options));
+
+ var promise = options.check_barcode ?
+ service.test_barcode(params.copy_barcode) : $q.when();
+
+ // avoid re-check on override, etc.
+ delete options.check_barcode;
+
+ return promise.then(function() {
+
+ var method = 'open-ils.circ.renew';
+ if (options.override) method += '.override';
+
+ return egCore.net.request(
+ 'open-ils.circ', method, egCore.auth.token(), params
+
+ ).then(function(evt) {
+
+ if (angular.isArray(evt)) evt = evt[0];
+
+ return service.flesh_response_data(
+ 'renew', evt, params, options)
+ .then(function() {
+ return service.handle_renew_resp(evt, params, options);
+ })
+ .then(function(final_resp) {
+ return service.munge_resp_data(final_resp)
+ })
+ });
+ });
+ }
+
+ // Performs a checkin
+ // Returns a promise resolved with the original params and options,
+ // plus the final checkin event (e.g. in the case of override).
+ // Rejected if the checkin cannot be completed.
+ service.checkin = function(params, options) {
+ if (!options) options = {};
+
+ console.debug('egCirc.checkin() : '
+ + js2JSON(params) + ' : ' + js2JSON(options));
+
+ var promise = options.check_barcode ?
+ service.test_barcode(params.copy_barcode) : $q.when();
+
+ // avoid re-check on override, etc.
+ delete options.check_barcode;
+
+ return promise.then(function() {
+
+ var method = 'open-ils.circ.checkin';
+ if (options.override) method += '.override';
+
+ return egCore.net.request(
+ 'open-ils.circ', method, egCore.auth.token(), params
+
+ ).then(function(evt) {
+
+ if (angular.isArray(evt)) evt = evt[0];
+ return service.flesh_response_data(
+ 'checkin', evt, params, options)
+ .then(function() {
+ return service.handle_checkin_resp(evt, params, options);
+ })
+ .then(function(final_resp) {
+ return service.munge_resp_data(final_resp)
+ })
+ });
+ });
+ }
+
+ // provide consistent formatting of the final response data
+ service.munge_resp_data = function(final_resp) {
+ var data = final_resp.data = {};
+
+ if (!final_resp.evt) return;
+
+ var payload = final_resp.evt.payload;
+ if (!payload) return;
+
+ data.circ = payload.circ;
+ data.parent_circ = payload.parent_circ;
+ data.hold = payload.hold;
+ data.record = payload.record;
+ data.acp = payload.copy;
+ data.acn = payload.volume ? payload.volume : payload.copy.call_number();
+ data.au = payload.patron;
+ data.transit = payload.transit;
+ data.status = payload.status;
+ data.message = payload.message;
+ data.title = final_resp.evt.title;
+ data.author = final_resp.evt.author;
+ data.isbn = final_resp.evt.isbn;
+ data.route_to = final_resp.evt.route_to;
+
+ // for checkin, the mbts lives on the main circ
+ if (payload.circ && payload.circ.billable_transaction())
+ data.mbts = payload.circ.billable_transaction().summary();
+
+ // on renewals, the mbts lives on the parent circ
+ if (payload.parent_circ && payload.parent_circ.billable_transaction())
+ data.mbts = payload.parent_circ.billable_transaction().summary();
+
+ if (!data.route_to) {
+ if (data.transit) {
+ data.route_to = data.transit.dest().shortname();
+ } else if (data.acp) {
+ data.route_to = data.acp.location().name();
+ }
+ }
+
+ return final_resp;
+ }
+
+ service.handle_overridable_checkout_event = function(evt, params, options) {
+
+ if (options.override) {
+ // override attempt already made and failed.
+ // NOTE: I don't think we'll ever get here, since the
+ // override attempt should produce a perm failure...
+ console.debug('override failed: ' + evt.textcode);
+ return $q.reject();
+
+ }
+
+ if (service.auto_override_checkout_events[evt.textcode]) {
+ // user has already opted to override this type
+ // of event. Re-run the checkout w/ override.
+ options.override = true;
+ return service.checkout(params, options);
+ }
+
+ // Ask the user if they would like to override this event.
+ // Some events offer a stock override dialog, while others
+ // require additional context.
+
+ switch(evt.textcode) {
+ case 'COPY_NOT_AVAILABLE':
+ return service.copy_not_avail_dialog(evt, params, options);
+ case 'COPY_ALERT_MESSAGE':
+ return service.copy_alert_dialog(evt, params, options, 'checkout');
+ default:
+ return service.override_dialog(evt, params, options, 'checkout');
+ }
+ }
+
+ service.handle_overridable_renew_event = function(evt, params, options) {
+
+ if (options.override) {
+ // override attempt already made and failed.
+ // NOTE: I don't think we'll ever get here, since the
+ // override attempt should produce a perm failure...
+ console.debug('override failed: ' + evt.textcode);
+ return $q.reject();
+
+ }
+
+ // renewal auto-overrides are the same as checkout
+ if (service.auto_override_checkout_events[evt.textcode]) {
+ // user has already opted to override this type
+ // of event. Re-run the renew w/ override.
+ options.override = true;
+ return service.renew(params, options);
+ }
+
+ // Ask the user if they would like to override this event.
+ // Some events offer a stock override dialog, while others
+ // require additional context.
+
+ switch(evt.textcode) {
+ case 'COPY_ALERT_MESSAGE':
+ return service.copy_alert_dialog(evt, params, options, 'renew');
+ default:
+ return service.override_dialog(evt, params, options, 'renew');
+ }
+ }
+
+
+ service.handle_overridable_checkin_event = function(evt, params, options) {
+
+ if (options.override) {
+ // override attempt already made and failed.
+ // NOTE: I don't think we'll ever get here, since the
+ // override attempt should produce a perm failure...
+ console.debug('override failed: ' + evt.textcode);
+ return $q.reject();
+
+ }
+
+ if (options.suppress_checkin_popups
+ && service.checkin_suppress_overrides.indexOf(evt.textcode) > -1) {
+ // Event is suppressed. Re-run the checkin w/ override.
+ options.override = true;
+ return service.checkin(params, options);
+ }
+
+ // Ask the user if they would like to override this event.
+ // Some events offer a stock override dialog, while others
+ // require additional context.
+
+ switch(evt.textcode) {
+ case 'COPY_ALERT_MESSAGE':
+ return service.copy_alert_dialog(evt, params, options, 'checkin');
+ default:
+ return service.override_dialog(evt, params, options, 'checkin');
+ }
+ }
+
+
+ service.handle_renew_resp = function(evt, params, options) {
+
+ var final_resp = {evt : evt, params : params, options : options};
+
+ // track the barcode regardless of whether it refers to a copy
+ evt.copy_barcode = params.copy_barcode;
+
+ // Overridable Events
+ if (service.renew_overridable_events.indexOf(evt.textcode) > -1)
+ return service.handle_overridable_renew_event(evt, params, options);
+
+ // Other events
+ switch (evt.textcode) {
+ case 'SUCCESS':
+ return $q.when(final_resp);
+
+ case 'COPY_IN_TRANSIT':
+ case 'PATRON_CARD_INACTIVE':
+ case 'PATRON_INACTIVE':
+ case 'PATRON_ACCOUNT_EXPIRED':
+ case 'CIRC_CLAIMS_RETURNED':
+ return service.exit_alert(
+ egCore.strings[evt.textcode],
+ {barcode : params.copy_barcode}
+ );
+
+ case 'PERM_FAILURE':
+ return service.exit_alert(
+ egCore.strings[evt.textcode],
+ {permission : evt.ilsperm}
+ );
+
+ default:
+ return service.exit_alert(
+ egCore.strings.CHECKOUT_FAILED_GENERIC, {
+ barcode : params.copy_barcode,
+ textcode : evt.textcode,
+ desc : evt.desc
+ }
+ );
+ }
+ }
+
+
+ service.handle_checkout_resp = function(evt, params, options) {
+
+ var final_resp = {evt : evt, params : params, options : options};
+
+ // track the barcode regardless of whether it refers to a copy
+ evt.copy_barcode = params.copy_barcode;
+
+ // Overridable Events
+ if (service.checkout_overridable_events.indexOf(evt.textcode) > -1)
+ return service.handle_overridable_checkout_event(evt, params, options);
+
+ // Other events
+ switch (evt.textcode) {
+ case 'SUCCESS':
+ return $q.when(final_resp);
+
+ case 'ITEM_NOT_CATALOGED':
+ return service.precat_dialog(params, options);
+
+ case 'OPEN_CIRCULATION_EXISTS':
+ return service.circ_exists_dialog(evt, params, options);
+
+ case 'COPY_IN_TRANSIT':
+ return service.copy_in_transit_dialog(evt, params, options);
+
+ case 'PATRON_CARD_INACTIVE':
+ case 'PATRON_INACTIVE':
+ case 'PATRON_ACCOUNT_EXPIRED':
+ case 'CIRC_CLAIMS_RETURNED':
+ return service.exit_alert(
+ egCore.strings[evt.textcode],
+ {barcode : params.copy_barcode}
+ );
+
+ case 'PERM_FAILURE':
+ return service.exit_alert(
+ egCore.strings[evt.textcode],
+ {permission : evt.ilsperm}
+ );
+
+ default:
+ return service.exit_alert(
+ egCore.strings.CHECKOUT_FAILED_GENERIC, {
+ barcode : params.copy_barcode,
+ textcode : evt.textcode,
+ desc : evt.desc
+ }
+ );
+ }
+ }
+
+ // returns a promise resolved with the list of circ mods
+ service.get_circ_mods = function() {
+ if (egCore.env.ccm)
+ return $q.when(egCore.env.ccm.list);
+
+ return egCore.pcrud.retrieveAll('ccm', null, {atomic : true})
+ .then(function(list) {
+ egCore.env.absorbList(list, 'ccm');
+ return list;
+ });
+ };
+
+ // returns a promise resolved with the list of noncat types
+ service.get_noncat_types = function() {
+ if (egCore.env.cnct)
+ return $q.when(egCore.env.cnct.list);
+
+ return egCore.pcrud.search('cnct',
+ {owning_lib :
+ egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+ null, {atomic : true}
+ ).then(function(list) {
+ egCore.env.absorbList(list, 'cnct');
+ return list;
+ });
+ }
+
+ service.get_staff_penalty_types = function() {
+ if (egCore.env.csp)
+ return $q.when(egCore.env.csp.list);
+ return egCore.pcrud.search(
+ // id <= 100 are reserved for system use
+ 'csp', {id : {'>': 100}}, {}, {atomic : true})
+ .then(function(penalties) {
+ return egCore.env.absorbList(penalties, 'csp').list;
+ });
+ }
+
+ // ideally all of these data should be returned with the response,
+ // but until then, grab what we need.
+ service.flesh_response_data = function(action, evt, params, options) {
+ var promises = [];
+ var payload;
+ if (!evt || !(payload = evt.payload)) return $q.when();
+
+ promises.push(service.flesh_copy_location(payload.copy));
+ if (payload.copy) {
+ promises.push(
+ service.flesh_copy_status(payload.copy)
+
+ .then(function() {
+ // copy is in transit, but no transit was delivered
+ // in the payload. Do this here instead of below to
+ // ensure consistent copy status fleshiness
+ if (!payload.transit && payload.copy.status().id() == 6) { // in-transit
+ return service.find_copy_transit(evt, params, options)
+ .then(function(trans) {
+ if (trans) {
+ trans.source(egCore.org.get(trans.source()));
+ trans.dest(egCore.org.get(trans.dest()));
+ payload.transit = trans;
+ }
+ })
+ }
+ })
+ );
+ }
+
+ // local flesh transit
+ if (transit = payload.transit) {
+ transit.source(egCore.org.get(transit.source()));
+ transit.dest(egCore.org.get(transit.dest()));
+ }
+
+ // TODO: renewal responses should include the patron
+ if (!payload.patron && payload.circ) {
+ promises.push(
+ egCore.pcrud.retrieve('au', payload.circ.usr())
+ .then(function(user) {payload.patron = user})
+ );
+ }
+
+ // extract precat values
+ evt.title = payload.record ? payload.record.title() :
+ (payload.copy ? payload.copy.dummy_title() : null);
+
+ evt.author = payload.record ? payload.record.author() :
+ (payload.copy ? payload.copy.dummy_author() : null);
+
+ evt.isbn = payload.record ? payload.record.isbn() :
+ (payload.copy ? payload.copy.dummy_isbn() : null);
+
+ return $q.all(promises);
+ }
+
+ // fetches the full list of copy statuses
+ service.flesh_copy_status = function(copy) {
+ if (!copy) return $q.when();
+ if (egCore.env.ccs)
+ return $q.when(copy.status(egCore.env.ccs.map[copy.status()]));
+ return egCore.pcrud.retrieveAll('ccs', {}, {atomic : true}).then(
+ function(list) {
+ egCore.env.absorbList(list, 'ccs');
+ copy.status(egCore.env.ccs.map[copy.status()]);
+ }
+ );
+ }
+
+ // there may be *many* copy locations and we may be handling items
+ // for other locations. Fetch copy locations as-needed and cache.
+ service.flesh_copy_location = function(copy) {
+ if (!copy) return $q.when();
+ if (angular.isObject(copy.location())) return $q.when(copy);
+ if (egCore.env.acpl) {
+ if (egCore.env.acpl.map[copy.location()]) {
+ copy.location(egCore.env.acpl.map[copy.location()]);
+ return $q.when(copy);
+ }
+ }
+ return egCore.pcrud.retrieve('acpl', copy.location())
+ .then(function(loc) {
+ egCore.env.absorbList([loc], 'acpl'); // append to cache
+ copy.location(loc);
+ return copy;
+ });
+ }
+
+
+ // fetch org unit addresses as needed.
+ service.get_org_addr = function(org_id, addr_type) {
+ var org = egCore.org.get(org_id);
+ var addr_id = org[addr_type]();
+
+ if (egCore.env.aoa && egCore.env.aoa.map[addr_id])
+ return $q.when(egCore.env.aoa.map[addr_id]);
+
+ return egCore.pcrud.retrieve('aoa', addr_id).then(function(addr) {
+ egCore.env.absorbList([addr], 'aoa');
+ return egCore.env.aoa.map[addr_id];
+ });
+ }
+
+ service.exit_alert = function(msg, scope) {
+ return egAlertDialog.open(msg, scope).result.then(
+ function() {return $q.reject()});
+ }
+
+ // opens a dialog asking the user if they would like to override
+ // the returned event.
+ service.override_dialog = function(evt, params, options, action) {
+ return $modal.open({
+ templateUrl: './circ/share/t_event_override_dialog',
+ controller:
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.evt = evt;
+ $scope.auto_override =
+ service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1;
+ $scope.copy_barcode = params.copy_barcode; // may be null
+ $scope.ok = function() { $modalInstance.close() }
+ $scope.cancel = function ($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }]
+ }).result.then(
+ function() {
+ options.override = true;
+
+ if (action == 'checkin') {
+ return service.checkin(params, options);
+ }
+
+ // checkout/renew support override-after-first
+ if (service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1)
+ service.auto_override_checkout_events[evt.textcode] = true;
+
+ return service[action](params, options);
+ }
+ );
+ }
+
+ service.copy_not_avail_dialog = function(evt, params, options) {
+ return $modal.open({
+ templateUrl: './circ/share/t_copy_not_avail_dialog',
+ controller:
+ ['$scope','$modalInstance','copyStatus',
+ function($scope , $modalInstance , copyStatus) {
+ $scope.copyStatus = copyStatus;
+ $scope.ok = function() {$modalInstance.close()}
+ $scope.cancel = function() {$modalInstance.dismiss()}
+ }],
+ resolve : {
+ copyStatus : function() {
+ return egCore.pcrud.retrieve(
+ 'ccs', evt.payload.status());
+ }
+ }
+ }).result.then(
+ function() {
+ options.override = true;
+ return service.checkout(params, options);
+ }
+ );
+ }
+
+ // Opens a dialog allowing the user to fill in the desired non-cat count.
+ // Unlike other dialogs, which kickoff circ actions internally
+ // as a result of events, this dialog does not kick off any circ
+ // actions. It just collects the count and and resolves the promise.
+ //
+ // This assumes the caller has already handled the noncat-type
+ // selection and just needs to collect the count info.
+ service.noncat_dialog = function(params, options) {
+ var noncatMax = 99; // hard-coded max
+
+ // the caller should presumably have fetched the noncat_types via
+ // our API already, but fetch them again (from cache) to be safe.
+ return service.get_noncat_types().then(function() {
+
+ params.noncat = true;
+ var type = egCore.env.cnct.map[params.noncat_type];
+
+ return $modal.open({
+ templateUrl: './circ/share/t_noncat_dialog',
+ controller:
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.focusMe = true;
+ $scope.type = type;
+ $scope.count = 1;
+ $scope.noncatMax = noncatMax;
+ $scope.ok = function(count) { $modalInstance.close(count) }
+ $scope.cancel = function ($event) {
+ $modalInstance.dismiss()
+ $event.preventDefault();
+ }
+ }],
+ }).result.then(
+ function(count) {
+ if (count && count > 0 && count <= noncatMax) {
+ // NOTE: in Chrome, form validation ensure a valid number
+ params.noncat_count = count;
+ return $q.when(params);
+ } else {
+ return $q.reject();
+ }
+ }
+ );
+ });
+ }
+
+ // Opens a dialog allowing the user to fill in pre-cat copy info.
+ service.precat_dialog = function(params, options) {
+
+ return $modal.open({
+ templateUrl: './circ/share/t_precat_dialog',
+ controller:
+ ['$scope', '$modalInstance', 'circMods',
+ function($scope, $modalInstance, circMods) {
+ $scope.focusMe = true;
+ $scope.precatArgs = {
+ copy_barcode : params.copy_barcode,
+ circ_modifier : circMods.length ? circMods[0].code() : null
+ };
+ $scope.circModifiers = circMods;
+ $scope.ok = function(args) { $modalInstance.close(args) }
+ $scope.cancel = function () { $modalInstance.dismiss() }
+ }],
+ resolve : {
+ circMods : function() {
+ return service.get_circ_mods();
+ }
+ }
+ }).result.then(
+ function(args) {
+ if (!args || !args.dummy_title) return $q.reject();
+ angular.forEach(args, function(val, key) {params[key] = val});
+ params.precat = true;
+ return service.checkout(params, options);
+ }
+ );
+ }
+
+ // find the open transit for the given copy barcode; flesh the org
+ // units locally.
+ service.find_copy_transit = function(evt, params, options) {
+
+ if (evt && evt.payload && evt.payload.transit)
+ return $q.when(evt.payload.transit);
+
+ return egCore.pcrud.search('atc',
+ { dest_recv_time : null},
+ { flesh : 1,
+ flesh_fields : {atc : ['target_copy']},
+ join : {
+ acp : {
+ filter : {
+ barcode : params.copy_barcode,
+ deleted : 'f'
+ }
+ }
+ },
+ limit : 1,
+ order_by : {atc : 'source_send_time desc'},
+ }
+ ).then(function(transit) {
+ transit.source(egCore.org.get(transit.source()));
+ transit.dest(egCore.org.get(transit.dest()));
+ return transit;
+ });
+ }
+
+ service.copy_in_transit_dialog = function(evt, params, options) {
+ return $modal.open({
+ templateUrl: './circ/share/t_copy_in_transit_dialog',
+ controller:
+ ['$scope','$modalInstance','transit',
+ function($scope , $modalInstance , transit) {
+ $scope.transit = transit;
+ $scope.ok = function() { $modalInstance.close(transit) }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }],
+ resolve : {
+ // fetch the conflicting open transit w/ fleshed copy
+ transit : function() {
+ return service.find_copy_transit(evt, params, options);
+ }
+ }
+ }).result.then(
+ function(transit) {
+ // user chose to abort the transit then checkout
+ return service.abort_transit(transit.id())
+ .then(function() {
+ return service.checkout(params, options);
+ });
+ }
+ );
+ }
+
+ service.abort_transit = function(transit_id) {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.transit.abort',
+ egCore.auth.token(), {transitid : transit_id}
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ alert(evt);
+ return $q.reject();
+ }
+ return $q.when();
+ });
+ }
+
+ service.last_copy_circ = function(copy_id) {
+ return egCore.pcrud.search('circ',
+ {target_copy : copy_id},
+ {order_by : {circ : 'xact_start desc' }, limit : 1}
+ );
+ }
+
+ service.circ_exists_dialog = function(evt, params, options) {
+
+ var openCirc = evt.payload.old_circ;
+ var sameUser = openCirc.usr() == params.patron_id;
+
+ return $modal.open({
+ templateUrl: './circ/share/t_circ_exists_dialog',
+ controller:
+ ['$scope','$modalInstance',
+ function($scope , $modalInstance) {
+ $scope.circDate = openCirc.xact_start();
+ $scope.sameUser = sameUser;
+ $scope.ok = function() { $modalInstance.close() }
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault(); // form, avoid calling ok();
+ }
+ }]
+ }).result.then(
+ function() {
+
+ return service.checkin(
+ {barcode : params.copy_barcode, noop : true}
+ ).then(function(checkin_resp) {
+ if (checkin_resp.evt.textcode == 'SUCCESS') {
+ return service.checkout(params, options);
+ } else {
+ alert(egCore.evt.parse(evt));
+ return $q.reject();
+ }
+ });
+ }
+ );
+ }
+
+ service.batch_backdate = function(circ_ids, backdate) {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.post_checkin_backdate.batch',
+ egCore.auth.token(), circ_ids, backdate);
+ }
+
+ service.backdate_dialog = function(circ_ids) {
+ return $modal.open({
+ templateUrl: './circ/share/t_backdate_dialog',
+ controller:
+ ['$scope','$modalInstance',
+ function($scope , $modalInstance) {
+
+ var today = new Date();
+ $scope.dialog = {
+ num_circs : circ_ids.length,
+ num_processed : 0,
+ backdate : today
+ }
+
+ $scope.$watch('dialog.backdate', function(newval) {
+ if (newval && newval > today)
+ $scope.dialog.backdate = today;
+ });
+
+
+ $scope.cancel = function() {
+ $modalInstance.dismiss();
+ }
+
+ $scope.ok = function() {
+
+ var bd = $scope.dialog.backdate.toISOString().replace(/T.*/,'');
+ service.batch_backdate(circ_ids, bd)
+ .then(
+ function() { // on complete
+ $modalInstance.close({backdate : bd});
+ },
+ null,
+ function(resp) { // on response
+ console.debug('backdate returned ' + resp);
+ if (resp == '1') {
+ $scope.num_processed++;
+ } else {
+ console.error(egCore.evt.parse(resp));
+ }
+ }
+ );
+ }
+ }]
+ }).result;
+ }
+
+ service.mark_claims_returned = function(barcode, date, override) {
+
+ var method = 'open-ils.circ.circulation.set_claims_returned';
+ if (override) method += '.override';
+
+ console.debug('claims returned ' + method);
+
+ return egCore.net.request(
+ 'open-ils.circ', method, egCore.auth.token(),
+ {barcode : barcode, backdate : date})
+
+ .then(function(resp) {
+
+ if (resp == 1) { // success
+ console.debug('claims returned succeeded for ' + barcode);
+ return barcode;
+
+ } else if (evt = egCore.evt.parse(resp)) {
+ console.debug('claims returned failed: ' + evt.toString());
+
+ if (evt.textcode == 'PATRON_EXCEEDS_CLAIMS_RETURN_COUNT') {
+ // TODO check perms before offering override option?
+
+ if (override) return;// just to be safe
+
+ return egConfirmDialog.open(
+ egCore.strings.TOO_MANY_CLAIMS_RETURNED, '', {}
+ ).result.then(function() {
+ return service.mark_claims_returned(barcode, date, true);
+ });
+ }
+
+ if (evt.textcode == 'PERM_FAILURE') {
+ console.error('claims returned permission denied')
+ // TODO: auth override dialog?
+ }
+ }
+ });
+ }
+
+ service.mark_claims_returned_dialog = function(copy_barcodes) {
+ if (!copy_barcodes.length) return;
+
+ return $modal.open({
+ templateUrl: './circ/share/t_mark_claims_returned_dialog',
+ controller:
+ ['$scope','$modalInstance',
+ function($scope , $modalInstance) {
+
+ var today = new Date();
+ $scope.args = {
+ barcodes : copy_barcodes,
+ date : today
+ };
+
+ $scope.$watch('args.date', function(newval) {
+ if (newval && newval > today)
+ $scope.args.backdate = today;
+ });
+
+ $scope.cancel = function() {$modalInstance.dismiss()}
+ $scope.ok = function() {
+
+ var date = $scope.args.date.toISOString().replace(/T.*/,'');
+
+ var deferred = $q.defer();
+
+ // serialize the action on each barcode so that the
+ // caller will never see multiple alerts at the same time.
+ function mark_one() {
+ var bc = copy_barcodes.pop();
+ if (!bc) {
+ deferred.resolve();
+ $modalInstance.close();
+ return;
+ }
+
+ // finally -> continue even when one fails
+ service.mark_claims_returned(bc, date)
+ .finally(function(barcode) {
+ if (barcode) deferred.notify(barcode);
+ mark_one();
+ });
+ }
+ mark_one(); // kick it off
+ return deferred.promise;
+ }
+ }]
+ }).result;
+ }
+
+ // serially checks in each barcode with claims_never_checked_out set
+ // returns promise, notified on each barcode, resolved after all
+ // checkins are complete.
+ service.mark_claims_never_checked_out = function(barcodes) {
+ if (!barcodes.length) return;
+
+ var deferred = $q.defer();
+ egConfirmDialog.open(
+ egCore.strings.MARK_NEVER_CHECKED_OUT, '', {barcodes : barcodes}
+
+ ).result.then(function() {
+ function mark_one() {
+ var bc = barcodes.pop();
+
+ if (!bc) { // all done
+ deferred.resolve();
+ return;
+ }
+
+ service.checkin(
+ {claims_never_checked_out : true, copy_barcode : bc})
+ .finally(function() {
+ deferred.notify(bc);
+ mark_one();
+ })
+ }
+ mark_one();
+ });
+
+ return deferred.promise;
+ }
+
+ service.mark_damaged = function(copy_ids) {
+ return egConfirmDialog.open(
+ egCore.strings.MARK_DAMAGED_CONFIRM, '',
+ { num_items : copy_ids.length,
+ ok : function() {},
+ cancel : function() {}
+ }
+
+ ).result.then(function() {
+ var promises = [];
+ angular.forEach(copy_ids, function(copy_id) {
+ promises.push(
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.mark_item_damaged',
+ egCore.auth.token(), copy_id
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.error('mark damaged failed: ' + evt);
+ }
+ })
+ );
+ });
+
+ return $q.all(promises);
+ });
+ }
+
+ service.mark_missing = function(copy_ids) {
+ return egConfirmDialog.open(
+ egCore.strings.MARK_MISSING_CONFIRM, '',
+ { num_items : copy_ids.length,
+ ok : function() {},
+ cancel : function() {}
+ }
+ ).result.then(function() {
+ var promises = [];
+ angular.forEach(copy_ids, function(copy_id) {
+ promises.push(
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.mark_item_missing',
+ egCore.auth.token(), copy_id
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.error('mark missing failed: ' + evt);
+ }
+ })
+ );
+ });
+
+ return $q.all(promises);
+ });
+ }
+
+
+
+ // Mark circulations as lost via copy barcode. As each item is
+ // processed, the returned promise is notified of the barcode.
+ // No confirmation dialog is presented.
+ service.mark_lost = function(copy_barcodes) {
+ var deferred = $q.defer();
+ var promises = [];
+
+ angular.forEach(copy_barcodes, function(barcode) {
+ promises.push(
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.circulation.set_lost',
+ egCore.auth.token(), {barcode : barcode}
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.error("Mark lost failed: " + evt.toString());
+ return;
+ }
+ // inform the caller as each item is processed
+ deferred.notify(barcode);
+ })
+ );
+ });
+
+ $q.all(promises).then(function() {deferred.resolve()});
+ return deferred.promise;
+ }
+
+ service.abort_transits = function(transit_ids) {
+ return egConfirmDialog.open(
+ egCore.strings.ABORT_TRANSIT_CONFIRM, '',
+ { num_transits : transit_ids.length,
+ ok : function() {},
+ cancel : function() {}
+ }
+
+ ).result.then(function() {
+ var promises = [];
+ angular.forEach(transit_ids, function(transit_id) {
+ promises.push(
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.transit.abort',
+ egCore.auth.token(), {transitid : transit_id}
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.error('abort transit failed: ' + evt);
+ }
+ })
+ );
+ });
+
+ return $q.all(promises);
+ });
+ }
+
+
+
+ // alert when copy location alert_message is set.
+ // This does not affect processing, it only produces a click-through
+ service.handle_checkin_loc_alert = function(evt, params, options) {
+
+ var copy = evt && evt.payload ? evt.payload.copy : null;
+
+ if (copy && !options.suppress_checkin_popups
+ && copy.location().checkin_alert() == 't') {
+
+ return egAlertDialog.open(
+ egCore.strings.LOCATION_ALERT_MSG, {copy : copy}).result;
+ }
+
+ return $q.when();
+ }
+
+ service.handle_checkin_resp = function(evt, params, options) {
+
+ var final_resp = {evt : evt, params : params, options : options};
+
+ var copy, hold, transit;
+ if (evt.payload) {
+ copy = evt.payload.copy;
+ hold = evt.payload.hold;
+ transit = evt.payload.transit;
+ }
+
+ // track the barcode regardless of whether it's valid
+ evt.copy_barcode = params.copy_barcode;
+
+ console.debug('checkin event ' + evt.textcode);
+
+ if (service.checkin_overridable_events.indexOf(evt.textcode) > -1)
+ return service.handle_overridable_checkin_event(evt, params, options);
+
+ switch (evt.textcode) {
+
+ case 'SUCCESS':
+ case 'NO_CHANGE':
+
+ switch(Number(copy.status().id())) {
+
+ case 0: /* AVAILABLE */
+ case 4: /* MISSING */
+ case 7: /* RESHELVING */
+
+ // see if the copy location requires an alert
+ return service.handle_checkin_loc_alert(evt, params, options)
+ .then(function() {return final_resp});
+
+ case 8: /* ON HOLDS SHELF */
+
+
+ if (hold) {
+
+ if (hold.pickup_lib() == egCore.auth.user().ws_ou()) {
+ // inform user if the item is on the local holds shelf
+
+ evt.route_to = egCore.strings.ROUTE_TO_HOLDS_SHELF;
+ return service.route_dialog(
+ './circ/share/t_hold_shelf_dialog',
+ evt, params, options
+ ).then(function() { return final_resp });
+
+ } else {
+ // normally, if the hold was on the shelf at a
+ // different location, it would be put into
+ // transit, resulting in a ROUTE_ITEM event.
+ return $q.when(final_resp);
+ }
+ } else {
+
+ console.error('checkin: item on holds shelf, '
+ + 'but hold info not returned from checkin');
+ return $q.when(final_resp);
+ }
+
+ case 11: /* CATALOGING */
+ evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+ return $q.when(final_resp);
+
+ case 15: /* ON_RESERVATION_SHELF */
+ // TODO: show booking reservation dialog
+ return $q.when(final_resp);
+
+ default:
+ console.error('Unhandled checkin copy status: '
+ + copy.status().id() + ' : ' + copy.status().name());
+ return $q.when(final_resp);
+ }
+
+ case 'ROUTE_ITEM':
+ return service.route_dialog(
+ './circ/share/t_transit_dialog',
+ evt, params, options
+ ).then(function() { return final_resp });
+
+ case 'ASSET_COPY_NOT_FOUND':
+ return egAlertDialog.open(
+ egCore.strings.UNCAT_ALERT_DIALOG, params)
+ .result.then(function() {return final_resp});
+
+ case 'ITEM_NOT_CATALOGED':
+ evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+ if (options.no_precat_alert)
+ return $q.when(final_resp);
+ return egAlertDialog.open(
+ egCore.strings.PRECAT_CHECKIN_MSG, params)
+ .result.then(function() {return final_resp});
+
+ default:
+ console.warn('unhandled checkin response : ' + evt.textcode);
+ return $q.when(final_resp);
+ }
+ }
+
+ // collect transit, address, and hold info that's not already
+ // included in responses.
+ service.collect_route_data = function(tmpl, evt, params, options) {
+ var promises = [];
+ var data = {};
+
+ if (evt.org && !tmpl.match(/hold_shelf/)) {
+ promises.push(
+ service.get_org_addr(evt.org, 'holds_address')
+ .then(function(addr) { data.address = addr })
+ );
+ }
+
+ if (evt.payload.hold) {
+ promises.push(
+ egCore.pcrud.retrieve('au',
+ evt.payload.hold.usr(), {
+ flesh : 1,
+ flesh_fields : {'au' : ['card']}
+ }
+ ).then(function(patron) {data.patron = patron})
+ );
+ }
+
+ if (!tmpl.match(/hold_shelf/)) {
+ promises.push(
+ service.find_copy_transit(evt, params, options)
+ .then(function(trans) {data.transit = trans})
+ );
+ }
+
+ return $q.all(promises).then(function() { return data });
+ }
+
+ service.route_dialog = function(tmpl, evt, params, options) {
+
+ return service.collect_route_data(tmpl, evt, params, options)
+ .then(function(data) {
+
+ // All actions flow from the print data
+
+ var print_context = {
+ copy : egCore.idl.toHash(evt.payload.copy),
+ title : evt.title,
+ author : evt.author
+ }
+
+ if (data.transit) {
+ // route_dialog includes the "route to holds shelf"
+ // dialog, which has no transit
+ print_context.transit = egCore.idl.toHash(data.transit);
+ print_context.dest_address = egCore.idl.toHash(data.address);
+ print_context.dest_location =
+ egCore.idl.toHash(egCore.org.get(data.transit.dest()));
+ }
+
+ if (data.patron) {
+ print_context.hold = egCore.idl.toHash(evt.payload.hold);
+ print_context.patron = egCore.idl.toHash(data.patron);
+ }
+
+ function print_transit() {
+ var template = data.transit ?
+ (data.patron ? 'hold_transit_slip' : 'transit_slip') :
+ 'hold_shelf_slip';
+
+ return egCore.print.print({
+ context : 'default',
+ template : template,
+ scope : print_context
+ });
+ }
+
+ // when auto-print is on, skip the dialog and go straight
+ // to printing.
+ if (options.auto_print_holds_transits)
+ return print_transit();
+
+ return $modal.open({
+ templateUrl: tmpl,
+ controller: [
+ '$scope','$modalInstance',
+ function($scope , $modalInstance) {
+
+ $scope.today = new Date();
+
+ // copy the print scope into the dialog scope
+ angular.forEach(print_context, function(val, key) {
+ $scope[key] = val;
+ });
+
+ $scope.ok = function() {$modalInstance.close()}
+
+ $scope.print = function() {
+ $modalInstance.close();
+ print_transit();
+ }
+ }]
+
+ }).result;
+ });
+ }
+
+ // action == what action to take if the user confirms the alert
+ service.copy_alert_dialog = function(evt, params, options, action) {
+ return egConfirmDialog.open(
+ egCore.strings.COPY_ALERT_MSG_DIALOG_TITLE,
+ evt.payload, // payload == alert message text
+ { copy_barcode : params.copy_barcode,
+ ok : function() {},
+ cancel : function() {}
+ }
+ ).result.then(function() {
+ options.override = true;
+ return service[action](params, options);
+ });
+ }
+
+ // check the barcode. If it's no good, show the warning dialog
+ // Resolves on success, rejected on error
+ service.test_barcode = function(bc) {
+
+ var ok = service.check_barcode(bc);
+ if (ok) return $q.when();
+
+ return $modal.open({
+ templateUrl: './circ/share/t_bad_barcode_dialog',
+ controller:
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.barcode = bc;
+ $scope.ok = function() { $modalInstance.close() }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }]
+ }).result;
+ }
+
+ // check() and checkdigit() copied directly
+ // from chrome/content/util/barcode.js
+
+ service.check_barcode = function(bc) {
+ if (bc != Number(bc)) return false;
+ bc = bc.toString();
+ // "16.00" == Number("16.00"), but the . is bad.
+ // Throw out any barcode that isn't just digits
+ if (bc.search(/\D/) != -1) return false;
+ var last_digit = bc.substr(bc.length-1);
+ var stripped_barcode = bc.substr(0,bc.length-1);
+ return service.barcode_checkdigit(stripped_barcode).toString() == last_digit;
+ }
+
+ service.barcode_checkdigit = function(bc) {
+ var reverse_barcode = bc.toString().split('').reverse();
+ var check_sum = 0; var multiplier = 2;
+ for (var i = 0; i < reverse_barcode.length; i++) {
+ var digit = reverse_barcode[i];
+ var product = digit * multiplier; product = product.toString();
+ var temp_sum = 0;
+ for (var j = 0; j < product.length; j++) {
+ temp_sum += Number( product[j] );
+ }
+ check_sum += Number( temp_sum );
+ multiplier = ( multiplier == 2 ? 1 : 2 );
+ }
+ check_sum = check_sum.toString();
+ var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10;
+ var check_digit = next_multiple_of_10 - Number(check_sum);
+ if (check_digit == 10) check_digit = 0;
+ return check_digit;
+ }
+
+ service.create_penalty = function(user_id) {
+ return $modal.open({
+ templateUrl: './circ/share/t_new_message_dialog',
+ controller:
+ ['$scope','$modalInstance','staffPenalties',
+ function($scope , $modalInstance , staffPenalties) {
+ $scope.focusNote = true;
+ $scope.penalties = staffPenalties;
+ $scope.args = {penalty : 21}; // default to Note
+ $scope.setPenalty = function(id) {
+ args.penalty = id;
+ }
+ $scope.ok = function(count) { $modalInstance.close($scope.args) }
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }],
+ resolve : { staffPenalties : service.get_staff_penalty_types }
+ }).result.then(
+ function(args) {
+ var pen = new egCore.idl.ausp();
+ pen.usr(user_id);
+ pen.org_unit(egCore.auth.user().ws_ou());
+ pen.note(args.note);
+ pen.standing_penalty(args.penalty);
+ pen.staff(egCore.auth.user().id());
+ pen.set_date('now');
+ return egCore.pcrud.create(pen);
+ }
+ );
+ }
+
+ // assumes, for now anyway, penalty type is fleshed onto usr_penalty.
+ service.edit_penalty = function(usr_penalty) {
+ return $modal.open({
+ templateUrl: './circ/share/t_new_message_dialog',
+ controller:
+ ['$scope','$modalInstance','staffPenalties',
+ function($scope , $modalInstance , staffPenalties) {
+ $scope.focusNote = true;
+ $scope.penalties = staffPenalties;
+ $scope.args = {
+ penalty : usr_penalty.standing_penalty().id(),
+ note : usr_penalty.note()
+ }
+ $scope.setPenalty = function(id) { args.penalty = id; }
+ $scope.ok = function(count) { $modalInstance.close($scope.args) }
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }],
+ resolve : { staffPenalties : service.get_staff_penalty_types }
+ }).result.then(
+ function(args) {
+ usr_penalty.note(args.note);
+ usr_penalty.standing_penalty(args.penalty);
+ return egCore.pcrud.update(usr_penalty);
+ }
+ );
+ }
+
+ return service;
+
+}]);
+
+
--- /dev/null
+/**
+ * Holds, yo
+ */
+
+angular.module('egCoreMod')
+
+.factory('egHolds',
+
+ ['$modal','$q','egCore','egAlertDialog','egConfirmDialog','egAlertDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog , egAlertDialog) {
+
+ var service = {};
+
+ service.fetch_holds = function(hold_ids) {
+ var deferred = $q.defer();
+
+ // FIXME: large batches using .authoritative result in many
+ // stranded cstore backends on the server. Needs investigation.
+ // For now, collect holds in a series of small batches.
+ // Fetch them serially both to avoid the above problem and
+ // to maintain order.
+ var batch_size = 5;
+ var index = 0;
+
+ function one_batch() {
+ var ids = hold_ids.slice(index, index + batch_size)
+ .filter(function(id) {return Boolean(id)}) // avoid nulls
+
+ console.debug('egHolds.fetch_holds => ' + ids);
+ index += batch_size;
+
+ if (!ids.length) {
+ deferred.resolve();
+ return;
+ }
+
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.details.batch.retrieve.authoritative',
+ egCore.auth.token(), ids
+
+ ).then(
+ one_batch, // kick off the next batch
+ null,
+ function(hold_data) {
+ var hold = hold_data.hold;
+ hold_data.id = hold.id();
+ service.local_flesh(hold_data);
+ deferred.notify(hold_data);
+ }
+ );
+ }
+
+ one_batch(); // kick it off
+ return deferred.promise;
+ }
+
+
+ service.cancel_holds = function(hold_ids) {
+
+ return $modal.open({
+ templateUrl : './circ/share/t_cancel_hold_dialog',
+ controller :
+ ['$scope', '$modalInstance', 'cancel_reasons',
+ function($scope, $modalInstance, cancel_reasons) {
+ $scope.args = {
+ cancel_reasons : cancel_reasons,
+ num_holds : hold_ids.length
+ };
+
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+
+ $scope.ok = function() {
+
+ function cancel_one() {
+ var hold_id = hold_ids.pop();
+ if (!hold_id) {
+ $modalInstance.close();
+ return;
+ }
+ egCore.net.request(
+ 'open-ils.circ', 'open-ils.circ.hold.cancel',
+ egCore.auth.token(), hold_id,
+ $scope.args.cancel_reason.id(),
+ $scope.args.note
+ ).then(function(resp) {
+ if (evt = egCore.evt.parse(resp)) {
+ console.error('unable to cancel hold: '
+ + evt.toString());
+ }
+ cancel_one();
+ });
+ }
+
+ cancel_one();
+ }
+ }
+ ],
+ resolve : {
+ cancel_reasons : function() {
+ return service.get_cancel_reasons();
+ }
+ }
+ }).result;
+ }
+
+ service.get_cancel_reasons = function() {
+ if (egCore.env.ahrcc) return $q.when(egCore.env.ahrcc.list);
+ return egCore.pcrud.retrieveAll('ahrcc', {}, {atomic : true})
+ .then(function(list) { return egCore.env.absorbList(list, 'ahrcc').list });
+ }
+
+ // Updates a batch of holds, notifies on each response.
+ // new_values = array of hashes describing values to change,
+ // including the id of the hold to change.
+ // e.g. {id : 1, mint_condition : true}
+ service.update_holds = function(new_values) {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.update.batch',
+ egCore.auth.token(), null, new_values);
+ }
+
+ service.set_copy_quality = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_copy_quality_dialog',
+ controller :
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+
+ function update(val) {
+ var vals = hold_ids.map(function(hold_id) {
+ return {id : hold_id, mint_condition : val}})
+ service.update_holds(vals).finally(function() {
+ $modalInstance.close();
+ });
+ }
+ $scope.good = function() { update(true) }
+ $scope.any = function() { update(false) }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }
+ ]
+ }).result;
+ }
+
+ service.edit_pickup_lib = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_edit_pickup_lib',
+ controller :
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.args = {}
+ $scope.ok = function() {
+ var vals = hold_ids.map(function(hold_id) {
+ return {
+ id : hold_id,
+ pickup_lib : $scope.args.org_unit.id()
+ }
+ });
+ service.update_holds(vals).finally(function() {
+ $modalInstance.close();
+ });
+ }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }
+ ]
+ }).result;
+ }
+
+ service.get_sms_carriers = function() {
+ if (egCore.env.csc) return $q.when(egCore.env.csc.list);
+ return egCore.pcrud.retrieveAll('csc', {}, {atomic : true})
+ .then(function(list) { return egCore.env.absorbList(list, 'csc').list });
+ }
+
+ service.edit_notify_prefs = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_notification_prefs',
+ controller :
+ ['$scope', '$modalInstance', 'sms_carriers',
+ function($scope, $modalInstance, sms_carriers) {
+ $scope.args = {}
+ $scope.sms_carriers = sms_carriers;
+ $scope.num_holds = hold_ids.length;
+ $scope.ok = function() {
+
+ var vals = hold_ids.map(function(hold_id) {
+ var val = {id : hold_id};
+ angular.forEach(
+ ['email', 'phone', 'sms'],
+ function(type) {
+ var key = type + '_notify';
+ if ($scope.args['update_' + key])
+ val[key] = $scope.args[key];
+ }
+ );
+ if ($scope.args.update_sms_carrier)
+ val.sms_carrier = $scope.args.sms_carrier.id();
+ return val;
+ });
+
+ service.update_holds(vals).finally(function() {
+ $modalInstance.close();
+ });
+ }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }
+ ],
+ resolve : {
+ sms_carriers : service.get_sms_carriers
+ }
+ }).result;
+ }
+
+ service.edit_dates = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+
+ // collects the fields from the dialog the user wishes to modify
+ function relay_to_update(modal_scope) {
+ var vals = hold_ids.map(function(hold_id) {
+ var val = {id : hold_id};
+ angular.forEach(
+ ['thaw_date', 'request_time', 'expire_time', 'shelf_expire_time'],
+ function(field) {
+ if (modal_scope.args['modify_' + field])
+ val[field] = modal_scope.args[field].toISOString();
+ }
+ );
+
+ return val;
+ });
+
+ console.log(JSON.stringify(vals,null,2));
+ return service.update_holds(vals);
+ }
+
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_dates',
+ controller :
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ var today = new Date();
+ $scope.args = {
+ thaw_date : today,
+ request_time : today,
+ expire_time : today,
+ shelf_expire_time : today
+ }
+ $scope.num_holds = hold_ids.length;
+ $scope.ok = function() {
+ relay_to_update($scope).then($modalInstance.close);
+ }
+ $scope.cancel = function() { $modalInstance.dismiss() }
+ }
+ ],
+ }).result;
+ }
+
+ service.update_field_with_confirm = function(hold_ids, msg_key, field, value) {
+ if (!hold_ids.length) return $q.when();
+
+ return egConfirmDialog.open(
+ egCore.strings[msg_key], '', {num_holds : hold_ids.length})
+ .result.then(function() {
+
+ var vals = hold_ids.map(function(hold_id) {
+ val = {id : hold_id};
+ val[field] = value;
+ return val;
+ });
+ return service.update_holds(vals);
+ });
+ }
+
+ service.suspend_holds = function(hold_ids) {
+ return service.update_field_with_confirm(
+ hold_ids, 'SUSPEND_HOLDS', 'frozen', true);
+ }
+
+ service.activate_holds = function(hold_ids) {
+ return service.update_field_with_confirm(
+ hold_ids, 'ACTIVATE_HOLDS', 'frozen', false);
+ }
+
+ service.set_top_of_queue = function(hold_ids) {
+ return service.update_field_with_confirm(
+ hold_ids, 'SET_TOP_OF_QUEUE', 'cut_in_line', true);
+ }
+
+ service.clear_top_of_queue = function(hold_ids) {
+ return service.update_field_with_confirm(
+ hold_ids, 'CLEAR_TOP_OF_QUEUE', 'cut_in_line', null);
+ }
+
+ service.transfer_to_marked_title = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+
+ var bib_id = egCore.hatch.getLocalItem(
+ 'eg.circ.hold.title_transfer_target');
+
+ if (!bib_id) {
+ // no target marked
+ return egAlertDialog.open(
+ egCore.strings.NO_HOLD_TRANSFER_TITLE_MARKED).result;
+ }
+
+ return egConfirmDialog.open(
+ egCore.strings.TRANSFER_HOLD_TO_TITLE, '', {
+ num_holds : hold_ids.length,
+ bib_id : bib_id
+ }
+ ).result.then(function() {
+ return egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.change_title.specific_holds',
+ egCore.auth.token(), bib_id, hold_ids);
+ });
+ }
+
+ // serially retargets each hold
+ service.retarget = function(hold_ids) {
+ if (!hold_ids.length) return $q.when();
+ var deferred = $q.defer();
+
+ egConfirmDialog.open(
+ egCore.strings.RETARGET_HOLDS, '',
+ {hold_ids : hold_ids.join(',')}
+
+ ).result.then(function() {
+
+ function do_one() {
+ var hold_id = hold_ids.pop();
+ if (!hold_id) {
+ deferred.resolve();
+ return;
+ }
+
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.reset',
+ egCore.auth.token(), hold_id).finally(do_one);
+ }
+
+ do_one(); // kick it off
+ });
+
+ return deferred.promise;
+ }
+
+ // fleshes orgs, etc. for hold data blobs retrieved from
+ // open-ils.circ.hold.details[.batch].retrieve
+ service.local_flesh = function(hold_data) {
+
+ hold_data.status_string =
+ egCore.strings['HOLD_STATUS_' + hold_data.status]
+ || hold_data.status;
+
+ var hold = hold_data.hold;
+ hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+ hold.current_shelf_lib(egCore.org.get(hold.current_shelf_lib()));
+ hold_data.id = hold.id();
+
+ // current_copy is not always fleshed in the API
+ if (hold.current_copy() && typeof hold.current_copy() != 'object')
+ hold.current_copy(hold_data.copy);
+ }
+
+ return service;
+}])
+
+/**
+ * Action handlers for the common Hold grid UI.
+ * These generally scrub the data for valid input then pass the
+ * holds / copies / etc. off to the relevant action in egHolds or egCirc.
+ *
+ * Caller must apply a reset_page function, which is called after
+ * most actionis are performed.
+ */
+.factory('egHoldGridActions',
+ ['$window','$location','egCore','egHolds','egCirc',
+function($window , $location , egCore , egHolds , egCirc) {
+
+ var service = {};
+
+ service.refresh = function() {
+ console.error('egHoldGridActions.refresh not defined!');
+ }
+
+ service.cancel_hold = function(items) {
+ var hold_ids = items.filter(function(item) {
+ return !item.hold.cancel_time();
+ }).map(function(item) {return item.hold.id()});
+
+ return egHolds.cancel_holds(hold_ids).then(service.refresh);
+ }
+
+ // jump to circ list for either 1) the targeted copy or
+ // 2) the hold target copy for copy-level holds
+ service.show_recent_circs = function(items) {
+ if (items.length && (copy = items[0].copy)) {
+ var url = $location.path(
+ '/cat/item/' + copy.id() + '/circ_list').absUrl();
+ $window.open(url, '_blank').focus();
+ }
+ }
+
+ function generic_update(items, action) {
+ if (!items.length) return $q.when();
+ var hold_ids = items.map(function(item) {return item.hold.id()});
+ return egHolds[action](hold_ids).then(service.refresh);
+ }
+
+ service.set_copy_quality = function(items) {
+ generic_update(items, 'set_copy_quality'); }
+ service.edit_pickup_lib = function(items) {
+ generic_update(items, 'edit_pickup_lib'); }
+ service.edit_notify_prefs = function(items) {
+ generic_update(items, 'edit_notify_prefs'); }
+ service.edit_dates = function(items) {
+ generic_update(items, 'edit_dates'); }
+ service.suspend = function(items) {
+ generic_update(items, 'suspend_holds'); }
+ service.activate = function(items) {
+ generic_update(items, 'activate_holds'); }
+ service.set_top_of_queue = function(items) {
+ generic_update(items, 'set_top_of_queue'); }
+ service.clear_top_of_queue = function(items) {
+ generic_update(items, 'clear_top_of_queue'); }
+ service.transfer_to_marked_title = function(items) {
+ generic_update(items, 'transfer_to_marked_title'); }
+
+ service.mark_damaged = function(items) {
+ var copy_ids = items
+ .filter(function(item) { return Boolean(item.copy) })
+ .map(function(item) { return item.copy.id() });
+ if (copy_ids.length)
+ egCirc.mark_damaged(copy_ids).then(service.refresh);
+ }
+
+ service.mark_missing = function(items) {
+ var copy_ids = items
+ .filter(function(item) { return Boolean(item.copy) })
+ .map(function(item) { return item.copy.id() });
+ if (copy_ids.length)
+ egCirc.mark_missing(copy_ids).then(service.refresh);
+ }
+
+ service.retarget = function(items) {
+ var hold_ids = items.map(function(item) { return item.hold.id() });
+ egHolds.retarget(hold_ids).then(service.refresh);
+ }
+
+ return service;
+}])
+
+/**
+ * Hold details interface
+ */
+.directive('egHoldDetails', function() {
+ return {
+ restrict : 'AE',
+ templateUrl : './circ/share/t_hold_details',
+ scope : {
+ holdId : '=',
+ // if set, called whenever hold details are retrieved. The
+ // argument is the hold blob returned from hold.details.retrieve
+ holdRetrieved : '=',
+ showPatron : '='
+ },
+ controller : [
+ '$scope','$modal','egCore','egHolds','egCirc',
+ function($scope , $modal , egCore , egHolds , egCirc) {
+
+ function draw() {
+ if (!$scope.holdId) return;
+
+ egCore.net.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.details.retrieve.authoritative',
+ egCore.auth.token(), $scope.holdId
+
+ ).then(function(hold_data) {
+ egHolds.local_flesh(hold_data);
+
+ angular.forEach(hold_data,
+ function(val, key) { $scope[key] = val });
+
+ // fetch + flesh the cancel_cause if needed
+ if ($scope.hold.cancel_time()) {
+ egHolds.get_cancel_reasons().then(function() {
+ // egHolds caches the causes in egEnv
+ $scope.hold.cancel_cause(
+ egCore.env.ahrcc.map[$scope.hold.cancel_cause()]);
+ })
+ }
+
+ if ($scope.hold.current_copy()) {
+ egCirc.flesh_copy_location($scope.hold.current_copy());
+ }
+
+ if ($scope.holdRetrieved)
+ $scope.holdRetrieved(hold_data);
+
+ });
+ }
+
+ $scope.show_notify_tab = function() {
+ $scope.detail_tab = 'notify';
+ egCore.pcrud.search('ahn',
+ {hold : $scope.hold.id()},
+ {flesh : 1, flesh_fields : {ahn : ['notify_staff']}},
+ {atomic : true}
+ ).then(function(nots) {
+ $scope.hold.notifications(nots);
+ });
+ }
+
+ $scope.delete_note = function(note) {
+ egCore.pcrud.remove(note).then(function() {
+ // remove the deleted note from the locally fleshed notes
+ $scope.hold.notes(
+ $scope.hold.notes().filter(function(n) {
+ return n.id() != note.id()
+ })
+ );
+ });
+ }
+
+ $scope.new_note = function() {
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_note_dialog',
+ controller :
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.args = {};
+ $scope.ok = function() {
+ $modalInstance.close($scope.args)
+ },
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }
+ ]
+ }).result.then(function(args) {
+ var note = new egCore.idl.ahrn();
+ note.hold($scope.hold.id());
+ note.staff(true);
+ note.slip(args.slip);
+ note.pub(args.pub);
+ note.title(args.title);
+ note.body(args.body);
+ return egCore.pcrud.create(note).then(function(n) {
+ $scope.hold.notes().push(n);
+ });
+ });
+ }
+
+ $scope.new_notification = function() {
+ return $modal.open({
+ templateUrl : './circ/share/t_hold_notification_dialog',
+ controller :
+ ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.args = {};
+ $scope.ok = function() {
+ $modalInstance.close($scope.args)
+ },
+ $scope.cancel = function($event) {
+ $modalInstance.dismiss();
+ $event.preventDefault();
+ }
+ }
+ ]
+ }).result.then(function(args) {
+ var note = new egCore.idl.ahn();
+ note.hold($scope.hold.id());
+ note.method(args.method);
+ note.note(args.note);
+ note.notify_staff(egCore.auth.user().id());
+ note.notify_time('now');
+ return egCore.pcrud.create(note).then(function(n) {
+ n.notify_staff(egCore.auth.user());
+ $scope.hold.notifications().push(n);
+ });
+ });
+ }
+
+ $scope.$watch('holdId', function(newVal, oldVal) {
+ if (newVal != oldVal) draw();
+ });
+
+ draw();
+ }
+ ]
+ }
+})
+
+
--- /dev/null
+{
+ "name": "evergreen-staff-client",
+ "description": "Evergreen ILS Browser Staff Client",
+ "version": "0.0.1",
+ "license": "GPL",
+ "homepage": "http://evergreen-ils.org/",
+ "devDependencies": {
+ "bower": "^1.3.3",
+ "grunt": "~0.4.4",
+ "grunt-cli": "^0.1.13",
+ "grunt-contrib-concat": "^0.4.0",
+ "grunt-contrib-copy": "^0.5.0",
+ "grunt-contrib-cssmin": "^0.9.0",
+ "grunt-contrib-jasmine": "^0.6.4",
+ "grunt-contrib-uglify": "^0.4.0",
+ "grunt-exec": "^0.4.5",
+ "grunt-karma": "^0.8.3",
+ "karma": "^0.12.14",
+ "karma-jasmine": "^0.1.5",
+ "karma-phantomjs-launcher": "^0.1.4",
+ "karma-script-launcher": "~0.1.0",
+ "karma-spec-reporter": "0.0.12",
+ "karma-story-reporter": "^0.2.2"
+ },
+ "scripts": {
+ "test": "grunt test"
+ }
+}
--- /dev/null
+/* Core Sevice - egAuth
+ *
+ * Manages login and auth session retrieval.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egAuth',
+ ['$q','$timeout','$rootScope','egNet','egHatch',
+function($q , $timeout , $rootScope , egNet , egHatch) {
+
+ var service = {
+ // the currently active user (au) object
+ user : function() {
+ return this._user;
+ },
+
+ // the currently active auth token string
+ token : function() {
+ return egHatch.getLocalItem('eg.auth.token');
+ },
+
+ // authtime in seconds
+ authtime : function() {
+ return egHatch.getLocalItem('eg.auth.time');
+ },
+
+ // the currently active workstation name
+ // For ws_ou or wsid(), see egAuth.user().ws_ou(), etc.
+ workstation : function() {
+ return this.ws;
+ }
+ };
+
+ /* Returns a promise, which is resolved if valid
+ * authtoken is found, otherwise rejected */
+ service.testAuthToken = function() {
+ var deferred = $q.defer();
+ var token = service.token();
+
+ if (token) {
+
+ egNet.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve', token)
+
+ .then(function(user) {
+ if (user && user.classname) {
+ // authtoken test succeeded
+ service._user = user;
+ service.poll();
+
+ if (user.wsid()) {
+ // user previously logged in with a workstation.
+ // Find the workstation name from the list
+ // of configured workstations
+ egHatch.getItem('eg.workstation.all')
+ .then(function(all) {
+ if (all) {
+ var ws = all.filter(
+ function(w) {return w.id == user.wsid()})[0];
+ if (ws) service.ws = ws.name;
+ }
+ deferred.resolve(); // found WS
+ });
+ } else {
+ deferred.resolve(); // no WS
+ }
+ } else {
+ // authtoken test failed
+ egHatch.removeLocalItem('eg.auth.token');
+ deferred.reject();
+ }
+ });
+
+ } else {
+ // no authtoken to test
+ deferred.reject();
+ }
+
+ return deferred.promise;
+ };
+
+ /**
+ * Returns a promise, which is resolved on successful
+ * login and rejected on failed login.
+ */
+ service.login = function(args) {
+ var deferred = $q.defer();
+ egNet.request(
+ 'open-ils.auth',
+ 'open-ils.auth.authenticate.init', args.username).then(
+ function(seed) {
+ args.password = hex_md5(seed + hex_md5(args.password))
+ egNet.request(
+ 'open-ils.auth',
+ 'open-ils.auth.authenticate.complete', args).then(
+ function(evt) {
+ if (evt.textcode == 'SUCCESS') {
+ service.ws = args.workstation;
+ service.poll();
+ egHatch.setLocalItem(
+ 'eg.auth.token', evt.payload.authtoken);
+ egHatch.setLocalItem(
+ 'eg.auth.time', evt.payload.authtime);
+ deferred.resolve();
+ } else {
+ // note: the likely outcome here is a NO_SESION
+ // server event, which results in broadcasting an
+ // egInvalidAuth by egNet.
+ console.error('login failed ' + js2JSON(evt));
+ deferred.reject();
+ }
+ }
+ )
+ }
+ );
+
+ return deferred.promise;
+ };
+
+ /**
+ * Force-check the validity of the authtoken on occasion.
+ * This allows us to redirect an idle staff client back to the login
+ * page after the session times out. Otherwise, the UI would stay
+ * open with potentially sensitive data visible.
+ * TODO: What is the practical difference (for a browser) between
+ * checking auth validity and the ui.general.idle_timeout setting?
+ * Does that setting serve a purpose in a browser environment?
+ */
+ service.poll = function() {
+ if (!service.authtime()) return;
+
+ $timeout(
+ function() {
+ if (!service.authtime()) return;
+ egNet.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.retrieve', service.token())
+ .then(function(user) {
+ if (user && user.classname) { // all good
+ service.poll();
+ } else {
+ $rootScope.$broadcast('egAuthExpired')
+ }
+ })
+ },
+ // add a 5 second delay to give the token plenty of time
+ // to expire on the server.
+ service.authtime() * 1000 + 5000
+ );
+ }
+
+ service.logout = function() {
+ if (service.token()) {
+ egNet.request(
+ 'open-ils.auth',
+ 'open-ils.auth.session.delete',
+ service.token()); // fire and forget
+ egHatch.removeLocalItem('eg.auth.token');
+ egHatch.removeLocalItem('eg.auth.time');
+ }
+ service._user = null;
+ };
+
+ return service;
+}])
+
+
+/**
+ * Service for testing user permissions.
+ * Note: this cannot live within egAuth, because it creates a circular
+ * dependency of egOrg -> egEnv -> egAuth -> egOrg
+ */
+.factory('egPerm',
+ ['$q','egNet','egAuth','egOrg',
+function($q , egNet , egAuth , egOrg) {
+ var service = {};
+
+ /*
+ * Returns the full list of org unit objects at which the currently
+ * logged in user has the selected permissions.
+ * @permList - list or string. If a list, the response object is a
+ * hash of perm => orgList maps. If a string, the response is the
+ * org list for the requested perm.
+ */
+ service.hasPermAt = function(permList, asId) {
+ var deferred = $q.defer();
+ var isArray = true;
+ if (!angular.isArray(permList)) {
+ isArray = false;
+ permList = [permList];
+ }
+ // as called, this method will return the top-most org unit of the
+ // sub-tree at which this user has the selected permission.
+ // From there, flesh the descendant orgs locally.
+ egNet.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.has_work_perm_at.batch',
+ egAuth.token(), permList
+ ).then(function(resp) {
+ var answer = {};
+ angular.forEach(permList, function(perm) {
+ var all = [];
+ angular.forEach(resp[perm], function(oneOrg) {
+ all = all.concat(egOrg.descendants(oneOrg, asId));
+ });
+ answer[perm] = all;
+ });
+ if (!isArray) answer = answer[permList[0]];
+ deferred.resolve(answer);
+ });
+ return deferred.promise;
+ };
+
+
+ /**
+ * Returns a hash of perm => hasPermBool for each requested permission.
+ * If the authenticated user has no workstation, no checks are made
+ * and all permissions return false.
+ */
+ service.hasPermHere = function(permList) {
+ var response = {};
+
+ var isArray = true;
+ if (!angular.isArray(permList)) {
+ isArray = false;
+ permList = [permList];
+ }
+
+ // no workstation, all are false
+ if (egAuth.user().wsid() === null) {
+ console.warn("egPerm.hasPermHere() called with no workstation");
+ if (isArray) {
+ response = permList.map(function(perm) {
+ return response[perm] = false;
+ });
+ } else {
+ response = false;
+ }
+ return $q.when(response);
+ }
+
+ ws_ou = Number(egAuth.user().ws_ou()); // from string
+
+ return service.hasPermAt(permList, true)
+ .then(function(orgMap) {
+ angular.forEach(orgMap, function(orgIds, perm) {
+ // each permission is mapped to a flat list of org unit ids,
+ // including descendants. See if our workstation org unit
+ // is in the list.
+ response[perm] = orgIds.indexOf(ws_ou) > -1;
+ });
+ if (!isArray) response = response[permList[0]];
+ return response;
+ });
+ }
+
+ return service;
+}])
+
+
--- /dev/null
+
+/**
+ * egCoreMod houses all of the services, etc. required by all pages
+ * for basic functionality.
+ */
+angular.module('egCoreMod', ['cfp.hotkeys']);
--- /dev/null
+/**
+ * egCore service
+ *
+ * Aggregates all core services into a container service. This allows
+ * use of core services without having to inject each individually.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCore',
+ ['egIDL','egNet','egEnv','egOrg','egPCRUD','egEvent','egAuth',
+ 'egPerm','egHatch','egPrint','egStartup','egStrings',
+function(egIDL , egNet , egEnv , egOrg , egPCRUD , egEvent , egAuth ,
+ egPerm , egHatch , egPrint , egStartup , egStrings) {
+
+ return {
+ idl : egIDL,
+ net : egNet,
+ env : egEnv,
+ org : egOrg,
+ pcrud : egPCRUD,
+ evt : egEvent,
+ auth : egAuth,
+ perm : egPerm,
+ hatch : egHatch,
+ print : egPrint,
+ startup : egStartup,
+ strings : egStrings
+ };
+
+}]);
+
+
--- /dev/null
+angular.module('egCoreMod')
+
+/*
+ * Iframe container for (mostly legacy) embedded interfaces
+ */
+.directive('egEmbedFrame', function() {
+ return {
+ restrict : 'AE',
+ replace : true,
+ scope : {
+ // URL to load in the embed iframe
+ url : '=',
+
+ // optional hash of functions which augment or override
+ // the stock xulG functions defined below.
+ handlers : '=',
+
+ // called after onload of each new iframe page
+ onchange : '=',
+ },
+
+ templateUrl : './share/t_eframe',
+
+ controller :
+ ['$scope','$window','$location','$q','$timeout','egCore',
+ function($scope , $window , $location , $q , $timeout , egCore) {
+
+ // Set the iframe height to just under the window height.
+ // leave room for the navbar, padding, margins, etc.
+ $scope.height = $window.outerHeight - 300;
+
+ // browser client doesn't use cookies, so we don't load the
+ // (at the time of writing, quite limited) angular.cookies
+ // module. We could load something, but this seems to work
+ // well enough for setting the auth cookie (at least, until
+ // it doesn't).
+ //
+ // note: document.cookie is smart enough to leave unreferenced
+ // cookies alone, so contrary to how this might look, it's not
+ // deleting other cookies (anoncache, etc.)
+
+ // delete any existing ses cookie
+ $window.document.cookie = "ses=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ // push our authtoken in
+ $window.document.cookie = 'ses=' + egCore.auth.token() + '; path=/; secure'
+
+ // $location has functions for modifying paths and search,
+ // but they all assume you are staying within the angular
+ // app, which we are not. Build the URLs by hand.
+ function open_tab(path) {
+ var url = 'https://' + $window.location.hostname +
+ egCore.env.basePath + path;
+ console.debug('egEmbedFrame opening tab ' + url);
+ $window.open(url, '_blank').focus();
+ }
+
+ // define our own xulG functions to be inserted into the
+ // iframe. NOTE: window-level functions are bad. Though
+ // there is probably a way, I was unable to correctly wire
+ // up the iframe onload handler within the controller or link
+ // funcs. In any event, the code below is meant as a stop-gap
+ // for porting dojo, etc. apps to angular apps and should
+ // eventually go away.
+ // NOTE: catalog integration is not a stop-gap
+ $window.egEmbedFrameLoader = function(iframe) {
+
+ var page = iframe.contentWindow.location.href;
+ console.debug('egEmbedFrameLoader(): ' + page);
+
+ // reload ifram page w/o reloading the entire UI
+ $scope.reload = function() {
+ iframe.contentWindow.location.replace(
+ iframe.contentWindow.location);
+ }
+
+ // tell the iframe'd window its inside the staff client
+ iframe.contentWindow.IAMXUL = true;
+
+ // also tell it it's inside the browser client, which
+ // may be needed in a few special cases.
+ iframe.contentWindow.IAMBROWSER /* hear me roar */ = true;
+
+ // XUL has a dump() function which is occasinally called
+ // from embedded browsers.
+ iframe.contentWindow.dump = function(msg) {
+ console.debug('egEmbedFrame:dump(): ' + msg);
+ }
+
+ // define a few commonly used stock xulG handlers.
+
+ iframe.contentWindow.xulG = {
+ // patron search
+ spawn_search : function(search) {
+ open_tab('/circ/patron/search?search='
+ + encodeURIComponent(js2JSON(search)));
+ },
+
+ // edit an existing user
+ spawn_editor : function(info) {
+ if (info.usr) {
+ open_tab('/circ/patron/register/edit/' + info.usr);
+
+ } else if (info.clone) {
+ // FIXME: The save-and-clone operation in the
+ // patron editor results in this action.
+ // For some reason, this specific function results
+ // in a new browser window opening instead of a
+ // browser tab. Possibly this is caused by the
+ // fact that the action occurs as a result of a
+ // button click instead of an href. *shrug*.
+ // It's obnoxious.
+ open_tab('/circ/patron/register/clone/' + info.clone);
+ }
+ },
+
+ // open a user account
+ new_patron_tab : function(tab_info, usr_info) {
+ open_tab('/circ/patron/' + usr_info.id + '/checkout');
+ },
+
+ get_barcode_and_settings_async : function(barcode, only_settings) {
+ if (!barcode) return $q.reject();
+ var deferred = $q.defer();
+
+ var barcode_promise = $q.when(barcode);
+ if (!only_settings) {
+
+ // first verify / locate the barcode
+ barcode_promise = egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.get_barcodes',
+ egCore.auth.token(),
+ egCore.auth.user().ws_ou(), 'actor', barcode
+ ).then(function(resp) {
+
+ if (!resp || egCore.evt.parse(resp) || !resp.length) {
+ console.error('user not found: ' + barcode);
+ deferred.reject();
+ return null;
+ }
+
+ resp = resp[0];
+ return barcode = resp.barcode;
+ });
+ }
+
+ barcode_promise.then(function(barcode) {
+ if (!barcode) return;
+
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.fleshed.retrieve_by_barcode',
+ egCore.auth.token(), barcode);
+
+ }).then(function(user) {
+ if (!user) return null;
+
+ if (e = egCore.evt.parse(user)) {
+ console.error('user fetch failed : ' + e.toString());
+ deferred.reject();
+ return null;
+ }
+
+ // copied more or less directly from XUL menu.js
+ var settings = {};
+ for(var i = 0; i < user.settings().length; i++) {
+ settings[user.settings()[i].name()] =
+ JSON2js(user.settings()[i].value());
+ }
+
+ if(!settings['opac.default_phone'] && user.day_phone())
+ settings['opac.default_phone'] = user.day_phone();
+ if(!settings['opac.hold_notify'] && settings['opac.hold_notify'] !== '')
+ settings['opac.hold_notify'] = 'email:phone';
+
+ // Taken from patron/util.js format_name
+ // FIXME: I18n
+ var patron_name =
+ ( user.prefix() ? user.prefix() + ' ' : '') +
+ user.family_name() + ', ' +
+ user.first_given_name() + ' ' +
+ ( user.second_given_name() ? user.second_given_name() + ' ' : '' ) +
+ ( user.suffix() ? user.suffix() : '');
+
+ deferred.resolve({
+ "barcode": barcode,
+ "settings" : settings,
+ "user_email" : user.email(),
+ "patron_name" : patron_name
+ });
+ });
+
+ return deferred.promise;
+ }
+ }
+
+ if ($scope.handlers) {
+ $scope.handlers.reload = $scope.reload;
+ angular.forEach($scope.handlers, function(val, key) {
+ iframe.contentWindow.xulG[key] = val;
+ });
+ }
+
+ if ($scope.onchange) $scope.onchange(page);
+ }
+ }]
+ }
+})
+
+
--- /dev/null
+/**
+ * Core Service - egEnv
+ *
+ * Manages startup data loading and data caching.
+ * All registered loaders run * simultaneously. When all promises
+ * are resolved, the promise * returned by egEnv.load() is resolved.
+ *
+ * There are two main uses cases for egEnv:
+ *
+ * 1. When loading a variety of objects on page load, having them
+ * loaded with egEnv ensures that the load will happen in parallel
+ * and that it will complete before egStartup completes, which is
+ * generally before page controllers run.
+ *
+ * 2. When loading generic IDL data across different services,
+ * having them all stash the data in egEnv means they each have
+ * an agreed-upon cache mechanism.
+ *
+ * It's also a good place to stash other environmental tidbits...
+ *
+ * Generic and class-based loaders are supported.
+ *
+ * To load a registred class, push the class hint onto
+ * egEnv.loadClasses.
+ *
+ * // will cause all 'pgt' objects to be fetched
+ * egEnv.loadClasses.push('pgt');
+ *
+ * To register a new class loader,attach a loader function to
+ * egEnv.classLoaders, keyed on the class hint, which returns a promise.
+ *
+ * egEnv.classLoaders.ccs = function() {
+ * // loads copy status objects, returns promise
+ * };
+ *
+ * Generic loaders go onto the egEnv.loaders array. Each should
+ * return a promise.
+ *
+ * egEnv.loaders.push(function() {
+ * return egNet.request(...)
+ * .then(function(stuff) { console.log('stuff!')
+ * });
+ */
+
+angular.module('egCoreMod')
+
+// env fetcher
+.factory('egEnv',
+ ['$q','$window','egAuth','egPCRUD','egIDL',
+function($q, $window , egAuth, egPCRUD, egIDL) {
+
+ var service = {
+ // collection of custom loader functions
+ loaders : []
+ };
+
+
+ // <base href="<basePath>"/> from the current index page
+ // Currently defaults to /eg/staff for all pages.
+ // Use $location.path() to jump around within an app.
+ // Use egEnv.basePath to create URLs to new apps.
+ // NOTE: the dynamic version below derived from the DOM does not
+ // work w/ unit tests. Use hard-coded value instead for now.
+ service.basePath = '/eg/staff/';
+ //$window.document.getElementsByTagName('base')[0].getAttribute('href');
+
+ /* returns a promise, loads all of the specified classes */
+ service.load = function() {
+ // always assume the user is logged in
+ if (!egAuth.user()) return $q.when();
+
+ var allPromises = [];
+ var classes = this.loadClasses;
+ console.debug('egEnv loading classes => ' + classes);
+
+ angular.forEach(classes, function(cls) {
+ allPromises.push(service.classLoaders[cls]());
+ });
+ angular.forEach(this.loaders, function(loader) {
+ allPromises.push(loader());
+ });
+
+ return $q.all(allPromises).then(
+ function() { console.debug('egEnv load complete') });
+ };
+
+ /** given a tree-shaped collection, captures the tree and
+ * flattens the tree for absorption.
+ */
+ service.absorbTree = function(tree, class_) {
+ var list = [];
+ function squash(node) {
+ list.push(node);
+ angular.forEach(node.children(), squash);
+ }
+ squash(tree);
+ var blob = service.absorbList(list, class_);
+ blob.tree = tree;
+ };
+
+ /** caches the object list both as the list and an id => object map */
+ service.absorbList = function(list, class_) {
+ var blob;
+ var pkey = egIDL.classes[class_].pkey;
+
+ if (service[class_]) {
+ // appending data to an existing class. Useful for receiving
+ // class elements as-needed. Avoid adding items which are
+ // already tracked in the list.
+ blob = service[class_];
+ angular.forEach(list, function(item) {
+ if (!service[class_].map[item[pkey]()])
+ blob.list.push(item);
+ });
+ } else {
+ blob = {list : list, map : {}};
+ }
+
+ angular.forEach(list, function(item) {blob.map[item[pkey]()] = item});
+ service[class_] = blob;
+ return blob;
+ };
+
+ /*
+ * list of classes to load on every page, regardless of whether
+ * a page-specific list is provided.
+ */
+ service.loadClasses = ['aou'];
+
+ /*
+ * Default class loaders. Only add classes directly to this file
+ * that are loaded practically always. All other app-specific
+ * classes should be registerd from within the app.
+ */
+ service.classLoaders = {
+ aou : function() {
+
+ // EXPERIMENT: cache the org tree in session storage.
+ // This means that if the org tree changes, users will have to
+ // open the client in a new browser tab to clear the cached tree.
+ var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree');
+ if (treeJSON) {
+ console.debug('serving org tree from cache');
+ var tree = JSON2js(treeJSON);
+ service.absorbTree(tree, 'aou')
+ return $q.when(tree);
+ }
+
+ return egPCRUD.search('aou', {parent_ou : null},
+ {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+ ).then(
+ function(tree) {
+ $window.sessionStorage.setItem(
+ 'eg.env.aou.tree', js2JSON(tree));
+ service.absorbTree(tree, 'aou')
+ }
+ );
+ },
+ };
+
+ return service;
+}]);
+
+
+
--- /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.success = this.textcode == 'SUCCESS';
+ this.toString = function() {
+ var s = 'Event: ' + (this.code || '') + ':' +
+ this.textcode + ' -> ' + new String(this.desc);
+ if(this.ilsperm)
+ s += ' ' + this.ilsperm + '@' + this.ilspermloc;
+ if(this.note)
+ s += '\n' + this.note;
+ return s;
+ }
+ }
+
+ if(thing && typeof thing == 'object' && 'textcode' in thing)
+ return new EGEvent(thing);
+ return null;
+ }
+ }
+});
+
--- /dev/null
+/**
+ * File upload reader.
+ * http://stackoverflow.com/questions/17063000/ng-model-for-input-type-file
+ *
+ * After reading, the contents will be available in the scope variable
+ * referred to by container="..."
+ */
+
+angular.module('egCoreMod')
+.directive("egFileReader", [function () {
+ return {
+ scope: {
+ container: "="
+ },
+ link: function (scope, element, attributes) {
+ // TODO: support DataURL, etc. via attrs
+ element.bind("change", function (changeEvent) {
+ var reader = new FileReader();
+ reader.onload = function (loadEvent) {
+ scope.$apply(function () {
+ scope.container = loadEvent.target.result;
+ });
+ }
+ reader.readAsText(changeEvent.target.files[0]);
+ });
+ }
+ }
+}]);
--- /dev/null
+angular.module('egGridMod',
+ ['egCoreMod', 'egUiMod', 'ui.bootstrap'])
+
+.directive('egGrid', function() {
+ return {
+ restrict : 'AE',
+ transclude : true,
+ scope : {
+
+ // IDL class hint (e.g. "aou")
+ idlClass : '@',
+
+ // default page size
+ pageSize : '@',
+
+ // if true, grid columns are derived from all non-virtual
+ // fields on the base idlClass
+ autoFields : '@',
+
+ // grid preferences will be stored / retrieved with this key
+ persistKey : '@',
+
+ // field whose value is unique and may be used for item
+ // reference / lookup. This will usually be someting like
+ // "id". This is not needed when using autoFields, since we
+ // can determine the primary key directly from the IDL.
+ idField : '@',
+
+ // Reference to externally provided egGridDataProvider
+ itemsProvider : '=',
+
+ // comma-separated list of supported or disabled grid features
+ // supported features:
+ // -display : columns are hidden by default
+ // -sort : columns are unsortable by default
+ // -multisort : sort priorities config disabled by default
+ features : '@',
+
+ // optional primary grid label
+ mainLabel : '@',
+
+ // if true, use the IDL class label as the mainLabel
+ autoLabel : '=',
+
+ // optional context menu label
+ menuLabel : '@',
+
+ // Hash of control functions.
+ //
+ // These functions are defined by the calling scope and
+ // invoked as-is by the grid w/ the specified parameters.
+ //
+ // itemRetrieved : function(item) {}
+ // allItemsRetrieved : function() {}
+ //
+ // ---------------
+ // These functions are defined by the grid and thus
+ // replace any values defined for these attributes from the
+ // calling scope.
+ //
+ // activateItem : function(item) {}
+ // allItems : function(allItems) {}
+ // selectedItems : function(selected) {}
+ // selectItems : function(ids) {}
+ // setQuery : function(queryStruct) {} // causes reload
+ // setSort : function(sortSturct) {} // causes reload
+ gridControls : '=',
+ },
+
+ // TODO: avoid hard-coded url
+ templateUrl : '/eg/staff/share/t_autogrid',
+
+ link : function(scope, element, attrs) {
+ // link() is called after page compilation, which means our
+ // eg-grid-field's have been parsed and loaded. Now it's
+ // safe to perform our initial page load.
+
+ // load auto fields after eg-grid-field's so they are not clobbered
+ scope.handleAutoFields();
+ scope.collect();
+ },
+
+ controller : [
+ '$scope','$q','egCore','egGridFlatDataProvider','$location',
+ 'egGridColumnsProvider','$filter','$window','$sce','$timeout',
+ function($scope, $q , egCore, egGridFlatDataProvider , $location,
+ egGridColumnsProvider , $filter , $window , $sce , $timeout) {
+
+ var grid = this;
+
+ grid.init = function() {
+ grid.offset = 0;
+ grid.limit = Number($scope.pageSize) || 25;
+ $scope.items = [];
+ $scope.showGridConf = false;
+ grid.totalCount = -1;
+ $scope.selected = {};
+ $scope.actions = []; // actions for selected items
+ $scope.menuItems = []; // global actions
+
+ // remove some unneeded values from the scope to reduce bloat
+
+ grid.idlClass = $scope.idlClass;
+ delete $scope.idlClass;
+
+ grid.persistKey = $scope.persistKey;
+ delete $scope.persistKey;
+
+ grid.indexField = $scope.idField;
+ delete $scope.idField;
+
+ grid.dataProvider = $scope.itemsProvider;
+
+ var features = ($scope.features) ?
+ $scope.features.split(',') : [];
+ delete $scope.features;
+
+ if (!grid.indexField && grid.idlClass)
+ grid.indexField = egCore.idl.classes[grid.idlClass].pkey;
+
+ grid.columnsProvider = egGridColumnsProvider.instance({
+ idlClass : grid.idlClass,
+ defaultToHidden : (features.indexOf('-display') > -1),
+ defaultToNoSort : (features.indexOf('-sort') > -1),
+ defaultToNoMultiSort : (features.indexOf('-multisort') > -1)
+ });
+
+ $scope.handleAutoFields = function() {
+ if ($scope.autoFields) {
+ if (grid.autoLabel) {
+ $scope.mainLabel =
+ egCore.idl.classes[grid.idlClass].label;
+ }
+ grid.columnsProvider.compileAutoColumns();
+ delete $scope.autoFields;
+ }
+ }
+
+ if (!grid.dataProvider) {
+ // no provider, um, provided.
+ // Use a flat data provider
+
+ grid.selfManagedData = true;
+ grid.dataProvider = egGridFlatDataProvider.instance({
+ indexField : grid.indexField,
+ idlClass : grid.idlClass,
+ columnsProvider : grid.columnsProvider,
+ query : $scope.query
+ });
+ }
+
+ $scope.itemFieldValue = grid.dataProvider.itemFieldValue;
+ $scope.indexValue = function(item) {
+ return grid.indexValue(item)
+ };
+
+ grid.applyControlFunctions();
+
+ grid.loadConfig().then(function() {
+ // link columns to scope after loadConfig(), since it
+ // replaces the columns array.
+ $scope.columns = grid.columnsProvider.columns;
+ });
+
+ // NOTE: grid.collect() is first called from link(), not here.
+ }
+
+ // link our control functions into the gridControls
+ // scope object so the caller can access them.
+ grid.applyControlFunctions = function() {
+
+ // we use some of these controls internally, so sett
+ // them up even if the caller doesn't request them.
+ var controls = $scope.gridControls || {};
+
+ // link in the control functions
+ controls.selectedItems = function() {
+ return grid.getSelectedItems()
+ }
+
+ controls.allItems = function() {
+ return $scope.items;
+ }
+
+ controls.selectItems = function(ids) {
+ if (!ids) return;
+ $scope.selected = {};
+ angular.forEach(ids, function(i) {
+ $scope.selected[''+i] = true;
+ });
+ }
+
+ // if the caller provided a functional setQuery,
+ // extract the value before replacing it
+ if (controls.setQuery) {
+ grid.dataProvider.query =
+ controls.setQuery();
+ }
+
+ controls.setQuery = function(query) {
+ grid.dataProvider.query = query;
+ controls.refresh();
+ }
+
+ // if the caller provided a functional setSort
+ // extract the value before replacing it
+ grid.dataProvider.sort =
+ controls.setSort ? controls.setSort() : [];
+
+ controls.setSort = function(sort) {
+ controls.refresh();
+ }
+
+ controls.refresh = function(noReset) {
+ if (!noReset) grid.offset = 0;
+ grid.collect();
+ }
+
+ controls.setLimit = function(limit) {
+ grid.limit = limit;
+ }
+ controls.getLimit = function() {
+ return grid.limit;
+ }
+ controls.setOffset = function(offset) {
+ grid.offset = offset;
+ }
+ controls.getOffset = function() {
+ return grid.offset;
+ }
+
+ grid.dataProvider.refresh = controls.refresh;
+ grid.controls = controls;
+ }
+
+ // add a new (global) grid menu item
+ grid.addMenuItem = function(item) {
+ $scope.menuItems.push(item);
+ var handler = item.handler;
+ item.handler = function() {
+ $scope.gridMenuIsOpen = false; // close menu
+ if (handler) {
+ handler(item,
+ item.handlerData, grid.getSelectedItems());
+ }
+ }
+ }
+
+ // add a selected-items action
+ grid.addAction = function(act) {
+ $scope.actions.push(act);
+ }
+
+ // remove the stored column configuration preferenc, then recover
+ // the column visibility information from the initial page load.
+ $scope.resetColumns = function() {
+ $scope.gridColumnPickerIsOpen = false;
+ egCore.hatch.removeItem('eg.grid.' + grid.persistKey)
+ .then(function() {
+ grid.columnsProvider.reset();
+ if (grid.selfManagedData) grid.collect();
+ });
+ }
+
+ $scope.showAllColumns = function() {
+ grid.columnsProvider.showAllColumns();
+ }
+
+ $scope.hideAllColumns = function() {
+ grid.columnsProvider.hideAllColumns();
+ }
+
+ $scope.toggleColumnVisibility = function(col) {
+ $scope.gridColumnPickerIsOpen = false;
+ col.visible = !col.visible;
+
+ // egGridFlatDataProvider only retrieves data to be
+ // displayed. When column visibility changes, it's
+ // necessary to fetch the newly visible column data.
+ if (grid.selfManagedData) grid.collect();
+ }
+
+ // save the columns configuration (position, sort, width) to
+ // eg.grid.<persist-key>
+ $scope.saveConfig = function() {
+ $scope.gridColumnPickerIsOpen = false;
+
+ if (!grid.persistKey) {
+ console.warn(
+ "Cannot save settings without a grid persist-key");
+ return;
+ }
+
+ // only store information about visible columns.
+ var conf = grid.columnsProvider.columns.filter(
+ function(col) {return Boolean(col.visible) });
+
+ // now scrunch the data down to just the needed info
+ conf = conf.map(function(col) {
+ var c = {name : col.name}
+ // Apart from the name, only store non-default values.
+ // No need to store col.visible, since that's implicit
+ if (col.flex != 2) c.flex = col.flex;
+ if (Number(col.sort)) c.sort = Number(c.sort);
+ return c;
+ });
+
+ egCore.hatch.setItem('eg.grid.' + grid.persistKey, conf)
+ .then(function() {
+ // Save operation performed from the grid configuration UI.
+ // Hide the configuration UI and re-draw w/ sort applied
+ if ($scope.showGridConf)
+ $scope.toggleConfDisplay();
+ });
+ }
+
+ // load the columns configuration (position, sort, width) from
+ // eg.grid.<persist-key> and apply the loaded settings to the
+ // columns on our columnsProvider
+ grid.loadConfig = function() {
+ if (!grid.persistKey) return $q.when();
+
+ return egCore.hatch.getItem('eg.grid.' + grid.persistKey)
+ .then(function(conf) {
+ if (!conf) return;
+
+ var columns = grid.columnsProvider.columns;
+ var new_cols = [];
+
+ angular.forEach(conf, function(col) {
+ var grid_col = columns.filter(
+ function(c) {return c.name == col.name})[0];
+
+ if (!grid_col) {
+ // saved column does not match a column in the
+ // current grid. skip it.
+ return;
+ }
+
+ grid_col.flex = col.flex || 2;
+ grid_col.sort = col.sort || 0;
+ // all saved columns are assumed to be true
+ grid_col.visible = true;
+ new_cols.push(grid_col);
+ });
+
+ // columns which are not expressed within the saved
+ // configuration are marked as non-visible and
+ // appended to the end of the new list of columns.
+ angular.forEach(columns, function(col) {
+ var found = conf.filter(
+ function(c) {return (c.name == col.name)})[0];
+ if (!found) {
+ col.visible = false;
+ new_cols.push(col);
+ }
+ });
+
+ grid.columnsProvider.columns = new_cols;
+ grid.compileSort();
+ });
+ }
+
+ $scope.onContextMenu = function($event) {
+ var col = angular.element($event.target).attr('column');
+ console.log('selected column ' + col);
+ }
+
+ $scope.page = function() {
+ return (grid.offset / grid.limit) + 1;
+ }
+
+ $scope.goToPage = function(page) {
+ page = Number(page);
+ if (angular.isNumber(page) && page > 0) {
+ grid.offset = (page - 1) * grid.limit;
+ grid.collect();
+ }
+ }
+
+ $scope.offset = function(o) {
+ if (angular.isNumber(o))
+ grid.offset = o;
+ return grid.offset
+ }
+
+ $scope.limit = function(l) {
+ if (angular.isNumber(l))
+ grid.limit = l;
+ return grid.limit
+ }
+
+ $scope.onFirstPage = function() {
+ return grid.offset == 0;
+ }
+
+ $scope.hasNextPage = function() {
+ // we have less data than requested, there must
+ // not be any more pages
+ if (grid.count() < grid.limit) return false;
+
+ // if the total count is not known, assume that a full
+ // page of data implies more pages are available.
+ if (grid.totalCount == -1) return true;
+
+ // we have a full page of data, but is there more?
+ return grid.totalCount > (grid.offset + grid.count());
+ }
+
+ $scope.incrementPage = function() {
+ grid.offset += grid.limit;
+ grid.collect();
+ }
+
+ $scope.decrementPage = function() {
+ if (grid.offset < grid.limit) {
+ grid.offset = 0;
+ } else {
+ grid.offset -= grid.limit;
+ }
+ grid.collect();
+ }
+
+ // number of items loaded for the current page of results
+ grid.count = function() {
+ return $scope.items.length;
+ }
+
+ // returns the unique identifier value for the provided item
+ // for internal consistency, indexValue is always coerced
+ // into a string.
+ grid.indexValue = function(item) {
+ if (angular.isObject(item)) {
+ if (item !== null) {
+ if (angular.isFunction(item[grid.indexField]))
+ return ''+item[grid.indexField]();
+ return ''+item[grid.indexField]; // flat data
+ }
+ }
+ // passed a non-object; assume it's an index
+ return ''+item;
+ }
+
+ // fires the action handler function for a context action
+ $scope.actionLauncher = function(action) {
+ if (!action.handler) {
+ console.error(
+ 'No handler specified for "' + action.label + '"');
+ return;
+ }
+
+ try {
+ action.handler(grid.getSelectedItems());
+ } catch(E) {
+ console.error('Error executing handler for "'
+ + action.label + '" => ' + E + "\n" + E.stack);
+ }
+ }
+
+ // returns the list of selected item objects
+ grid.getSelectedItems = function() {
+ return $scope.items.filter(
+ function(item) {
+ return Boolean($scope.selected[grid.indexValue(item)]);
+ }
+ );
+ }
+
+ grid.getItemByIndex = function(index) {
+ for (var i = 0; i < $scope.items.length; i++) {
+ var item = $scope.items[i];
+ if (grid.indexValue(item) == index)
+ return item;
+ }
+ }
+
+ // selects one row after deselecting all of the others
+ grid.selectOneItem = function(index) {
+ $scope.selected = {};
+ $scope.selected[index] = true;
+ }
+
+ // selects or deselects an item, without affecting the others.
+ // returns true if the item is selected; false if de-selected.
+ grid.toggleSelectOneItem = function(index) {
+ if ($scope.selected[index]) {
+ delete $scope.selected[index];
+ return false;
+ } else {
+ return $scope.selected[index] = true;
+ }
+ }
+
+ grid.selectAllItems = function() {
+ angular.forEach($scope.items, function(item) {
+ $scope.selected[grid.indexValue(item)] = true
+ });
+ }
+
+ $scope.$watch('selectAll', function(newVal) {
+ if (newVal) {
+ grid.selectAllItems();
+ } else {
+ $scope.selected = {};
+ }
+ });
+
+ // returns true if item1 appears in the list before item2;
+ // false otherwise. this is slightly more efficient that
+ // finding the position of each then comparing them.
+ // item1 / item2 may be an item or an item index
+ grid.itemComesBefore = function(itemOrIndex1, itemOrIndex2) {
+ var idx1 = grid.indexValue(itemOrIndex1);
+ var idx2 = grid.indexValue(itemOrIndex2);
+
+ // use for() for early exit
+ for (var i = 0; i < $scope.items.length; i++) {
+ var idx = grid.indexValue($scope.items[i]);
+ if (idx == idx1) return true;
+ if (idx == idx2) return false;
+ }
+ return false;
+ }
+
+ // 0-based position of item in the current data set
+ grid.indexOf = function(item) {
+ var idx = grid.indexValue(item);
+ for (var i = 0; i < $scope.items.length; i++) {
+ if (grid.indexValue($scope.items[i]) == idx)
+ return i;
+ }
+ return -1;
+ }
+
+ grid.modifyColumnFlex = function(column, val) {
+ column.flex += val;
+ // prevent flex:0; use hiding instead
+ if (column.flex < 1)
+ column.flex = 1;
+ }
+ $scope.modifyColumnFlex = function(col, val) {
+ grid.modifyColumnFlex(col, val);
+ }
+
+ // handles click, control-click, and shift-click
+ $scope.handleRowClick = function($event, item) {
+ var index = grid.indexValue(item);
+
+ var origSelected = Object.keys($scope.selected);
+
+ if ($event.ctrlKey || $event.metaKey /* mac command */) {
+ // control-click
+ if (grid.toggleSelectOneItem(index))
+ grid.lastSelectedItemIndex = index;
+
+ } else if ($event.shiftKey) {
+ // shift-click
+
+ if (!grid.lastSelectedItemIndex ||
+ index == grid.lastSelectedItemIndex) {
+ grid.selectOneItem(index);
+ grid.lastSelectedItemIndex = index;
+
+ } else {
+
+ var selecting = false;
+ var ascending = grid.itemComesBefore(
+ grid.lastSelectedItemIndex, item);
+ var startPos =
+ grid.indexOf(grid.lastSelectedItemIndex);
+
+ // update to new last-selected
+ grid.lastSelectedItemIndex = index;
+
+ // select each row between the last selected and
+ // currently selected items
+ while (true) {
+ startPos += ascending ? 1 : -1;
+ var curItem = $scope.items[startPos];
+ if (!curItem) break;
+ var curIdx = grid.indexValue(curItem);
+ $scope.selected[curIdx] = true;
+ if (curIdx == index) break; // all done
+ }
+ }
+
+ } else {
+ grid.selectOneItem(index);
+ grid.lastSelectedItemIndex = index;
+ }
+ }
+
+ // Builds a sort expression from column sort priorities.
+ // called on page load and any time the priorities are modified.
+ grid.compileSort = function() {
+ var sortList = grid.columnsProvider.columns.filter(
+ function(col) { return Number(col.sort) != 0 }
+ ).sort(
+ function(a, b) {
+ if (Math.abs(a.sort) < Math.abs(b.sort))
+ return -1;
+ return 1;
+ }
+ );
+
+ if (sortList.length) {
+ grid.dataProvider.sort = sortList.map(function(col) {
+ var blob = {};
+ blob[col.name] = col.sort < 0 ? 'desc' : 'asc';
+ return blob;
+ });
+ }
+ }
+
+ // builds a sort expression using a single column,
+ // toggling between ascending and descending sort.
+ $scope.quickSort = function(col_name) {
+ var sort = grid.dataProvider.sort;
+ if (sort && sort.length &&
+ sort[0] == col_name) {
+ var blob = {};
+ blob[col_name] = 'desc';
+ grid.dataProvider.sort = [blob];
+ } else {
+ grid.dataProvider.sort = [col_name];
+ }
+
+ grid.offset = 0;
+ grid.collect();
+ }
+
+ // show / hide the grid configuration row
+ $scope.toggleConfDisplay = function() {
+ if ($scope.showGridConf) {
+ $scope.showGridConf = false;
+ if (grid.columnsProvider.hasSortableColumn()) {
+ // only refresh the grid if the user has the
+ // ability to modify the sort priorities.
+ grid.compileSort();
+ grid.offset = 0;
+ grid.collect();
+ }
+ } else {
+ $scope.showGridConf = true;
+ }
+
+ $scope.gridColumnPickerIsOpen = false;
+ }
+
+ // called when a dragged column is dropped onto itself
+ // or any other column
+ grid.onColumnDrop = function(target) {
+ if (angular.isUndefined(target)) return;
+ if (target == grid.dragColumn) return;
+ var srcIdx, targetIdx, srcCol;
+ angular.forEach(grid.columnsProvider.columns,
+ function(col, idx) {
+ if (col.name == grid.dragColumn) {
+ srcIdx = idx;
+ srcCol = col;
+ } else if (col.name == target) {
+ targetIdx = idx;
+ }
+ }
+ );
+
+ if (srcIdx < targetIdx) targetIdx--;
+
+ // move src column from old location to new location in
+ // the columns array, then force a page refresh
+ grid.columnsProvider.columns.splice(srcIdx, 1);
+ grid.columnsProvider.columns.splice(targetIdx, 0, srcCol);
+ $scope.$apply();
+ }
+
+ // prepares a string for inclusion within a CSV document
+ // by escaping commas and quotes and removing newlines.
+ grid.csvDatum = function(str) {
+ str = ''+str;
+ if (!str) return '';
+ str = str.replace(/\n/g, '');
+ if (str.match(/\,/) || str.match(/"/)) {
+ str = str.replace(/"/g, '""');
+ str = '"' + str + '"';
+ }
+ return str;
+ }
+
+ // sets the download file name and inserts the current CSV
+ // into a Blob URL for browser download.
+ $scope.generateCSVExportURL = function() {
+ $scope.gridColumnPickerIsOpen = false;
+
+ // let the file name describe the grid
+ $scope.csvExportFileName =
+ ($scope.mainLabel || grid.persistKey || 'eg_grid_data')
+ .replace(/\s+/g, '_') + '_' + $scope.page();
+
+ // toss the CSV into a Blob and update the export URL
+ var csv = grid.generateCSV();
+ var blob = new Blob([csv], {type : 'text/plain'});
+ $scope.csvExportURL =
+ ($window.URL || $window.webkitURL).createObjectURL(blob);
+ }
+
+ $scope.printCSV = function() {
+ $scope.gridColumnPickerIsOpen = false;
+ egCore.hatch.print('default', 'text/plain', grid.generateCSV())
+ .then(function() { console.debug('print complete') });
+ }
+
+ // generates CSV for the currently visible grid contents
+ grid.generateCSV = function() {
+ var csvStr = '';
+ var colCount = grid.columnsProvider.columns.length;
+
+ // columns
+ angular.forEach(grid.columnsProvider.columns,
+ function(col) {
+ if (!col.visible) return;
+ csvStr += grid.csvDatum(col.label);
+ csvStr += ',';
+ }
+ );
+
+ csvStr = csvStr.replace(/,$/,'\n');
+
+ // items
+ angular.forEach($scope.items, function(item) {
+ angular.forEach(grid.columnsProvider.columns,
+ function(col) {
+ if (!col.visible) return;
+ // bare value
+ var val = grid.dataProvider.itemFieldValue(item, col);
+ // filtered value (dates, etc.)
+ val = $filter('egGridValueFilter')(val, col);
+ csvStr += grid.csvDatum(val);
+ csvStr += ',';
+ }
+ );
+ csvStr = csvStr.replace(/,$/,'\n');
+ });
+
+ return csvStr;
+ }
+
+ // Interpolate the value for column.linkpath within the context
+ // of the row item to generate the final link URL.
+ $scope.generateLinkPath = function(col, item) {
+ return egCore.strings.$replace(col.linkpath, {item : item});
+ }
+
+ // If a column provides its own HTML template, translate it,
+ // using the current item for the template scope.
+ // note: $sce is required to avoid security restrictions and
+ // is OK here, since the template comes directly from a
+ // local HTML template (not user input).
+ $scope.translateCellTemplate = function(col, item) {
+ var html = egCore.strings.$replace(col.template, {item : item});
+ return $sce.trustAsHtml(html);
+ }
+
+ $scope.collect = function() { grid.collect() }
+
+ // asks the dataProvider for a page of data
+ grid.collect = function() {
+
+ // avoid firing the collect if there is nothing to collect.
+ if (grid.selfManagedData && !grid.dataProvider.query) return;
+
+ if (grid.collecting) return; // avoid parallel collect()
+ grid.collecting = true;
+
+ console.debug('egGrid.collect() offset='
+ + grid.offset + '; limit=' + grid.limit);
+
+ // ensure all of our dropdowns are closed
+ // TODO: git rid of these and just use dropdown-toggle,
+ // which is more reliable.
+ $scope.gridColumnPickerIsOpen = false;
+ $scope.gridRowCountIsOpen = false;
+ $scope.gridPageSelectIsOpen = false;
+
+ $scope.items = [];
+ $scope.selected = {};
+ grid.dataProvider.get(grid.offset, grid.limit).then(
+ function() {
+ if (grid.controls.allItemsRetrieved)
+ grid.controls.allItemsRetrieved();
+ },
+ null,
+ function(item) {
+ if (item) {
+ $scope.items.push(item)
+ if (grid.controls.itemRetrieved)
+ grid.controls.itemRetrieved(item);
+ }
+ }).finally(function() {
+ console.debug('egGrid.collect() complete');
+ grid.collecting = false
+ });
+ }
+
+ grid.init();
+ }]
+ };
+})
+
+/**
+ * eg-grid-field : used for collecting custom field data from the templates.
+ * This directive does not direct display, it just passes data up to the
+ * parent grid.
+ */
+.directive('egGridField', function() {
+ return {
+ require : '^egGrid',
+ restrict : 'AE',
+ scope : {
+ name : '@', // required; unique name
+ path : '@', // optional; flesh path
+ label : '@', // optional; display label
+ flex : '@', // optional; default flex width
+ dateformat : '@', // optional: passed down to egGridValueFilter
+
+ // if a field is part of an IDL object, but we are unable to
+ // determine the class, because it's nested within a hash
+ // (i.e. we can't navigate directly to the object via the IDL),
+ // idlClass lets us specify the class. This is particularly
+ // useful for nested wildcard fields.
+ parentIdlClass : '@',
+
+ // optional: for non-IDL columns, specifying a datatype
+ // lets the caller control which display filter is used.
+ // datatype should match the standard IDL datatypes.
+ datatype : '@'
+ },
+ link : function(scope, element, attrs, egGridCtrl) {
+
+ // boolean fields are presented as value-less attributes
+ angular.forEach(
+ [
+ 'visible',
+ 'hidden',
+ 'sortable',
+ 'nonsortable',
+ 'multisortable',
+ 'nonmultisortable',
+ 'required' // if set, always fetch data for this column
+ ],
+ function(field) {
+ if (angular.isDefined(attrs[field]))
+ scope[field] = true;
+ }
+ );
+
+ // any HTML content within the field is its custom template
+ var tmpl = element.html();
+ if (tmpl && !tmpl.match(/^\s*$/))
+ scope.template = tmpl
+
+ egGridCtrl.columnsProvider.add(scope);
+ scope.$destroy();
+ }
+ };
+})
+
+/**
+ * eg-grid-action : used for specifying actions which may be applied
+ * to items within the grid.
+ */
+.directive('egGridAction', function() {
+ return {
+ require : '^egGrid',
+ restrict : 'AE',
+ transclude : true,
+ scope : {
+ label : '@', // Action label
+ handler : '=', // Action function handler
+ divider : '='
+ },
+ link : function(scope, element, attrs, egGridCtrl) {
+ egGridCtrl.addAction({
+ label : scope.label,
+ divider : scope.divider,
+ handler : scope.handler
+ });
+ scope.$destroy();
+ }
+ };
+})
+
+.factory('egGridColumnsProvider', ['egCore', function(egCore) {
+
+ function ColumnsProvider(args) {
+ var cols = this;
+ cols.columns = [];
+ cols.stockVisible = [];
+ cols.idlClass = args.idlClass;
+ cols.defaultToHidden = args.defaultToHidden;
+ cols.defaultToNoSort = args.defaultToNoSort;
+ cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
+
+ // resets column width, visibility, and sort behavior
+ // Visibility resets to the visibility settings defined in the
+ // template (i.e. the original egGridField values).
+ cols.reset = function() {
+ angular.forEach(cols.columns, function(col) {
+ col.flex = 2;
+ col.sort = 0;
+ if (cols.stockVisible.indexOf(col.name) > -1) {
+ col.visible = true;
+ } else {
+ col.visible = false;
+ }
+ });
+ }
+
+ // returns true if any columns are sortable
+ cols.hasSortableColumn = function() {
+ return cols.columns.filter(
+ function(col) {
+ return col.sortable || col.multisortable;
+ }
+ ).length > 0;
+ }
+
+ cols.showAllColumns = function() {
+ $scope.gridColumnPickerIsOpen = false;
+ angular.forEach(cols.columns, function(column) {
+ column.visible = true;
+ });
+ if (grid.selfManagedData) grid.collect();
+ }
+
+ cols.hideAllColumns = function() {
+ $scope.gridColumnPickerIsOpen = false;
+ angular.forEach(cols.columns, function(col) {
+ delete col.visible;
+ });
+ // note: no need to fetch new data if no columns are visible
+ }
+
+ cols.indexOf = function(name) {
+ for (var i = 0; i < cols.columns.length; i++) {
+ if (cols.columns[i].name == name)
+ return i;
+ }
+ return -1;
+ }
+
+ cols.findColumn = function(name) {
+ return cols.columns[cols.indexOf(name)];
+ }
+
+ cols.compileAutoColumns = function() {
+ var idl_class = egCore.idl.classes[cols.idlClass];
+
+ angular.forEach(
+ idl_class.fields.sort(
+ function(a, b) { return a.name < b.name ? -1 : 1 }),
+ function(field) {
+ if (field.virtual) return;
+ if (field.datatype == 'link' || field.datatype == 'org_unit') {
+ // if the field is a link and the linked class has a
+ // "selector" field specified, use the selector field
+ // as the display field for the columns.
+ // flattener will take care of the fleshing.
+ if (field['class']) {
+ var selector_field = egCore.idl.classes[field['class']].fields
+ .filter(function(f) { return Boolean(f.selector) })[0];
+ if (selector_field) {
+ field.path = field.name + '.' + selector_field.selector;
+ }
+ }
+ }
+ cols.add(field, true);
+ }
+ );
+ }
+
+ // if a column definition has a path with a wildcard, create
+ // columns for all non-virtual fields at the specified
+ // position in the path.
+ cols.expandPath = function(colSpec) {
+
+ var dotpath = colSpec.path.replace(/\.?\*$/,'');
+ var class_obj;
+
+ if (colSpec.parentIdlClass) {
+ class_obj = egCore.idl.classes[colSpec.parentIdlClass];
+
+ } else {
+
+ class_obj = egCore.idl.classes[cols.idlClass];
+ if (!class_obj) return;
+
+ var path_parts = dotpath.split(/\./);
+
+ // find the IDL class definition for the last element in the
+ // path before the .*
+ // an empty path_parts means expand the root class
+ if (path_parts) {
+ for (var path_idx in path_parts) {
+ var part = path_parts[path_idx];
+ var idl_field = class_obj.field_map[part];
+
+ // unless we're at the end of the list, this field should
+ // link to another class.
+ if (idl_field && idl_field['class'] && (
+ idl_field.datatype == 'link' ||
+ idl_field.datatype == 'org_unit')) {
+ class_obj = egCore.idl.classes[idl_field['class']];
+ } else {
+ if (path_idx < (path_parts.length - 1)) {
+ // we ran out of classes to hop through before
+ // we ran out of path components
+ console.error("egGrid: invalid IDL path: " + dotpath);
+ }
+ }
+ }
+ }
+ }
+
+ if (class_obj) {
+ angular.forEach(class_obj.fields, function(field) {
+
+ // Only show wildcard fields where we have data to show
+ // Virtual and un-fleshed links will not have any data.
+ if (field.virtual || (
+ field.datatype == 'link' || field.datatype == 'org_unit'))
+ return;
+
+ var col = cols.cloneFromScope(colSpec);
+ col.path = dotpath + '.' + field.name;
+ cols.add(col, false, true,
+ {idl_field : field, idl_class : class_obj});
+ });
+
+ } else {
+ console.error(
+ "egGrid: wildcard path does not resolve to an object: "
+ + dotpath);
+ }
+ }
+
+ // angular.clone(scopeObject) is not permittable. Manually copy
+ // the fields over that we need (so the scope object can go away).
+ cols.cloneFromScope = function(colSpec) {
+ return {
+ name : colSpec.name,
+ label : colSpec.label,
+ path : colSpec.path,
+ flex : Number(colSpec.flex) || 2,
+ sort : Number(colSpec.sort) || 0,
+ required : colSpec.required,
+ linkpath : colSpec.linkpath,
+ template : colSpec.template,
+ visible : colSpec.visible,
+ hidden : colSpec.hidden,
+ datatype : colSpec.datatype,
+ sortable : colSpec.sortable,
+ nonsortable : colSpec.nonsortable,
+ multisortable : colSpec.multisortable,
+ nonmultisortable : colSpec.nonmultisortable,
+ dateformat : colSpec.dateformat,
+ parentIdlClass : colSpec.parentIdlClass
+ };
+ }
+
+
+ // Add a column to the columns collection.
+ // Columns may come from a slim eg-columns-field or
+ // directly from the IDL.
+ cols.add = function(colSpec, fromIDL, fromExpand, idl_info) {
+
+ // First added column with the specified path takes precedence.
+ // This allows for specific definitions followed by wildcard
+ // definitions. If a match is found, back out.
+ if (cols.columns.filter(function(c) {
+ return (c.path == colSpec.path) })[0]) {
+ //console.debug('skipping column ' + colSpec.path);
+ return;
+ }
+
+ var column = fromExpand ? colSpec : cols.cloneFromScope(colSpec);
+
+ if (column.path && column.path.match(/\*$/))
+ return cols.expandPath(colSpec);
+
+ if (!column.name) column.name = column.path;
+ if (!column.path) column.path = column.name;
+
+ if (column.visible || (!cols.defaultToHidden && !column.hidden))
+ column.visible = true;
+
+ if (column.sortable || (!cols.defaultToNoSort && !column.nonsortable))
+ column.sortable = true;
+
+ if (column.multisortable ||
+ (!cols.defaultToNoMultiSort && !column.nonmultisortable))
+ column.multisortable = true;
+
+ cols.columns.push(column);
+
+ // Track which columns are visible by default in case we
+ // need to reset column visibility
+ if (column.visible)
+ cols.stockVisible.push(column.name);
+
+ if (fromIDL) return; // directly from egIDL. nothing left to do.
+
+ // lookup the matching IDL field
+ if (!idl_info && cols.idlClass)
+ idl_info = cols.idlFieldFromPath(column.path);
+
+ if (!idl_info) {
+ // column is not represented within the IDL
+ column.adhoc = true;
+ return;
+ }
+
+ column.datatype = idl_info.idl_field.datatype;
+
+ if (!column.label) {
+ column.label = idl_info.idl_field.label || column.name;
+ /*
+ // append class label to column label to better differentiate
+ // columns in the selector.
+ // Disabled for now, since it results in columns w/ really
+ // long names, making the grid unappealing when any of
+ // these colmns are selected.
+ // TODO: consider nesting the colum picker by class?
+ if (fromExpand) {
+ var label =
+ idl_info.idl_class.label || idl_info.idl_class.name;
+ column.label = label + '::' + column.label;
+ }
+ */
+ }
+ },
+
+ // finds the IDL field from the dotpath, using the columns
+ // idlClass as the base.
+ cols.idlFieldFromPath = function(dotpath) {
+ var class_obj = egCore.idl.classes[cols.idlClass];
+ var path_parts = dotpath.split(/\./);
+
+ var idl_field;
+ for (var path_idx in path_parts) {
+ var part = path_parts[path_idx];
+ idl_field = class_obj.field_map[part];
+
+ if (idl_field && idl_field['class'] && (
+ idl_field.datatype == 'link' ||
+ idl_field.datatype == 'org_unit')) {
+ class_obj = egCore.idl.classes[idl_field['class']];
+ }
+ // else, path is not in the IDL, which is fine
+ }
+
+ if (!idl_field) return null;
+
+ return {
+ idl_field :idl_field,
+ idl_class : class_obj
+ };
+ }
+ }
+
+ return {
+ instance : function(args) { return new ColumnsProvider(args) }
+ }
+}])
+
+
+/*
+ * Generic data provider template class. This is basically an abstract
+ * class factory service whose instances can be locally modified to
+ * meet the needs of each individual grid.
+ */
+.factory('egGridDataProvider',
+ ['$q','$timeout','$filter','egCore',
+ function($q , $timeout , $filter , egCore) {
+
+ function GridDataProvider(args) {
+ var gridData = this;
+ if (!args) args = {};
+
+ gridData.sort = [];
+ gridData.get = args.get;
+ gridData.query = args.query;
+ gridData.idlClass = args.idlClass;
+ gridData.columnsProvider = args.columnsProvider;
+
+ // Delivers a stream of array data via promise.notify()
+ // Useful for passing an array of data to egGrid.get()
+ // If a count is provided, the array will be trimmed to
+ // the range defined by count and offset
+ gridData.arrayNotifier = function(arr, offset, count) {
+ if (!arr || arr.length == 0) return $q.when();
+ if (count) arr = arr.slice(offset, offset + count);
+ var def = $q.defer();
+ // promise notifications are only witnessed when delivered
+ // after the caller has his hands on the promise object
+ $timeout(function() {
+ angular.forEach(arr, def.notify);
+ def.resolve();
+ });
+ return def.promise;
+ }
+
+ // Calls the grid refresh function. Once instantiated, the
+ // grid will replace this function with it's own refresh()
+ gridData.refresh = function(noReset) { }
+
+ if (!gridData.get) {
+ // returns a promise whose notify() delivers items
+ gridData.get = function(index, count) {
+ console.error("egGridDataProvider.get() not implemented");
+ }
+ }
+
+ // attempts a flat field lookup first. If the column is not
+ // found on the top-level object, attempts a nested lookup
+ // TODO: consider a caching layer to speed up template
+ // rendering, particularly for nested objects?
+ gridData.itemFieldValue = function(item, column) {
+ if (column.name in item) {
+ if (typeof item[column.name] == 'function') {
+ return item[column.name]();
+ } else {
+ return item[column.name];
+ }
+ } else {
+ return gridData.nestedItemFieldValue(item, column);
+ }
+ }
+
+ // TODO: deprecate me
+ gridData.flatItemFieldValue = function(item, column) {
+ console.warn('gridData.flatItemFieldValue deprecated; '
+ + 'leave provider.itemFieldValue unset');
+ return item[column.name];
+ }
+
+ // given an object and a dot-separated path to a field,
+ // extract the value of the field. The path can refer
+ // to function names or object attributes. If the final
+ // value is an IDL field, run the value through its
+ // corresponding output filter.
+ gridData.nestedItemFieldValue = function(obj, column) {
+ if (obj === null || obj === undefined || obj === '') return '';
+ if (!column.path) return obj;
+
+ var idl_field;
+ var parts = column.path.split('.');
+
+ angular.forEach(parts, function(step, idx) {
+ // object is not fleshed to the expected extent
+ if (!obj || typeof obj != 'object') {
+ obj = '';
+ return;
+ }
+
+ var cls = obj.classname;
+ if (cls && (class_obj = egCore.idl.classes[cls])) {
+ idl_field = class_obj.field_map[step];
+ obj = obj[step] ? obj[step]() : '';
+ } else {
+ if (angular.isFunction(obj[step])) {
+ obj = obj[step]();
+ } else {
+ obj = obj[step];
+ }
+ }
+ });
+
+ // We found a nested IDL object which may or may not have
+ // been configured as a top-level column. Grab the datatype.
+ if (idl_field && !column.datatype)
+ column.datatype = idl_field.datatype;
+
+ if (obj === null || obj === undefined || obj === '') return '';
+ return obj;
+ }
+ }
+
+ return {
+ instance : function(args) {
+ return new GridDataProvider(args);
+ }
+ };
+ }
+])
+
+
+// Factory service for egGridDataManager instances, which are
+// responsible for collecting flattened grid data.
+.factory('egGridFlatDataProvider',
+ ['$q','egCore','egGridDataProvider',
+ function($q , egCore , egGridDataProvider) {
+
+ return {
+ instance : function(args) {
+ var provider = egGridDataProvider.instance(args);
+
+ provider.get = function(offset, count) {
+
+ // no query means no call
+ if (!provider.query ||
+ angular.equals(provider.query, {}))
+ return $q.when();
+
+ // find all of the currently visible columns
+ var queryFields = {}
+ angular.forEach(provider.columnsProvider.columns,
+ function(col) {
+ // only query IDL-tracked columns
+ if (!col.adhoc && (col.required || col.visible))
+ queryFields[col.name] = col.path;
+ }
+ );
+
+ return egCore.net.request(
+ 'open-ils.fielder',
+ 'open-ils.fielder.flattened_search',
+ egCore.auth.token(), provider.idlClass,
+ queryFields, provider.query,
+ { sort : provider.sort,
+ limit : count,
+ offset : offset
+ }
+ );
+ }
+ //provider.itemFieldValue = provider.flatItemFieldValue;
+ return provider;
+ }
+ };
+ }
+])
+
+.directive('egGridColumnDragSource', function() {
+ return {
+ restrict : 'A',
+ require : '^egGrid',
+ link : function(scope, element, attrs, egGridCtrl) {
+ angular.element(element).attr('draggable', 'true');
+
+ element.bind('dragstart', function(e) {
+ egGridCtrl.dragColumn = attrs.column;
+ egGridCtrl.dragType = attrs.dragType || 'move'; // or resize
+ egGridCtrl.colResizeDir = 0;
+
+ if (egGridCtrl.dragType == 'move') {
+ // style the column getting moved
+ angular.element(e.target).addClass(
+ 'eg-grid-column-move-handle-active');
+ }
+ });
+
+ element.bind('dragend', function(e) {
+ if (egGridCtrl.dragType == 'move') {
+ angular.element(e.target).removeClass(
+ 'eg-grid-column-move-handle-active');
+ }
+ });
+ }
+ };
+})
+
+.directive('egGridColumnDragDest', function() {
+ return {
+ restrict : 'A',
+ require : '^egGrid',
+ link : function(scope, element, attrs, egGridCtrl) {
+
+ element.bind('dragover', function(e) { // required for drop
+ e.stopPropagation();
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+
+ if (egGridCtrl.colResizeDir == 0) return; // move
+
+ var cols = egGridCtrl.columnsProvider;
+ var srcCol = egGridCtrl.dragColumn;
+ var srcColIdx = cols.indexOf(srcCol);
+
+ if (egGridCtrl.colResizeDir == -1) {
+ if (cols.indexOf(attrs.column) <= srcColIdx) {
+ egGridCtrl.modifyColumnFlex(
+ egGridCtrl.columnsProvider.findColumn(
+ egGridCtrl.dragColumn), -1);
+ if (cols.columns[srcColIdx+1]) {
+ // source column shrinks by one, column to the
+ // right grows by one.
+ egGridCtrl.modifyColumnFlex(
+ cols.columns[srcColIdx+1], 1);
+ }
+ scope.$apply();
+ }
+ } else {
+ if (cols.indexOf(attrs.column) > srcColIdx) {
+ egGridCtrl.modifyColumnFlex(
+ egGridCtrl.columnsProvider.findColumn(
+ egGridCtrl.dragColumn), 1);
+ if (cols.columns[srcColIdx+1]) {
+ // source column grows by one, column to the
+ // right grows by one.
+ egGridCtrl.modifyColumnFlex(
+ cols.columns[srcColIdx+1], -1);
+ }
+
+ scope.$apply();
+ }
+ }
+ });
+
+ element.bind('dragenter', function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ if (egGridCtrl.dragType == 'move') {
+ angular.element(e.target).addClass('eg-grid-col-hover');
+ } else {
+ // resize grips are on the right side of each column.
+ // dragenter will either occur on the source column
+ // (dragging left) or the column to the right.
+ if (egGridCtrl.colResizeDir == 0) {
+ if (egGridCtrl.dragColumn == attrs.column) {
+ egGridCtrl.colResizeDir = -1; // west
+ } else {
+ egGridCtrl.colResizeDir = 1; // east
+ }
+ }
+ }
+ });
+
+ element.bind('dragleave', function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ if (egGridCtrl.dragType == 'move') {
+ angular.element(e.target).removeClass('eg-grid-col-hover');
+ }
+ });
+
+ element.bind('drop', function(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ egGridCtrl.colResizeDir = 0;
+ if (egGridCtrl.dragType == 'move') {
+ angular.element(e.target).removeClass('eg-grid-col-hover');
+ egGridCtrl.onColumnDrop(attrs.column); // move the column
+ }
+ });
+ }
+ };
+})
+
+.directive('egGridMenuItem', function() {
+ return {
+ restrict : 'AE',
+ require : '^egGrid',
+ scope : {
+ label : '@',
+ handler : '=', // onclick handler function
+ divider : '=', // if true, show a divider only
+ handlerData : '=', // if set, passed as second argument to handler
+ disabled : '=', // function
+ hidden : '=' // function
+ },
+ link : function(scope, element, attrs, egGridCtrl) {
+ egGridCtrl.addMenuItem({
+ label : scope.label,
+ handler : scope.handler,
+ divider : scope.divider,
+ disabled : scope.disabled,
+ hidden : scope.hidden,
+ handlerData : scope.handlerData
+ });
+ scope.$destroy();
+ }
+ };
+})
+
+
+
+/**
+ * Translates bare IDL object values into display values.
+ * 1. Passes dates through the angular date filter
+ * 2. Translates bools to Booleans so the browser can display translated
+ * value. (Though we could manually translate instead..)
+ * Others likely to follow...
+ */
+.filter('egGridValueFilter', ['$filter', function($filter) {
+ return function(value, column) {
+ switch(column.datatype) {
+ case 'bool':
+ switch(value) {
+ // Browser will translate true/false for us
+ case 't' :
+ case '1' : // legacy
+ case true:
+ return ''+true;
+ case 'f' :
+ case '0' : // legacy
+ case false:
+ return ''+false;
+ // value may be null, '', etc.
+ default : return '';
+ }
+ case 'timestamp':
+ // canned angular date filter FTW
+ if (!column.dateformat)
+ column.dateformat = 'shortDate';
+ return $filter('date')(value, column.dateformat);
+ case 'money':
+ return $filter('currency')(value);
+ default:
+ return value;
+ }
+ }
+}]);
+
--- /dev/null
+/**
+ * Core Service - egHatch
+ *
+ * Dispatches print and data storage requests to the appropriate handler.
+ *
+ * With each top-level request, if a connection to Hatch is established,
+ * the request is relayed. If a connection has not been attempted, an
+ * attempt is made then the request is handled. If Hatch is known to be
+ * inaccessible, requests are routed to local handlers.
+ *
+ * Most handlers also provide direct remote and local variants to the
+ * application can decide to which to use as needed.
+ *
+ * Local storage requests are handled by $window.localStorage.
+ *
+ * Note that all top-level and remote requests return promises. All
+ * local requests return immediate values, since local requests are
+ * never asynchronous.
+ *
+ * BEWARE: never store "fieldmapper" objects, since their structure
+ * may change over time as the IDL changes. Always flatten objects
+ * into key/value pairs before calling set*Item()
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egHatch',
+ ['$q','$window','$timeout','$interpolate','$http',
+ function($q , $window , $timeout , $interpolate , $http) {
+
+ var service = {};
+ service.msgId = 0;
+ service.messages = {};
+ service.pending = [];
+ service.socket = null;
+ service.hatchAvailable = null;
+ service.defaultHatchURL = 'wss://localhost:8443/hatch';
+
+ // write a message to the Hatch websocket
+ service.sendToHatch = function(msg) {
+ var msg2 = {};
+
+ // shallow copy and scrub msg before sending
+ angular.forEach(msg, function(val, key) {
+ if (key.match(/deferred/)) return;
+ msg2[key] = val;
+ });
+
+ console.debug("sending to Hatch: " + JSON.stringify(msg2,null,2));
+ service.socket.send(JSON.stringify(msg2));
+ }
+
+ // Send the request to Hatch if it's available.
+ // Otherwise handle the request locally.
+ service.attemptHatchDelivery = function(msg) {
+
+ msg.msgid = service.msgId++;
+ msg.deferred = $q.defer();
+
+ if (service.hatchAvailable === false) { // Hatch is closed
+ msg.deferred.reject(msg);
+
+ } else if (service.hatchAvailable === true) { // Hatch is open
+ // Hatch is known to be open
+ service.messages[msg.msgid] = msg;
+ service.sendToHatch(msg);
+
+ } else { // Hatch status unknown; attempt to connect
+ service.messages[msg.msgid] = msg;
+ service.pending.push(msg);
+ service.hatchConnect();
+ }
+
+ return msg.deferred.promise;
+ }
+
+
+ // resolve the promise on the given request and remove
+ // it from our tracked requests.
+ service.resolveRequest = function(msg) {
+
+ if (!service.messages[msg.msgid]) {
+ console.warn('no cached message for '
+ + msg.msgid + ' : ' + JSON.stringify(msg, null, 2));
+ return;
+ }
+
+ // for requests sent through Hatch, only the cached
+ // request will have the original promise attached
+ msg.deferred = service.messages[msg.msgid].deferred;
+ delete service.messages[msg.msgid]; // un-cache
+
+ // resolve / reject
+ if (msg.error) {
+ throw new Error(
+ "egHatch command failed : "
+ + JSON.stringify(msg.error, null, 2));
+ } else {
+ msg.deferred.resolve(msg.content);
+ }
+ }
+
+ service.hatchClosed = function() {
+ service.socket = null;
+ service.printers = [];
+ service.printConfig = {};
+ while ( (msg = service.pending.shift()) ) {
+ msg.deferred.reject(msg);
+ delete service.messages[msg.msgid];
+ }
+ if (service.onHatchClose)
+ service.onHatchClose();
+ }
+
+ service.hatchURL = function() {
+ return service.getLocalItem('eg.hatch.url')
+ || service.defaultHatchURL;
+ }
+
+ // Returns true if Hatch is required or if we are currently
+ // communicating with the Hatch service.
+ service.usingHatch = function() {
+ return service.hatchAvailable || service.hatchRequired();
+ }
+
+ // Returns true if this browser (via localStorage) is
+ // configured to require Hatch.
+ service.hatchRequired = function() {
+ return service.getLocalItem('eg.hatch.required');
+ }
+
+ service.hatchConnect = function() {
+
+ if (service.socket &&
+ service.socket.readyState == service.socket.CONNECTING) {
+ // connection in progress. Nothing to do. Our queued
+ // message will be delivered when onopen() fires
+ return;
+ }
+
+ try {
+ service.socket = new WebSocket(service.hatchURL());
+ } catch(e) {
+ service.hatchAvailable = false;
+ service.hatchClosed();
+ return;
+ }
+
+ service.socket.onopen = function() {
+ console.debug('connected to Hatch');
+ service.hatchAvailable = true;
+ if (service.onHatchOpen)
+ service.onHatchOpen();
+ while ( (msg = service.pending.shift()) ) {
+ service.sendToHatch(msg);
+ };
+ }
+
+ service.socket.onclose = function() {
+ if (service.hatchAvailable === false) return; // already registered
+
+ // onclose() will be called regularly as we disconnect from
+ // Hatch via timeouts. Return hatchAvailable to its unknow state
+ service.hatchAvailable = null;
+ service.hatchClosed();
+ }
+
+ service.socket.onerror = function() {
+ if (service.hatchAvailable === false) return; // already registered
+ service.hatchAvailable = false;
+ console.debug(
+ "unable to connect to Hatch server at " + service.hatchURL());
+ service.hatchClosed();
+ }
+
+ service.socket.onmessage = function(evt) {
+ var msgStr = evt.data;
+ if (!msgStr) throw new Error("Hatch returned empty message");
+
+ var msgObj = JSON.parse(msgStr);
+ console.debug('Hatch says ' + JSON.stringify(msgObj, null, 2));
+ service.resolveRequest(msgObj);
+ }
+ }
+
+ service.getPrintConfig = function() {
+ if (service.printConfig)
+ return $q.when(service.printConfig);
+
+ return service.getRemoteItem('eg.print.config')
+ .then(function(conf) {
+ return (service.printConfig = conf || {})
+ });
+ }
+
+ service.setPrintConfig = function(conf) {
+ service.printConfig = conf;
+ return service.setRemoteItem('eg.print.config', conf);
+ }
+
+
+ service.remotePrint = function(
+ context, contentType, content, withDialog) {
+
+ return service.getPrintConfig().then(
+ function(conf) {
+ // print configuration retrieved; print
+ return service.attemptHatchDelivery({
+ action : 'print',
+ config : conf[context],
+ content : content,
+ contentType : contentType,
+ showDialog : withDialog,
+ });
+ }
+ );
+ }
+
+ // launch the print dialog then attach the resulting configuration
+ // to the requested context, then store the final values.
+ service.configurePrinter = function(context, printer) {
+
+ // load current settings
+ return service.getPrintConfig()
+
+ // dispatch the print configuration request
+ .then(function(config) {
+
+ // loaded remote config
+ if (!config[context]) config[context] = {};
+ config[context].printer = printer;
+ return service.attemptHatchDelivery({
+ key : 'no-op',
+ action : 'print-config',
+ config : config[context]
+ })
+ })
+
+ // set the returned settings to the requested context
+ .then(function(newconf) {
+ if (angular.isObject(newconf)) {
+ newconf.printer = printer;
+ return service.printConfig[context] = newconf;
+ } else {
+ console.warn("configurePrinter() returned " + newconf);
+ }
+ })
+
+ // store the newly linked settings
+ .then(function() {
+ service.setItem('eg.print.config', service.printConfig);
+ })
+
+ // return the final settings to the caller
+ .then(function() {return service.printConfig});
+ }
+
+ service.getPrinters = function() {
+ if (service.printers) // cached printers
+ return $q.when(service.printers);
+
+ return service.attemptHatchDelivery({action : 'printers'}).then(
+
+ // we have remote printers; sort by name and return
+ function(printers) {
+ service.printers = printers.sort(
+ function(a,b) {return a.name < b.name ? -1 : 1});
+ return service.printers;
+ },
+
+ // remote call failed and there is no such thing as local
+ // printers; return empty set.
+ function() { return [] }
+ );
+ }
+
+ // get the value for a stored item
+ service.getItem = function(key) {
+ return service.getRemoteItem(key)['catch'](
+ function(msg) {
+ if (service.hatchRequired()) {
+ console.error("Unable to getItem: " + key
+ + "; hatchRequired=true, but hatch is not connected");
+ return null;
+ }
+ return service.getLocalItem(msg.key);
+ }
+ );
+ }
+
+ service.getRemoteItem = function(key) {
+ return service.attemptHatchDelivery({
+ key : key,
+ action : 'get',
+ });
+ }
+
+ service.getLocalItem = function(key) {
+ var val = $window.localStorage.getItem(key);
+ if (val == null) return;
+ return JSON.parse(val);
+ }
+
+ service.setItem = function(key, value) {
+ var str = JSON.stringify(value);
+ return service.setRemoteItem(key, str)['catch'](
+ function(msg) {
+ if (service.hatchRequired()) {
+ console.error("Unable to setItem: " + key
+ + "; hatchRequired=true, but hatch is not connected");
+ return null;
+ }
+ return service.setLocalItem(msg.key, null, str);
+ }
+ );
+ }
+
+ // set the value for a stored or new item
+ service.setRemoteItem = function(key, value) {
+ return service.attemptHatchDelivery({
+ key : key,
+ value : value,
+ action : 'set',
+ });
+ }
+
+ // Set the value for the given key
+ // If the value is raw, pass it as 'value'. If it was
+ // externally JSONified, pass it via jsonified.
+ service.setLocalItem = function(key, value, jsonified) {
+ if (jsonified === undefined )
+ jsonified = JSON.stringify(value);
+ $window.localStorage.setItem(key, jsonified);
+ }
+
+ // appends the value to the existing item stored at key.
+ // If not item is found at key, this behaves just like setItem()
+ service.appendItem = function(key, value) {
+ return service.appendRemoteItem(key, value)['catch'](
+ function(msg) {
+ if (service.hatchRequired()) {
+ console.error("Unable to appendItem: " + key
+ + "; hatchRequired=true, but hatch is not connected");
+ return null;
+ }
+ service.appendLocalItem(msg.key, msg.value);
+ }
+ );
+ }
+
+ service.appendRemoteItem = function(key, value) {
+ return service.attemptHatchDelivery({
+ key : key,
+ value : value,
+ action : 'append',
+ });
+ }
+
+ // assumes the appender and appendee are both strings
+ // TODO: support arrays as well
+ service.appendLocalItem = function(key, value) {
+ var item = service.getLocalItem(key);
+ if (item) {
+ if (typeof item != 'string') {
+ logger.warn("egHatch.appendLocalItem => "
+ + "cannot append to a non-string item: " + key);
+ return;
+ }
+ value = item + value; // concatenate our value
+ }
+ service.setLocalitem(key, value);
+ }
+
+ // remove a stored item
+ service.removeItem = function(key) {
+ return service.removeRemoteItem(key)['catch'](
+ function(msg) {
+ return service.removeLocalItem(msg.key)
+ }
+ );
+ }
+
+ service.removeRemoteItem = function(key) {
+ return service.attemptHatchDelivery({
+ key : key,
+ action : 'remove'
+ });
+ }
+
+ service.removeLocalItem = function(key) {
+ $window.localStorage.removeItem(key);
+ }
+
+ // if set, prefix limits the return set to keys starting with 'prefix'
+ service.getKeys = function(prefix) {
+ return service.getRemoteKeys(prefix)['catch'](
+ function() {
+ if (service.hatchRequired()) {
+ console.error("Unable to get pref keys; "
+ + "hatchRequired=true, but hatch is not connected");
+ return [];
+ }
+ return service.getLocalKeys(prefix)
+ }
+ );
+ }
+
+ service.getRemoteKeys = function(prefix) {
+ return service.attemptHatchDelivery({
+ key : prefix,
+ action : 'keys'
+ });
+ }
+
+ service.getLocalKeys = function(prefix) {
+ var keys = [];
+ var idx = 0;
+ while ( (k = $window.localStorage.key(idx++)) !== null) {
+ // key prefix match test
+ if (prefix && k.substr(0, prefix.length) != prefix) continue;
+ keys.push(k);
+ }
+ return keys;
+ }
+
+ return service;
+}])
+
--- /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;
+
+ // keep this reference around (note: not a clone, just a ref)
+ // so that unit tests, which repeatedly instantiate the
+ // service will work.
+ //$window._preload_fieldmapper_IDL = null;
+
+ /**
+ * Creates the class constructor and getter/setter
+ * methods for each IDL class.
+ */
+ function mkclass(cls, fields) {
+
+ service[cls] = function(seed) {
+ this.a = seed || [];
+ this.classname = cls;
+ this._isfieldmapper = true;
+ }
+
+ /** creates the getter/setter methods for each field */
+ angular.forEach(fields, function(field, idx) {
+ service[cls].prototype[fields[idx].name] = function(n) {
+ if (arguments.length==1) this.a[idx] = n;
+ return this.a[idx];
+ }
+ });
+
+ // global class constructors required for JSON_v1.js
+ $window[cls] = service[cls];
+ }
+
+ for (var cls in service.classes)
+ mkclass(cls, service.classes[cls].fields);
+ };
+
+ /**
+ * Generate a hash version of an IDL object.
+ *
+ * Flatten determines if nested objects should be squashed into
+ * the top-level hash.
+ *
+ * If 'flatten' is false, e.g.:
+ *
+ * {"call_number" : {"label" : "foo"}}
+ *
+ * If 'flatten' is true, e.g.:
+ *
+ * e.g. {"call_number.label" : "foo"}
+ */
+ service.toHash = function(obj, flatten) {
+ if (!angular.isObject(obj)) return obj; // arrays are objects
+
+ if (angular.isArray(obj)) { // NOTE: flatten arrays not supported
+ return obj.map(function(item) {return service.toHash(item)});
+ }
+
+ var field_names = obj.classname ?
+ Object.keys(service.classes[obj.classname].field_map) :
+ Object.keys(obj);
+
+ var hash = {};
+ angular.forEach(
+ field_names,
+ function(field) {
+
+ var val = service.toHash(
+ angular.isFunction(obj[field]) ?
+ obj[field]() : obj[field],
+ flatten
+ );
+
+ if (flatten && angular.isObject(val)) {
+ angular.forEach(val, function(sub_val, key) {
+ var fname = field + '.' + key;
+ hash[fname] = sub_val;
+ });
+
+ } else if (val !== undefined) {
+ hash[field] = val;
+ }
+ }
+ );
+
+ return hash;
+ }
+
+ // Transforms a flattened hash (see toHash() or egGridFlatDataProvider)
+ // to a nested hash.
+ //
+ // e.g. {"call_number.label" : "foo"} => {"call_number":{"label":"foo"}}
+ service.flatToNestedHash = function(obj) {
+ var hash = {};
+ angular.forEach(obj, function(val, key) {
+ var parts = key.split('.');
+ var sub_hash = hash;
+ var last_key;
+ for (var i = 0; i < parts.length; i++) {
+ var part = parts[i];
+ if (i == parts.length - 1) {
+ sub_hash[part] = val;
+ break;
+ } else {
+ if (!sub_hash[part])
+ sub_hash[part] = {};
+ sub_hash = sub_hash[part];
+ }
+ }
+ });
+
+ return hash;
+ }
+
+ return service;
+}]);
+
--- /dev/null
+angular.module('egCoreMod')
+
+.directive('egNavbar', function() {
+ return {
+ restrict : 'AE',
+ transclude : true,
+ templateUrl : 'eg-navbar-template',
+ link : function(scope, element, attrs) {
+
+ // Find all eg-accesskey entries within the menu and attach
+ // hotkey handlers for each.
+ // jqlite doesn't support selectors, so we have to
+ // manually navigate to the elements we're interested in.
+ function inspect(elm) {
+ elm = angular.element(elm);
+ if (elm.attr('eg-accesskey')) {
+ scope.addHotkey(
+ elm.attr('eg-accesskey'),
+ elm.attr('href'),
+ elm.attr('eg-accesskey-desc')
+ );
+ }
+ angular.forEach(elm.children(), inspect);
+ }
+ inspect(element);
+ },
+
+ controller:['$scope','$window','$location','hotkeys','egCore',
+ function($scope , $window , $location , hotkeys , egCore) {
+
+ function navTo(path) {
+ // $location.path() does not want a leading ".",
+ // which <a>'s will have.
+ // Note: avoid using $location.path() to derive the new
+ // URL, since it creates an intermediate path change.
+ path = path.replace(/^\./,'');
+ var reg = new RegExp($location.path());
+ $window.location.href =
+ $window.location.href.replace(reg, path);
+ }
+
+ // adds a keyboard shortcut
+ // http://chieffancypants.github.io/angular-hotkeys/
+ $scope.addHotkey = function(key, path, desc) {
+ hotkeys.add(key, desc, function() { navTo(path) });
+ };
+
+ $scope.applyLocale = function(locale) {
+ // EGWeb.pm can change the locale for us w/ the right param
+ // Note: avoid using $location.search() to derive a new
+ // URL, since it creates an intermediate path change.
+ // Instead, use the ham-fisted approach of killing any
+ // search args and applying the args we want.
+ $window.location.href =
+ $window.location.href.replace(
+ /(\?|\&).*/,
+ '?set_eg_locale=' + encodeURIComponent(locale)
+ );
+ }
+
+ // tied to logout link
+ $scope.logout = function() {
+ egCore.auth.logout();
+ return true;
+ };
+
+ egCore.startup.go().then(
+ function() {
+ if (egCore.auth.user()) {
+ $scope.username = egCore.auth.user().usrname();
+ $scope.workstation = egCore.auth.workstation();
+ }
+ }
+ );
+ }
+ ]
+ }
+});
+
--- /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') {
+ $rootScope.$broadcast('egAuthExpired')
+ } else {
+ return content;
+ }
+ };
+
+ net.request = function(service, method) {
+ var last;
+ var deferred = $q.defer();
+ var params = Array.prototype.slice.call(arguments, 2);
+ console.debug('egNet ' + method);
+ new OpenSRF.ClientSession(service).request({
+ async : true,
+ method : method,
+ params : params,
+ oncomplete : function() {
+ deferred.resolve(last);
+ },
+ onresponse : function(r) {
+ last = net.checkResponse(r.recv());
+ deferred.notify(last);
+ },
+ onerror : function(msg) {
+ // 'msg' currently tells us very little, so don't
+ // bother JSON-ifying it, since there is the off
+ // chance that JSON-ification could fail, e.g if
+ // the object has circular refs.
+ console.error(method +
+ ' (' + params + ') failed. See server logs.');
+ deferred.reject(msg);
+ },
+ onmethoderror : function(req, statCode, statMsg) {
+ console.error('error calling method ' +
+ method + ' : ' + statCode + ' : ' + statMsg);
+ }
+
+ }).send();
+
+ return deferred.promise;
+ }
+
+ // In addition to the service and method names, accepts a single array
+ // as the collection of API call parameters. This array will get
+ // expanded to individual arguments in the final server call.
+ // This is useful when the server call expects each param to be
+ // a top-level value, but the set of params is dynamic.
+ net.requestWithParamList = function(service, method, params) {
+ var args = [service, method].concat(params);
+ return net.request.apply(net, args);
+ }
+
+ return net;
+}]);
--- /dev/null
+/**
+ * Core Service - egOrg
+ *
+ * TODO: more docs
+ */
+angular.module('egCoreMod')
+
+.factory('egOrg',
+ ['$q','egEnv','egAuth','egNet',
+function($q, egEnv, egAuth, egNet) {
+
+ var service = {};
+
+ // org unit settings cache.
+ // This allows the caller to avoid local caches
+ service.cachedSettings = {};
+
+ service.get = function(node_or_id) {
+ if (typeof node_or_id == 'object')
+ return node_or_id;
+ return egEnv.aou.map[node_or_id];
+ };
+
+ service.list = function() {
+ return egEnv.aou.list;
+ };
+
+ service.tree = function() {
+ return egEnv.aou.tree;
+ }
+
+ // list of org_unit objects or IDs for ancestors + me
+ service.ancestors = function(node_or_id, as_id) {
+ var node = service.get(node_or_id);
+ if (!node) return [];
+ var nodes = [node];
+ while( (node = service.get(node.parent_ou())))
+ nodes.push(node);
+ if (as_id)
+ return nodes.map(function(n){return n.id()});
+ return nodes;
+ };
+
+ // list of org_unit objects or IDs for me + descendants
+ service.descendants = function(node_or_id, as_id) {
+ var node = service.get(node_or_id);
+ if (!node) return [];
+ var nodes = [];
+ function descend(n) {
+ nodes.push(n);
+ angular.forEach(n.children(), descend);
+ }
+ descend(node);
+ if (as_id)
+ return nodes.map(function(n){return n.id()});
+ return nodes;
+ }
+
+ // list of org_unit objects or IDs for ancestors + me + descendants
+ service.fullPath = function(node_or_id, as_id) {
+ var list = service.ancestors(node_or_id).concat(
+ service.descendants(node_or_id).slice(1));
+ if (as_id)
+ return list.map(function(n){return n.id()});
+ return list;
+ }
+
+ // returns a promise, resolved with a hash of setting name =>
+ // setting value for the selected org unit. Org unit defaults to
+ // auth workstation org unit.
+ service.settings = function(names, ou_id) {
+ var deferred = $q.defer();
+ ou_id = ou_id || egAuth.user().ws_ou();
+ var here = (ou_id == egAuth.user().ws_ou());
+
+ // allow non-array
+ if (!angular.isArray(names)) names = [names];
+
+ if (here) {
+ // only cache org settings retrieved for the current
+ // workstation org unit.
+ var newNames = [];
+ angular.forEach(names, function(name) {
+ if (!angular.isDefined(service.cachedSettings[name]))
+ newNames.push(name)
+ });
+
+ // only retrieve uncached values
+ names = newNames;
+ if (names.length == 0)
+ return $q.when(service.cachedSettings);
+ }
+
+ egNet.request(
+ 'open-ils.actor',
+ 'open-ils.actor.ou_setting.ancestor_default.batch',
+ ou_id, names, egAuth.token()
+ ).then(function(blob) {
+ var settings = {};
+ angular.forEach(blob, function(val, key) {
+ // val is either null or a structure containing the value
+ settings[key] = val ? val.value : null;
+ if (here) service.cachedSettings[key] = settings[key];
+ });
+
+ // resolve with cached settings if 'here', since 'settings'
+ // will only contain settings we had to retrieve
+ deferred.resolve(here ? service.cachedSettings : settings);
+ });
+ return deferred.promise;
+ }
+
+ return service;
+}]);
+
--- /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) { return ctx.retrieve('aou', 1) })
+ * .then(function(org) { console.log(org.id()); ctx.disconnect() })
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egPCRUD', ['$q','$rootScope','egAuth','egIDL',
+ function($q , $rootScope , egAuth , egIDL) {
+
+ var service = {};
+
+ // create service-level pass through functions
+ // for one-off PCRUDContext actions.
+ angular.forEach(['connect', 'retrieve', 'retrieveAll',
+ 'search', 'create', 'update', 'remove', 'apply'],
+ function(action) {
+ service[action] = function() {
+ var ctx = new PCRUDContext();
+ return ctx[action].apply(ctx, arguments);
+ }
+ }
+ );
+
+ /*
+ * Since services are singleton objectss, we need an internal
+ * class to manage individual PCRUD conversations.
+ */
+ var PCRUDContextIdent = 0; // useful for debug logging
+ function PCRUDContext() {
+ var self = this;
+ this.xact_close_mode = 'rollback';
+ this.ident = PCRUDContextIdent++;
+ this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+
+ this.toString = function() {
+ return '[PCRUDContext ' + this.ident + ']';
+ };
+
+ this.log = function(msg) {
+ console.debug(this + ': ' + msg);
+ };
+
+ this.err = function(msg) {
+ console.error(this + ': ' + msg);
+ };
+
+ this.connect = function() {
+ this.log('connect');
+ var deferred = $q.defer();
+ this.session.connect({onconnect :
+ function() {deferred.resolve(self)}});
+ return deferred.promise;
+ };
+
+ this.disconnect = function() {
+ this.log('disconnect');
+ this.session.disconnect();
+ };
+
+ this.retrieve = function(fm_class, pkey, pcrud_ops) {
+ return this._dispatch(
+ 'open-ils.pcrud.retrieve.' + fm_class,
+ [egAuth.token(), pkey, pcrud_ops]
+ );
+ };
+
+ this.retrieveAll = function(fm_class, pcrud_ops, req_ops) {
+ var search = {};
+ search[egIDL.classes[fm_class].pkey] = {'!=' : null};
+ return this.search(fm_class, search, pcrud_ops, req_ops);
+ };
+
+ this.search = function (fm_class, search, pcrud_ops, req_ops) {
+ req_ops = req_ops || {};
+
+ var return_type = req_ops.idlist ? 'id_list' : 'search';
+ var method = 'open-ils.pcrud.' + return_type + '.' + fm_class;
+
+ if (req_ops.atomic) method += '.atomic';
+
+ return this._dispatch(method,
+ [egAuth.token(), search, pcrud_ops]);
+ };
+
+ this.create = function(list) {return this.CUD('create', list)};
+ this.update = function(list) {return this.CUD('update', list)};
+ this.remove = function(list) {return this.CUD('delete', list)};
+ this.apply = function(list) {return this.CUD('apply', list)};
+
+ this.xactClose = function() {
+ return this._send_request(
+ 'open-ils.pcrud.transaction.' + this.xact_close_mode,
+ [egAuth.token()]
+ );
+ };
+
+ this.xactBegin = function() {
+ return this._send_request(
+ 'open-ils.pcrud.transaction.begin',
+ [egAuth.token()]
+ );
+ };
+
+ this._dispatch = function(method, params) {
+ if (this.authoritative) {
+ return this._wrap_xact(
+ function() {
+ return self._send_request(method, params);
+ }
+ );
+ } else {
+ return this._send_request(method, params)
+ }
+ };
+
+
+ // => connect
+ // => xact_begin
+ // => action
+ // => xact_close(commit/rollback)
+ // => disconnect
+ // Returns a promise
+ // main_func should return a promise
+ this._wrap_xact = function(main_func) {
+ var deferred = $q.defer();
+
+ // 1. connect
+ this.connect().then(function() {
+
+ // 2. start the transaction
+ self.xactBegin().then(function() {
+
+ // 3. execute the main body
+ main_func().then(
+ // main body complete
+ function(lastResp) {
+
+ // 4. close the transaction
+ self.xactClose().then(function() {
+ // 5. disconnect
+ self.disconnect();
+ // 6. all done
+ deferred.resolve(lastResp);
+ });
+ },
+
+ // main body error handler
+ function() {},
+
+ // main body notify() handler
+ function(data) {deferred.notify(data)}
+ );
+
+ })}); // close 'em all up.
+
+ return deferred.promise;
+ };
+
+ this._send_request = function(method, params) {
+ this.log('_send_request(' + method + ')');
+ var deferred = $q.defer();
+ var lastResp;
+ this.session.request({
+ method : method,
+ params : params,
+ onresponse : function(r) {
+ var resp = r.recv();
+ if (resp && (lastResp = resp.content())) {
+ deferred.notify(lastResp);
+ } else {
+ // pcrud requests should always return something
+ self.err(method + " returned no response");
+ }
+ },
+ oncomplete : function() {
+ deferred.resolve(lastResp);
+ },
+
+ onmethoderror : function(req, stat, stat_text) {
+ self.err(method + " failed. \ncode => "
+ + stat + "\nstatus => " + stat_text
+ + "\nparams => " + js2JSON(params));
+
+ if (stat == 401) {
+ // 401 is the PCRUD equivalent of a NO_SESSION event
+ $rootScope.$broadcast('egAuthExpired');
+ }
+
+ deferred.reject(req);
+ }
+ // Note: no onerror handler for websockets connections,
+ // because errors exist and are reported as top-level
+ // conditions, not request-specific conditions.
+ // Practically every error we care about (minus loss of
+ // connection) will be reported as a method error.
+ }).send();
+
+ return deferred.promise;
+ };
+
+ this.CUD = function (action, list) {
+ this.log('CUD(): ' + action);
+
+ this.cud_idx = 0;
+ this.cud_action = action;
+ this.xact_close_mode = 'commit';
+ this.cud_list = list;
+ this.cud_deferred = $q.defer();
+
+ if (!angular.isArray(list) || list.classname)
+ this.cud_list = [list];
+
+ return this._wrap_xact(
+ function() {
+ self._CUD_next_request();
+ return self.cud_deferred.promise;
+ }
+ );
+ }
+
+ /**
+ * Loops through the list of objects to update and sends
+ * them one at a time to the server for processing. Once
+ * all are done, the cud_deferred promise is resolved.
+ */
+ this._CUD_next_request = function() {
+
+ if (this.cud_idx >= this.cud_list.length) {
+ this.cud_deferred.resolve(this.cud_last);
+ return;
+ }
+
+ var action = this.cud_action;
+ var fm_obj = this.cud_list[this.cud_idx++];
+
+ if (action == 'auto') {
+ if (fm_obj.ischanged()) action = 'update';
+ if (fm_obj.isnew()) action = 'create';
+ if (fm_obj.isdeleted()) action = 'delete';
+
+ if (action == 'auto') {
+ // object does not need updating; move along
+ this._CUD_next_request();
+ }
+ }
+
+ this._send_request(
+ 'open-ils.pcrud.' + action + '.' + fm_obj.classname,
+ [egAuth.token(), fm_obj]).then(
+ function(data) {
+ // update actions return one response.
+ // no notify() handler needed.
+ self.cud_last = data;
+ self.cud_deferred.notify(data);
+ self._CUD_next_request();
+ }
+ );
+
+ };
+ }
+
+ return service;
+}]);
+
--- /dev/null
+/**
+ * egPrint : manage print templates, process templates, print content
+ *
+ * TODO: create configurable links between print template and context.
+ */
+angular.module('egCoreMod')
+
+.factory('egPrint',
+ ['$q','$window','$timeout','$http','egHatch','egAuth','egIDL','egOrg',
+function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg) {
+
+ var service = {};
+
+ service.template_base_path = 'share/print_templates/t_';
+
+ /*
+ * context : 'default', 'receipt','label', etc.
+ * scope : data loaded into the template environment
+ * template : template name (e.g. 'checkout', 'transit_slip'
+ * content : content to print. If 'template' is set, content is
+ * derived from the template.
+ * content_type : 'text/html', 'text/plain', 'text/csv'
+ * show_dialog : boolean, if true, print dialog is shown. This setting
+ * only affects remote printers, since browser printers
+ * do not allow such control
+ */
+ service.print = function(args) {
+ if (!args) return $q.when();
+
+ if (args.template) {
+ // fetch the template, then proceed to printing
+
+ return service.getPrintTemplate(args.template)
+ .then(function(content) {
+ args.content = content;
+ if (!args.content_type) args.content_type = 'html';
+ return service.print_content(args);
+ });
+
+ }
+
+ return service.print_content(args);
+ }
+
+ // add commonly used attributes to the print scope
+ service.fleshPrintScope = function(scope) {
+ if (!scope) scope = {};
+ scope.today = new Date().toISOString();
+ scope.staff = egIDL.toHash(egAuth.user());
+ scope.current_location =
+ egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+ }
+
+ // Template has been fetched (or no template needed)
+ // Process the template and send the result off to the printer.
+ service.print_content = function(args) {
+ service.fleshPrintScope(args.scope);
+
+ var promise;
+ if (args.content_type == 'text/html') {
+
+ // all HTML content is assumed to require compilation,
+ // regardless of the print destination
+ promise = service.ingest_print_content(
+ args.content_type, args.content, args.scope);
+
+ } else {
+ // text content does not require compilation for remote printing
+ promise = $q.when();
+ }
+
+ // TODO: link print context to template type
+ var context = args.context || 'default';
+
+ return promise.then(function(html) {
+
+ return egHatch.remotePrint(context,
+ args.content_type, html, args.show_dialog)['catch'](
+
+ function(msg) {
+ // remote print not available;
+
+ if (egHatch.hatchRequired()) {
+ console.error("Unable to print data; "
+ + "hatchRequired=true, but hatch is not connected");
+ return $q.reject();
+ }
+
+ if (args.content_type != 'text/html') {
+ // text content does require compilation
+ // (absorption) for browser printing
+ return service.ingest_print_content(
+ args.content_type, args.content, args.scope
+ ).then(function() { $window.print() });
+ } else {
+ // HTML content is already ingested and accessible
+ // within the page to the printer.
+ $window.print();
+ }
+ }
+ );
+ });
+ }
+
+ // loads an HTML print template by name from the server
+ // If no template is available in local/hatch storage,
+ // fetch the template as an HTML file from the server.
+ service.getPrintTemplate = function(name) {
+ var deferred = $q.defer();
+
+ egHatch.getItem('eg.print.template.' + name)
+ .then(function(html) {
+
+ if (html) {
+ // we have a locally stored template
+ deferred.resolve(html);
+ return;
+ }
+
+ var path = service.template_base_path + name;
+ console.debug('fetching template ' + path);
+
+ $http.get(path)
+ .success(function(data) { deferred.resolve(data) })
+ .error(function() {
+ console.error('unable to locate print template: ' + name);
+ deferred.reject();
+ });
+ });
+
+ return deferred.promise;
+ }
+
+ service.storePrintTemplate = function(name, html) {
+ return egHatch.setItem('eg.print.template.' + name, html);
+ }
+
+ return service;
+}])
+
+
+/**
+ * Container for inserting print data into the browser page.
+ * On insert, $window.print() is called to print the data.
+ * The div housing eg-print-container must apply the correct
+ * print media CSS to ensure this content (and not the rest
+ * of the page) is printed.
+ */
+
+// FIXME: only apply print CSS when print commands are issued via the
+// print container, otherwise using the browser's native print page
+// option will always result in empty pages. Move the print CSS
+// out of the standalone CSS file and put it into a template file
+// for this directive.
+.directive('egPrintContainer', ['$compile', function($compile) {
+ return {
+ restrict : 'AE',
+ scope : {}, // isolate our scope
+ link : function(scope, element, attrs) {
+ scope.elm = element;
+ },
+ controller :
+ ['$scope','$q','$window','$timeout','egHatch','egPrint',
+ function($scope , $q , $window , $timeout , egHatch , egPrint) {
+
+ egPrint.ingest_print_content = function(type, content, printScope) {
+
+ if (type == 'text/csv' || type == 'text/plain') {
+ // preserve newlines, spaces, etc.
+ content = '<pre>' + content + '</pre>';
+ }
+
+ $scope.elm.html(content);
+
+ var sub_scope = $scope.$new(true);
+ angular.forEach(printScope, function(val, key) {
+ sub_scope[key] = val;
+ })
+
+ var resp = $compile($scope.elm.contents())(sub_scope);
+
+ var deferred = $q.defer();
+ $timeout(function(){
+ // give the $digest a chance to complete then
+ // resolve with the compiled HTML from our
+ // print container
+
+ deferred.resolve(
+ resp.contents()[0].parentNode.innerHTML
+ );
+ });
+
+ return deferred.promise;
+ }
+ }
+ ]
+ }
+}])
+
--- /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 (service.promise) {
+ // startup already started, return our existing promise
+ return service.promise;
+ }
+
+ // create a new promise and fire off startup
+ var deferred = $q.defer();
+ service.promise = deferred.promise;
+
+ // IDL parsing is sync. No promises required
+ egIDL.parseIDL();
+ egAuth.testAuthToken().then(
+
+ // testAuthToken resolved
+ function() {
+ egEnv.load().then(
+ function() { deferred.resolve() },
+ function() {
+ deferred.reject('egEnv did not resolve')
+ }
+ );
+ },
+
+ // testAuthToken rejected
+ function() {
+ console.log('egAuth found no valid authtoken');
+ if (service.expiredAuthHandler()) deferred.resolve();
+ }
+ );
+
+ return service.promise;
+ }
+
+ return service;
+}]);
+
--- /dev/null
+/**
+ * egStatusBar
+ *
+ * Displays key information and messages to the user.
+ *
+ * Currently displays network connection status, egHatch connection
+ * status, and messages delivered via
+ * $scope.$emit('egStatusBarMessage', msg)
+ */
+
+angular.module('egCoreMod')
+
+.directive('egStatusBar', function() {
+ return {
+ restrict : 'AE',
+ replace : true,
+ templateUrl : 'eg-status-bar-template',
+ controller : [
+ '$scope','$rootScope','egHatch',
+ function($scope , $rootScope , egHatch) {
+ $scope.messages = []; // keep a log of recent messages
+
+ $scope.netConnected = function() {
+ // TODO: should should be abstracted through egNet
+ return OpenSRF.websocketConnected();
+ }
+
+ // update the UI whenever we lose connection
+ OpenSRF.onWebSocketClosed = function() {
+ $scope.$apply();
+ }
+
+ $scope.hatchConnected = function() {
+ return egHatch.hatchAvailable;
+ }
+
+ // update the UI whenever we lose connection
+ egHatch.onHatchClose = function() {
+ $scope.$apply();
+ }
+
+ // update the UI whenever we lose connection
+ egHatch.onHatchOpen = function() {
+ $scope.$apply();
+ }
+
+ $scope.hatchConnect = function() {
+ egHatch.hatchConnect();
+ }
+
+ $rootScope.$on('egStatusBarMessage', function(evt, args) {
+ $scope.messages.unshift(args.message);
+
+ // ensure the list does not exceed 10 messages
+ // TODO: configurable?
+ $scope.messages.splice(10, 1);
+ });
+ }]
+ }
+});
--- /dev/null
+/**
+ * egStrings : service for tracking page-specific string translations.
+ *
+ * Convience functions embedded herein are prefixed with "$" to avoid
+ * collisions with string keys, which are linked directly to the
+ * service.
+ *
+ * egStrings.A_STRING = 'hello, world {{foo}';
+ *
+ * egStrings.$replace(egStrings.A_STRING, {foo : 'bar'})
+ *
+ */
+
+angular.module('egCoreMod').factory('egStrings',
+['$interpolate', function($interpolate) {
+ return {
+ '$replace' : function(str, args) {
+ if (!str) return '';
+ return $interpolate(str)(args);
+ }
+ }
+}]);
--- /dev/null
+/**
+ * UI tools and directives.
+ */
+angular.module('egUiMod', ['egCoreMod', 'ui.bootstrap'])
+
+
+/**
+ * <input focus-me="iAmOpen"/>
+ * $scope.iAmOpen = true;
+ */
+.directive('focusMe',
+ ['$timeout','$parse',
+function($timeout , $parse) {
+ return {
+ link: function(scope, element, attrs) {
+ var model = $parse(attrs.focusMe);
+ scope.$watch(model, function(value) {
+ if(value === true)
+ $timeout(function() {element[0].focus()});
+ });
+ element.bind('blur', function() {
+ scope.$apply(model.assign(scope, false));
+ })
+ }
+ };
+}])
+
+/**
+ * <input blur-me="pleaseBlurMe"/>
+ * $scope.pleaseBlurMe = true
+ * Useful for de-focusing when no other obvious focus target exists
+ */
+.directive('blurMe',
+ ['$timeout','$parse',
+function($timeout , $parse) {
+ return {
+ link: function(scope, element, attrs) {
+ var model = $parse(attrs.blurMe);
+ scope.$watch(model, function(value) {
+ if(value === true)
+ $timeout(function() {element[0].blur()});
+ });
+ element.bind('focus', function() {
+ scope.$apply(model.assign(scope, false));
+ })
+ }
+ };
+}])
+
+
+// <input select-me="iWantToBeSelected"/>
+// $scope.iWantToBeSelected = true;
+.directive('selectMe',
+ ['$timeout','$parse',
+function($timeout , $parse) {
+ return {
+ link: function(scope, element, attrs) {
+ var model = $parse(attrs.selectMe);
+ scope.$watch(model, function(value) {
+ if(value === true)
+ $timeout(function() {element[0].select()});
+ });
+ element.bind('blur', function() {
+ scope.$apply(model.assign(scope, false));
+ })
+ }
+ };
+}])
+
+
+// 'reverse' filter
+// <div ng-repeat="item in items | reverse">{{item.name}}</div>
+// http://stackoverflow.com/questions/15266671/angular-ng-repeat-in-reverse
+// TODO: perhaps this should live elsewhere
+.filter('reverse', function() {
+ return function(items) {
+ return items.slice().reverse();
+ };
+})
+
+
+/**
+ * egAlertDialog.open({message : 'hello {{name}}'}).result.then(
+ * function() { console.log('alert closed') });
+ */
+.factory('egAlertDialog',
+
+ ['$modal','$interpolate',
+function($modal , $interpolate) {
+ var service = {};
+
+ service.open = function(message, msg_scope) {
+ return $modal.open({
+ templateUrl: './share/t_alert_dialog',
+ controller: ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.message = $interpolate(message)(msg_scope);
+ $scope.ok = function() {
+ if (msg_scope && msg_scope.ok) msg_scope.ok();
+ $modalInstance.close()
+ }
+ }
+ ]
+ });
+ }
+
+ return service;
+}])
+
+/**
+ * egConfirmDialog.open("some message goes {{here}}", {
+ * here : 'foo', ok : function() {}, cancel : function() {}});
+ */
+.factory('egConfirmDialog',
+
+ ['$modal','$interpolate',
+function($modal, $interpolate) {
+ var service = {};
+
+ service.open = function(title, message, msg_scope) {
+ return $modal.open({
+ templateUrl: './share/t_confirm_dialog',
+ controller: ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.title = $interpolate(title)(msg_scope);
+ $scope.message = $interpolate(message)(msg_scope);
+ $scope.ok = function() {
+ if (msg_scope.ok) msg_scope.ok();
+ $modalInstance.close()
+ }
+ $scope.cancel = function() {
+ if (msg_scope.cancel) msg_scope.cancel();
+ $modalInstance.dismiss();
+ }
+ }
+ ]
+ })
+ }
+
+ return service;
+}])
+
+/**
+ * egPromptDialog.open(
+ * "prompt message goes {{here}}",
+ * promptValue, // optional
+ * {
+ * here : 'foo',
+ * ok : function(value) {console.log(value)},
+ * cancel : function() {console.log('prompt denied')}
+ * }
+ * );
+ */
+.factory('egPromptDialog',
+
+ ['$modal','$interpolate',
+function($modal, $interpolate) {
+ var service = {};
+
+ service.open = function(message, promptValue, msg_scope) {
+ return $modal.open({
+ templateUrl: './share/t_prompt_dialog',
+ controller: ['$scope', '$modalInstance',
+ function($scope, $modalInstance) {
+ $scope.message = $interpolate(message)(msg_scope);
+ $scope.args = {value : promptValue || ''};
+ $scope.focus = true;
+ $scope.ok = function() {
+ if (msg_scope.ok) msg_scope.ok($scope.args.value);
+ $modalInstance.close()
+ }
+ $scope.cancel = function() {
+ if (msg_scope.cancel) msg_scope.cancel();
+ $modalInstance.dismiss();
+ }
+ }
+ ]
+ })
+ }
+
+ return service;
+}])
+
+
+/**
+ * Nested org unit selector modeled as a Bootstrap dropdown button.
+ */
+.directive('egOrgSelector', function() {
+ return {
+ restrict : 'AE',
+ transclude : true,
+ replace : true, // makes styling easier
+ scope : {
+ selected : '=', // defaults to workstation or root org
+
+ // Each org unit is passed into this function and, for
+ // any org units where the response value is true, the
+ // org unit will not be added to the selector.
+ hiddenTest : '=',
+
+ // Caller can either $watch(selected, ..) or register an
+ // onchange handler.
+ onchange : '=',
+
+ // optional primary drop-down button label
+ label : '@'
+ },
+
+ // any reason to move this into a TT2 template?
+ template :
+ '<div class="btn-group eg-org-selector" dropdown>'
+ + '<button type="button" class="btn btn-default dropdown-toggle">'
+ + '<span style="padding-right: 5px;">{{getSelectedName()}}</span>'
+ + '<span class="caret"></span>'
+ + '</button>'
+ + '<ul class="dropdown-menu">'
+ + '<li ng-repeat="org in orgList" ng-hide="hiddenTest(org.id)">'
+ + '<a href dropdown-toggle ng-click="orgChanged(org)"'
+ + 'style="padding-left: {{org.depth * 10 + 5}}px">'
+ + '{{org.shortname}}'
+ + '</a>'
+ + '</li>'
+ + '</ul>'
+ + '</div>',
+
+ controller : ['$scope','$timeout','egOrg','egAuth',
+ function($scope , $timeout , egOrg , egAuth) {
+
+ // avoid linking the full fleshed tree to the scope by
+ // tossing in a flattened list.
+ $scope.orgList = egOrg.list().map(function(org) {
+ return {
+ id : org.id(),
+ shortname : org.shortname(),
+ depth : org.ou_type().depth()
+ }
+ });
+
+ $scope.getSelectedName = function() {
+ if ($scope.selected)
+ return $scope.selected.shortname();
+ return $scope.label;
+ }
+
+ $scope.orgChanged = function(org) {
+ $scope.selected = egOrg.get(org.id);
+ if ($scope.onchange) $scope.onchange($scope.selected);
+ }
+
+ if (!$scope.selected)
+ $scope.selected = egOrg.get(egAuth.user().ws_ou());
+ }]
+ }
+})
+
+
+/*
+http://stackoverflow.com/questions/18061757/angular-js-and-html5-date-input-value-how-to-get-firefox-to-show-a-readable-d
+
+This directive allows us to use html5 input type="date" (for Chrome) and
+gracefully fall back to a regular ISO text input for Firefox.
+It also allows us to abstract away some browser finickiness.
+*/
+.directive(
+ 'egDateInput',
+ function(dateFilter) {
+ return {
+ require: 'ngModel',
+ template: '<input type="date"></input>',
+ replace: true,
+ link: function(scope, elm, attrs, ngModelCtrl) {
+
+ // since this is a date-only selector, set the time
+ // portion to 00:00:00, which should better match the
+ // user's expectations. Note this allows us to retain
+ // the timezone.
+ function strip_time(date) {
+ if (!date) date = new Date();
+ date.setHours(0);
+ date.setMinutes(0);
+ date.setSeconds(0);
+ date.setMilliseconds(0);
+ return date;
+ }
+
+ ngModelCtrl.$formatters.unshift(function (modelValue) {
+ // apply strip_time here in case the user never
+ // modifies the date value.
+ return dateFilter(strip_time(modelValue), 'yyyy-MM-dd');
+ });
+
+ ngModelCtrl.$parsers.unshift(function(viewValue) {
+ return strip_time(new Date(viewValue));
+ });
+ },
+ };
+})
--- /dev/null
+/**
+ * Service for fetching fleshed user objects.
+ */
+
+angular.module('egUserMod', ['egCoreMod'])
+
+.factory('egUser',
+ ['$q','$timeout','egNet','egAuth','egOrg',
+function($q, $timeout, egNet, egAuth, egOrg) {
+
+ var service = {
+ defaultFleshFields : [
+ 'card',
+ 'standing_penalties',
+ 'addresses',
+ 'billing_address',
+ 'mailing_address',
+ 'stat_cat_entries',
+ 'usr_activity'
+ ]
+ };
+
+ service.get = function(userId, args) {
+ var deferred = $q.defer();
+
+ var fields = service.defaultFleshFields;
+ if (args) {
+ if (args.useFields) {
+ // overridde flesh fields
+ fields = args.useFields;
+ }
+ if (args.addFields) {
+ // append flesh fields
+ fields = fields.concat(args.addFields);
+ }
+ }
+
+ egNet.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.fleshed.retrieve',
+ egAuth.token(), userId, fields).then(
+ function(user) {
+ if (user && user.classname == 'au') {
+ deferred.resolve(user);
+ } else {
+ deferred.reject(user);
+ }
+ }
+ );
+
+ return deferred.promise;
+ };
+
+ return service;
+}]);
+
--- /dev/null
+/**
+ * Mock data required by multiple unit tests.
+ */
+
+window._eg_mock_data = {
+
+ // builds a mock org unit tree fleshed with ou_types and
+ // absorbs the tree into egEnv
+ orgTree : function(egIDL, egEnv) {
+ var type1 = new egIDL.aout();
+ type1.id(1);
+ type1.depth(0);
+
+ var type2 = new egIDL.aout();
+ type2.id(2);
+ type2.depth(1);
+ type2.parent(1);
+
+ var type3 = new egIDL.aout();
+ type3.id(3);
+ type3.depth(2);
+ type3.parent(2);
+
+ var org1 = new egIDL.aou();
+ org1.id(1);
+ org1.ou_type(type1);
+
+ var org2 = new egIDL.aou();
+ org2.id(2);
+ org2.parent_ou(1);
+ org2.ou_type(type2);
+
+ var org3 = new egIDL.aou();
+ org3.id(3);
+ org3.parent_ou(1);
+ org3.ou_type(type2);
+
+ var org4 = new egIDL.aou();
+ org4.id(4);
+ org4.parent_ou(2);
+ org4.ou_type(type3);
+
+ org1.children([org2, org3]);
+ org2.children([org4]);
+ org3.children([]);
+ org4.children([]);
+
+ egEnv.absorbTree(org1, 'aou');
+ }
+}
--- /dev/null
+#!/usr/bin/perl
+use strict; use warnings;
+use XML::LibXML;
+use XML::LibXSLT;
+my $out_file = 'IDL2js.js';
+my $idl_file = '../../../../../../../examples/fm_IDL.xml';
+my $xsl_file = '/openils/var/xsl/fm_IDL2js.xsl'; # FIXME: hard-coded path
+
+my $xslt = XML::LibXSLT->new();
+my $style_doc = XML::LibXML->load_xml(location => $xsl_file, no_cdata=>1);
+my $stylesheet = $xslt->parse_stylesheet($style_doc);
+my $idl_doc = XML::LibXML->load_xml(location => $idl_file);
+my $results = $stylesheet->transform($idl_doc);
+my $output = $stylesheet->output_as_bytes($results);
+
+open(IDL, ">$out_file") or die "Cannot open IDL2js file $out_file : $!\n";
+
+print IDL $output;
+
+close(IDL);
+
+
--- /dev/null
+module.exports = function(config){
+ config.set({
+ basePath : '../',
+
+ // config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+ logLevel: config.LOG_INFO,
+
+ files : [
+ 'build/js/angular.min.js',
+ 'build/js/angular-route.min.js',
+ 'bower_components/angular-mocks/angular-mocks.js', // testing only
+ 'build/js/ui-bootstrap.min.js',
+ 'build/js/hotkeys.min.js',
+ /* OpenSRF must be installed first */
+ '/openils/lib/javascript/md5.js',
+ '/openils/lib/javascript/JSON_v1.js',
+ '/openils/lib/javascript/opensrf.js',
+ '/openils/lib/javascript/opensrf_ws.js',
+
+ // mock data for testing only
+ 'test/data/IDL2js.js',
+ 'test/data/eg_mock.js',
+
+ // service/*.js have to be loaded in order
+ 'services/core.js',
+ 'services/idl.js',
+ 'services/strings.js',
+ 'services/event.js',
+ 'services/net.js',
+ 'services/auth.js',
+ 'services/pcrud.js',
+ 'services/env.js',
+ 'services/org.js',
+ 'services/hatch.js',
+ 'services/print.js',
+ 'services/coresvc.js',
+ 'services/user.js',
+ 'services/startup.js',
+ 'services/ui.js',
+ 'services/statusbar.js',
+ 'services/grid.js',
+ 'services/navbar.js',
+ // load app scripts
+ 'app.js',
+ 'circ/**/*.js',
+ 'cat/**/*.js',
+ 'admin/**/*.js',
+ 'test/unit/egIDL.js', // order matters for some of these
+ 'test/unit/egOrg.js',
+ 'test/unit/**/*.js'
+ ],
+
+ // test results reporter to use
+ // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
+ reporters: ['spec'], // detailed report
+ //reporters: ['progress'], // summary report
+
+ // enable / disable colors in the output (reporters and logs)
+ colors: true,
+
+ // enable / disable watching file and executing tests whenever any file changes
+ autoWatch : false,
+
+ frameworks: ['jasmine'],
+
+ browsers: ['PhantomJS'],
+
+ // web server port
+ port: 9876,
+
+ /*
+ coverageReporter: {
+ type : 'html',
+ dir : 'coverage/',
+ },
+
+ preprocessors: {
+ '../src/*.js': ['coverage']
+ },
+ */
+
+ // If browser does not capture in given timeout [ms], kill it
+ captureTimeout: 60000,
+
+ // Continuous Integration mode
+ // if true, it capture browsers, run tests and exit
+ singleRun: true
+})}
--- /dev/null
+'use strict';
+
+describe('egCore', function(){
+ beforeEach(module('egCoreMod'));
+
+ it('should wrap services', inject(function(egCore, egIDL) {
+ expect(egCore.idl).toBe(egIDL);
+ }));
+
+ it('should wrap services', inject(function(egCore, egIDL) {
+ expect(egCore.auth).not.toBe(egIDL);
+ }));
+
+ it('should not wrap non-services', inject(function(egCore) {
+ expect(egCore.junk).not.toBeDefined();
+ }));
+
+});
--- /dev/null
+'use strict';
+
+describe('egEvent', function(){
+ beforeEach(module('egCoreMod'));
+
+ var evt = {
+ ilsevent: "12345",
+ pid: "12345",
+ desc: "Test Event Description",
+ payload: {test : 'xyz'},
+ textcode: "TEST_EVENT",
+ servertime: "Wed Nov 6 16:05:50 2013"
+ };
+
+ it('should parse an event object', inject(function(egEvent) {
+ expect(egEvent.parse(evt)).not.toBe(null);
+ }));
+
+ it('should not parse a non-event', inject(function(egEvent) {
+ expect(egEvent.parse({})).toBe(null);
+ }));
+
+ it('should not parse a non-event', inject(function(egEvent) {
+ expect(egEvent.parse({abc : '123'})).toBe(null);
+ }));
+
+ it('should not parse a non-event', inject(function(egEvent) {
+ expect(egEvent.parse([])).toBe(null);
+ }));
+
+ it('should not parse a non-event', inject(function(egEvent) {
+ expect(egEvent.parse('STRING')).toBe(null);
+ }));
+
+ it('should not parse a non-event', inject(function(egEvent) {
+ expect(egEvent.parse(true)).toBe(null);
+ }));
+
+ it('should stringify an event', inject(function(egEvent) {
+ expect(egEvent.parse(evt).toString()).toBe(
+ 'Event: 12345:TEST_EVENT -> Test Event Description')
+ }));
+
+});
--- /dev/null
+'use strict';
+
+describe('egHomeControllers', function(){
+ beforeEach(module('egHome'));
+
+ /* ---- LoginCtrl ---------------------------------- */
+
+ var loginCtrl, loginScope;
+ beforeEach(inject(function ($rootScope, $controller, $location) {
+ // pass the workstation name via (mock) URL param
+ $location.search({ws : 'TestWorkstation'});
+
+ loginScope = $rootScope.$new();
+ loginCtrl = $controller('LoginCtrl', {$scope: loginScope});
+ }));
+
+ it('should focus the login controller', inject(function() {
+ expect(loginScope.focusMe).toBe(true);
+ }));
+
+});
--- /dev/null
+'use strict';
+
+describe('egIDL', function(){
+ beforeEach(module('egCoreMod'));
+
+ it('should parse the IDL', inject(function(egIDL) {
+ egIDL.parseIDL();
+ expect(egIDL.classes.aou.fields.length).toBeGreaterThan(0);
+ }));
+
+ it('should create an aou object', inject(function(egIDL) {
+ egIDL.parseIDL();
+ var org = new egIDL.aou();
+ expect(typeof org.id).toBe('function');
+ }));
+
+ it('should create an aou object with accessor/mutators', inject(function(egIDL) {
+ egIDL.parseIDL();
+ var org = new egIDL.aou();
+ org.name('AN ORG');
+ expect(org.name()).toBe('AN ORG');
+ }));
+});
+
+
--- /dev/null
+'use strict';
+
+describe('egOrg', function(){
+ beforeEach(module('egCoreMod'));
+
+ function mkTree(egIDL, egEnv) { // FIXME: external sample data
+ egIDL.parseIDL();
+ window._eg_mock_data.orgTree(egIDL, egEnv);
+ }
+
+ it('should provide get by ID', inject(function(egIDL, egEnv, egOrg) {
+ mkTree(egIDL, egEnv);
+ expect(egOrg.get(egEnv.aou.tree.id())).toBe(egEnv.aou.tree);
+ }));
+
+ it('should provide get by node', inject(function(egIDL, egEnv, egOrg) {
+ mkTree(egIDL, egEnv);
+ expect(egOrg.get(egEnv.aou.tree).id()).toBe(egEnv.aou.tree.id());
+ }));
+
+ it('should provide ancestors', inject(function(egIDL, egEnv, egOrg) {
+ mkTree(egIDL, egEnv);
+ expect(egOrg.ancestors(2, true)).toEqual([2, 1]);
+ }));
+
+ it('should provide descendants', inject(function(egIDL, egEnv, egOrg) {
+ mkTree(egIDL, egEnv);
+ expect(egOrg.descendants(2, true)).toEqual([2, 4]);
+ }));
+
+ it('should provide full path', inject(function(egIDL, egEnv, egOrg) {
+ mkTree(egIDL, egEnv);
+ expect(egOrg.fullPath(4, true)).toEqual([4, 2, 1]);
+ }));
+});
+
+
--- /dev/null
+'use strict';
+
+describe('egPatronAppTest', function(){
+ beforeEach(module('egPatronApp'));
+
+ // basic controller sanity checks
+
+ var patronCtrl, patronScope;
+ beforeEach(inject(function ($rootScope, $controller, $location) {
+ patronScope = $rootScope.$new();
+ patronCtrl = $controller('PatronCtrl', {$scope: patronScope});
+ }));
+
+ /** patronSvc tests **/
+ describe('patronSvcTests', function() {
+
+ it('patronSvc should start with empty lists', inject(function(patronSvc) {
+ expect(patronSvc.patrons.length).toEqual(0);
+ }));
+
+ it('patronSvc reset should clear data', inject(function(patronSvc) {
+ patronSvc.checkout_overrides.a = 1;
+ expect(Object.keys(patronSvc.checkout_overrides).length).toBe(1);
+ patronSvc.resetPatronLists();
+ expect(Object.keys(patronSvc.checkout_overrides).length).toBe(0);
+ expect(patronSvc.holds.length).toBe(0);
+ }));
+
+ });
+
+});
--- /dev/null
+'use strict';
+
+describe('egStrings', function(){
+ beforeEach(module('egCoreMod'));
+
+ it('should interpolate values', inject(function(egStrings) {
+
+ egStrings.FOO = 'Hello, {{planet}}';
+
+ expect(egStrings.$replace(egStrings.FOO, {planet : 'Earth'}))
+ .toBe('Hello, Earth');
+ }));
+
+});
<!ENTITY staff.patron.user_edit.depth.label "Depth">
<!ENTITY staff.patron.user_edit.grantable.label "Grantable">
<!ENTITY staff.patron.user_edit.save.label "Save">
+<!ENTITY staff.patron.user_edit.display_perm.select_one "-- Select One --">
+<!ENTITY staff.patron.user_edit.save_user.depth_required "Depth is required to set the permission.">
+<!ENTITY staff.patron.user_edit.save_user.user_modified_successfully "User successfully modified.">
<!ENTITY staff.patron.ue.ev_user_editor.label "Evergreen User Editor">
<!ENTITY staff.patron.ue.user_greeting.label "Welcome ">
<!ENTITY staff.patron.ue.interface_note.label "Note: required or invalid fields are <span style='border-bottom: 2px solid red;'>marked with color</span>">
--- /dev/null
+<?xml version='1.0'?>
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<!-- LOCALIZATION -->
+<!DOCTYPE window PUBLIC "" ""[
+ <!--#include virtual="/opac/locale/${locale}/lang.dtd"-->
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xi="http://www.w3.org/2001/XInclude">
+
+ <head>
+ <title>&staff.patron.user_edit.title;</title>
+ <script language='javascript' src='/opac/common/js/utils.js'> </script>
+ <script language='javascript' src='/opac/common/js//config.js'> </script>
+ <script language='javascript' src='/opac/common/js/CGI.js'> </script>
+
+ <script language='javascript' src='/opac/common/js/slimtree.js'> </script>
+ <script language='javascript' src='/opac/common/js/JSON_v1.js'> </script>
+ <script language='javascript' src='/opac/common/js/fmall.js'> </script>
+ <script language='javascript' src='/opac/common/js/fmgen.js'> </script>
+ <script language='javascript' src='/opac/common/js/opac_utils.js'> </script>
+ <script language='javascript' src='/opac/common/js/<!--#echo var="locale"-->/OrgTree.js'> </script>
+ <script language='javascript' src='/opac/common/js/org_utils.js'> </script>
+ <script language='javascript' src='/opac/common/js/init.js'> </script>
+ <script language='javascript' src='/opac/common/js/RemoteRequest.js'> </script>
+ <script language='javascript' src='/opac/common/js/sprintf.js'> </script>
+ <script language='javascript' src='user_edit_xhtml.js'> </script>
+
+
+ <style>
+
+ /*
+ .messagecatalog { -moz-binding: url( /xul/server/main/bindings.xml#messagecatalog ) }
+ */
+
+ .stay_hidden { visibility: hidden;
+ display: none;
+
+ }
+
+ .hideme { visibility: hidden;
+ display: none;
+ }
+
+ table { border-collapse: collapse;
+ margin-bottom: 10px;
+ margin-top: 10px;
+ }
+
+ th {
+ white-space: nowrap;
+ padding-top: 15px;
+ padding-bottom: 10px;
+ text-align: center;
+ border-top: solid black 1px;
+ }
+
+ td.odd { background-color: lightcyan; }
+
+ td.label { text-align: right;
+ padding-right: 10px;
+ }
+
+ td.value { text-align: left;
+ padding-left: 10px;
+ }
+
+ input[disabled='true'] { color: black; }
+
+ </style>
+ </head>
+
+ <div class="messagecatalog" id="patronStrings" src="/xul/server/locale/<!--#echo var='locale'-->/patron.properties" />
+
+ <body onload="try { setTimeout(init_editor,1) } catch(E) { alert(js2JSON(E)); }">
+ <form method="GET" name="editor" id="editor" >
+
+ <table width="100%">
+ <tr>
+ <td class="label">&staff.patron.user_edit.user_name.label;</td>
+ <td class="value"><input disabled="true" type="text" name="user.usrname" id="user.usrname"/></td>
+ <td class="label">&staff.patron.user_edit.barcode.label;</td>
+ <td class="value"><input type="text" name="user.card.barcode" id="user.card.barcode" disabled="true"/></td>
+ </tr>
+ <tr>
+ <td class="label">&staff.patron.user_edit.firstname.label;</td>
+ <td class="value"><input disabled="true" type="text" name="user.first_given_name" id="user.first_given_name"/></td>
+ <td class="label">&staff.patron.user_edit.middlename.label;</td>
+ <td class="value"><input disabled="true" type="text" name="user.second_given_name" id="user.second_given_name"/></td>
+ <td class="label">&staff.patron.user_edit.lastname.label;</td>
+ <td class="value"><input disabled="true" type="text" name="user.family_name" id="user.family_name"/></td>
+ </tr>
+ <tr class='advanced hideme'>
+ <td class="value" colspan="6">
+ <table width="100%">
+ <thead>
+ <tr>
+ <th></th>
+ <th>&staff.patron.user_edit.working_location.label;</th>
+ </tr>
+ </thead>
+ <tbody id="work_ous" name="work_ous"/>
+ </table>
+ </td>
+ </tr>
+ <tr class='advanced hideme'>
+ <td class="value" colspan="6">
+ <table width="100%">
+ <thead>
+ <tr>
+ <th>&staff.patron.user_edit.permission.label;</th>
+ <th>&staff.patron.user_edit.applied.label;</th>
+ <th>&staff.patron.user_edit.depth.label;</th>
+ <th>&staff.patron.user_edit.grantable.label;</th>
+ </tr>
+ </thead>
+ <tbody id="permissions" name="permissions"/>
+ </table>
+ </td>
+ </tr>
+
+ </table>
+
+ <button onclick="save_user(); return false;">&staff.patron.user_edit.save.label;</button>
+ </form>
+
+
+ <div class='hideme' id="permission-tmpl">
+ <table>
+ <tr name='prow'>
+ <td class="value" name='plabel'>
+ <span name="p.code"></span>
+ </td>
+ <td class="value" name='papply'>
+ <input type="checkbox" name="p.id" onclick="set_perm(this.parentNode.parentNode);"/>
+ </td>
+ <td class="value" name='pdepth'>
+ <select onchange="set_perm(this.parentNode.parentNode);" name="p.depth"/>
+ </td>
+ <td class="value" name='pgrant'>
+ <input type="checkbox" name="p.grantable" onclick="set_perm(this.parentNode.parentNode);"/>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+
+ <div class='hideme' id="work_ou-tmpl">
+ <table>
+ <tr name='wrow'>
+ <td class="value" name='wapply'>
+ <input type="checkbox" name="a.id" onclick="set_work_ou(this.parentNode.parentNode);"/>
+ </td>
+ <td class="value" name='label'>
+ <span name="a.name"></span>
+ (<span name="a.shortname"></span>)
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <div class="hideme"><!-- embedded string -->
+ <span id="staff.patron.user_edit.display_perm.select_one">
+ &staff.patron.user_edit.display_perm.select_one;
+ </span>
+ <span id="staff.patron.user_edit.save_user.depth_required">
+ &staff.patron.user_edit.save_user.depth_required;
+ </span>
+ <span id="staff.patron.user_edit.save_user.user_modified_successfully">
+ &staff.patron.user_edit.save_user.user_modified_successfully;
+ </span>
+ </div>
+
+ </body>
+</html>
+
--- /dev/null
+var cgi;
+var orgTree;
+var user;
+var ses_id;
+var user_groups = [];
+var adv_items = [];
+var user_perms = [];
+var perm_list = [];
+var ou_type_list = [];
+var user_work_ous = [];
+var work_ou_list = [];
+
+function $(id) { return document.getElementById(id); }
+
+function set_work_ou(row) {
+ var wid = findNodeByName(row,'a.id').getAttribute('workou_id');
+ var wapply = findNodeByName(row,'a.id').checked;
+
+ var w;
+ for (var i in user_work_ous) {
+ if (!user_work_ous[i]) continue;
+ if (user_work_ous[i].work_ou() == wid) {
+ w = user_work_ous[i];
+ if (wapply) {
+ w.isdeleted(0);
+ w.ischanged(1);
+ } else {
+ if (w.isnew()) {
+ user_work_ous[i] = null;
+ } else {
+ w.isdeleted(1);
+ }
+ }
+ break;
+ }
+ }
+
+ if (!w) {
+ if (wapply) {
+ p = new puwoum();
+ p.isnew(1);
+ p.work_ou(wid);
+ p.usr(user.id());
+
+ user_work_ous.push(p);
+ }
+ }
+}
+
+function set_perm(row) {
+ var pid = findNodeByName(row,'p.code').getAttribute('permid');
+ var papply = findNodeByName(row,'p.id').checked;
+ var pdepth = findNodeByName(row,'p.depth').options[findNodeByName(row,'p.depth').selectedIndex].value;
+ var pgrant = findNodeByName(row,'p.grantable').checked;
+
+ var p;
+ for (var i in user_perms) {
+ if (user_perms[i].perm() == pid) {
+ p = user_perms[i];
+ if (papply) {
+ p.isdeleted(0);
+ p.ischanged(1);
+ p.depth(pdepth);
+ p.grantable(pgrant ? 1 : 0);
+ } else {
+ if (p.isnew()) {
+ user_perms[i] = null;
+ } else {
+ p.isdeleted(1);
+ }
+ }
+ break;
+ }
+ }
+
+ if (!p) {
+ if (papply) {
+ p = new pupm();
+ p.isnew(1);
+ p.perm(pid);
+ p.usr(user.id());
+ p.depth('' + pdepth);
+ p.grantable(pgrant ? 1 : 0);
+
+ user_perms.push(p);
+ }
+ }
+
+}
+
+function save_user () {
+
+ try {
+
+ var save_perms = [];
+ for (var i in user_perms) {
+ // Group based perm? skip it.
+ if (user_perms[i].id() < 0) continue;
+
+ if (user_perms[i].depth() == null) {
+ var p;
+ for (var j in perm_list) {
+ if (perm_list[j].id() == user_perms[i].perm()) {
+ p = perm_list[j];
+ break;
+ }
+ }
+
+ alert(
+ $('staff.patron.user_edit.save_user.depth_required').innerHTML
+ + '\n' + p.code()
+ );
+
+ throw new Error(
+ $('staff.patron.user_edit.save_user.depth_required').innerHTML
+ + '\n' + p.code()
+ );
+ }
+
+ save_perms.push( user_perms[i] );
+ }
+
+ var save_ous = [];
+ for (var i in user_work_ous) {
+ if (!user_work_ous[i]) continue;
+ save_ous.push( user_work_ous[i] );
+ }
+
+ var req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.work_ous.update', ses_id, save_ous );
+ req.send(true);
+ var wok = req.getResultObject();
+
+ if (wok.ilsevent) throw wok;
+
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.permissions.update', ses_id, save_perms );
+ req.send(true);
+ var pok = req.getResultObject();
+
+ if (pok.ilsevent) throw pok;
+
+ if (pok || wok) {
+ alert($('staff.patron.user_edit.save_user.user_modified_successfully').innerHTML);
+ // on_patron_save comes from the browser client
+ if (window.xulG && xulG.on_patron_save) xulG.on_patron_save();
+ }
+
+ init_editor();
+
+ } catch (e) {
+ dump( js2JSON( e ));
+ alert( js2JSON( e ));
+ };
+
+
+
+ return false;
+}
+
+var adv_mode = true;
+function apply_adv_mode (root) {
+ adv_items = findNodesByClass(root,'advanced');
+ for (var i in adv_items) {
+ adv_mode ?
+ removeCSSClass(adv_items[i], 'hideme') :
+ addCSSClass(adv_items[i], 'hideme');
+ }
+}
+
+function init_editor (u) {
+
+ var x = document.getElementById('editor').elements;
+
+ cgi = new CGI();
+ if (cgi.param('adv')) adv_mode = true;
+ try {
+ if (xulG) if (xulG.adv) adv_mode = true;
+ if (xulG) if (xulG.params) if (xulG.params.adv) adv_mode = true;
+ } catch (e) {}
+
+ apply_adv_mode(document.getElementById('editor'));
+
+ ses_id = cgi.param('ses');
+ try {
+ if (xulG) if (xulG.ses) ses_id = xulG.ses;
+ if (xulG) if (xulG.params) if (xulG.params.ses) ses_id = xulG.params.ses;
+ } catch (e) {}
+
+ var usr_id = cgi.param('usr');
+ try {
+ if (xulG) if (xulG.usr_id) usr_id = xulG.usr_id;
+ if (xulG) if (xulG.params) if (xulG.params.usr_id) usr_id = xulG.params.usr_id;
+ } catch (e) {}
+
+ var usr_barcode = cgi.param('barcode');
+ try {
+ if (xulG) if (xulG.usr_barcode) usr_ibarcode = xulG.usr_barcode;
+ if (xulG) if (xulG.params) if (xulG.params.usr_barcode) usr_ibarcode = xulG.params.usr_barcode;
+ } catch (e) {}
+
+ try {
+ var req;
+ if (usr_id) {
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve', ses_id, usr_id );
+ } else {
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve_by_barcode', ses_id, usr_barcode );
+ }
+ req.send(true);
+ user = req.getResultObject();
+ } catch (E) {
+ alert(E);
+ }
+
+ if (user.usrname()) x['user.usrname'].value = user.usrname();
+ x['user.usrname'].setAttribute('onchange','user.usrname(this.value)');
+
+ if (user.card() && user.card().barcode()) x['user.card.barcode'].value = user.card().barcode();
+ x['user.card.barcode'].setAttribute('onchange','user.card().barcode(this.value)');
+
+ if (user.first_given_name()) x['user.first_given_name'].value = user.first_given_name();
+ x['user.first_given_name'].setAttribute('onchange','user.first_given_name(this.value)');
+
+ if (user.second_given_name()) x['user.second_given_name'].value = user.second_given_name();
+ x['user.second_given_name'].setAttribute('onchange','user.second_given_name(this.value);');
+
+ if (user.family_name()) x['user.family_name'].value = user.family_name();
+ x['user.family_name'].setAttribute('onchange','user.family_name(this.value)');
+
+ // grab the editing staff user object
+ req = new RemoteRequest( 'open-ils.auth', 'open-ils.auth.session.retrieve', ses_id );
+ req.send(true);
+ var staff = req.getResultObject();
+
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id );
+ req.send(true);
+ var staff_perms = req.getResultObject();
+
+ // Get the top of the staff perm org for ASSIGN_WORK_ORG_UNIT
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.perm.highest_org', ses_id, staff.id(), 'ASSIGN_WORK_ORG_UNIT' );
+ req.send(true);
+ var top_work_ou = req.getResultObject();
+
+ // and now, the orgs where this staff member can apply the perms
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_tree.descendants.retrieve', top_work_ou);
+ req.send(true);
+ var work_ou_tree = req.getResultObject();
+
+ // and now, the orgs where this staff member can apply the perms
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.get_work_ous', ses_id, user.id());
+ req.send(true);
+ user_work_ous = req.getResultObject();
+
+ // and finally, the ou types
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_types.retrieve' );
+ req.send(true);
+ ou_type_list = req.getResultObject();
+
+ user_perms = [];
+ perm_list = [];
+ if (user.id() > 0) {
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id, user.id() );
+ req.send(true);
+ user_perms = req.getResultObject();
+
+ req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.retrieve' );
+ req.send(true);
+ perm_list = req.getResultObject();
+ }
+
+ f = document.getElementById('permissions');
+ while (f.firstChild) f.removeChild(f.lastChild);
+
+ var rcount = 0;
+ for (var i in perm_list.sort(function(a,b){ if (a.code() < b.code()) return -1;return 1; }))
+ display_perm(f,perm_list[i],staff_perms, rcount++);
+
+ f = document.getElementById('work_ous');
+ while (f.firstChild) f.removeChild(f.lastChild);
+
+ //flatten the ou tree, keep only those with can_hav_users = true
+ work_ou_list = [];
+ trim_ou_tree( [work_ou_tree], work_ou_list );
+
+ rcount = 0;
+ for (var i in work_ou_list.sort( function(a,b){ if (a.name() < b.name()) return -1;return 1; }) )
+ display_work_ou(f,work_ou_list[i], rcount++);
+
+ return true;
+}
+
+function grep ( code, list ) {
+ var ret = [];
+ for (var i in list) {
+ if (code(list[i])) ret.push(list[i]);
+ }
+ return ret;
+}
+
+function trim_ou_tree (tree, list) {
+ for (var i in tree) {
+ if (!tree[i]) continue;
+
+ var type = grep( function(x) {return x.id() == tree[i].ou_type()}, ou_type_list )[0];
+ if ( type && type.can_have_users() == 't' )
+ list.push(tree[i]);
+
+ if (tree[i].children()) trim_ou_tree(tree[i].children(), list);
+ }
+}
+
+function display_work_ou (root,ou_def,r) {
+
+ var wrow = findNodeByName(document.getElementById('work_ou-tmpl'), 'wrow').cloneNode(true);
+ root.appendChild(wrow);
+
+ var label_cell = findNodeByName(wrow,'label');
+ findNodeByName(label_cell,'a.name').appendChild(text(ou_def.name()));
+ findNodeByName(label_cell,'a.shortname').appendChild(text(ou_def.shortname()));
+ if (r % 2) label_cell.className += ' odd';
+
+ var apply_cell = findNodeByName(wrow,'wapply');
+ findNodeByName(apply_cell,'a.id').setAttribute('workou_id', ou_def.id());
+ if (r % 2) apply_cell.className += ' odd';
+
+ var has_it = grep(
+ function(x){ return x.work_ou() == ou_def.id() },
+ user_work_ous
+ ).length;
+
+ findNodeByName(apply_cell,'a.id').checked = has_it > 0 ? true : false;
+}
+
+function display_perm (root,perm_def,staff_perms, r) {
+
+ var prow = findNodeByName(document.getElementById('permission-tmpl'), 'prow').cloneNode(true);
+ root.appendChild(prow);
+
+ var all = false;
+ for (var i in staff_perms) {
+ if (staff_perms[i].perm() == -1) {
+ all = true;
+ break;
+ }
+ }
+
+
+ var sp,up;
+ if (!all) {
+ for (var i in staff_perms) {
+ if (perm_def.id() == staff_perms[i].perm() || staff_perms[i].perm() == -1) {
+ sp = staff_perms[i];
+ break;
+ }
+ }
+ }
+
+ for (var i in user_perms) {
+ if (perm_def.id() == user_perms[i].perm())
+ up = user_perms[i];
+ }
+
+
+ var dis = false;
+ if ((up && up.id() < 0) || !sp || !sp.grantable()) dis = true;
+ if (all) dis = false;
+
+ var label_cell = findNodeByName(prow,'plabel');
+ findNodeByName(label_cell,'p.code').appendChild(text(perm_def.code()));
+ findNodeByName(label_cell,'p.code').setAttribute('title', perm_def.description());
+ findNodeByName(label_cell,'p.code').setAttribute('permid', perm_def.id());
+ if (r % 2) label_cell.className += ' odd';
+
+ var apply_cell = findNodeByName(prow,'papply');
+ findNodeByName(apply_cell,'p.id').disabled = dis;
+ findNodeByName(apply_cell,'p.id').checked = up ? true : false;
+ if (r % 2) apply_cell.className += ' odd';
+
+ var depth_cell = findNodeByName(prow,'pdepth');
+ findNodeByName(depth_cell,'p.depth').disabled = dis;
+ findNodeByName(depth_cell,'p.depth').id = 'perm-depth-' + perm_def.id();
+ if (r % 2) depth_cell.className += ' odd';
+ selectBuilder(
+ 'perm-depth-' + perm_def.id(),
+ globalOrgTypes,
+ (up ? up.depth() : findOrgDepth(user.home_ou())),
+ { label_field : 'name',
+ value_field : 'depth',
+ empty_label : $('staff.patron.user_edit.display_perm.select_one').innerHTML,
+ empty_value : '',
+ clear : true }
+ );
+
+ var grant_cell = findNodeByName(prow,'pgrant');
+ findNodeByName(grant_cell,'p.grantable').disabled = dis;
+ findNodeByName(grant_cell,'p.grantable').checked = up ? (up.grantable() ? true : false) : false;
+ if (r % 2) grant_cell.className += ' odd';
+
+}
+
+
+function selectBuilder (id, objects, def, args) {
+ var label_field = args['label_field'];
+ var value_field = args['value_field'];
+ var depth = args['depth'];
+
+ if (!depth) depth = 0;
+
+ args['depth'] = parseInt(depth) + 1;
+
+ var child_field_name = args['child_field_name'];
+
+ var sel = id;
+ if (typeof sel != 'object')
+ sel = document.getElementById(sel);
+
+ if (args['clear']) {
+ for (var o in sel.options) {
+ sel.options[o] = null;
+ }
+ args['clear'] = false;
+ if (args['empty_label']) {
+ sel.options[0] = new Option( args['empty_label'], args['empty_value'] );
+ sel.selectedIndex = 0;
+ }
+ }
+
+ for (var i in objects) {
+ var l = objects[i][label_field];
+ var v = objects[i][value_field];
+
+ if (typeof l == 'function')
+ l = objects[i][label_field]();
+
+ if (typeof v == 'function')
+ v = objects[i][value_field]();
+
+ var opt = new Option( l, v );
+
+ if (depth) {
+ var d = 10 * depth;
+ opt.style.paddingLeft = '' + d + 'px';
+ }
+
+ sel.options[sel.options.length] = opt;
+
+
+ if (typeof def == 'object') {
+ for (var j in def) {
+ if (v == def[j]) {
+ opt.selected = true;
+ sel.value = v;
+ }
+ }
+ } else {
+ if (v == def) {
+ opt.selected = true;
+ sel.value = v;
+ }
+ }
+
+ if (child_field_name) {
+ var c = objects[i][child_field_name];
+ if (typeof c == 'function')
+ c = objects[i][child_field_name]();
+
+ selectBuilder(
+ id,
+ c,
+ def,
+ { label_field : args['label_field'],
+ value_field : args['value_field'],
+ depth : args['depth'],
+ child_field_name : args['child_field_name'] }
+ );
+ }
+
+ }
+}
+
+function findNodesByClass(root, nodeClass, list) {
+ if(!list) list = [];
+ if( !root || !nodeClass) {
+ return null;
+ }
+
+ if(root.nodeType != 1) {
+ return null;
+ }
+
+ if(root.className.match(nodeClass)) list.push( root );
+
+ var children = root.childNodes;
+
+ for( var i = 0; i != children.length; i++ ) {
+ findNodesByClass(children[i], nodeClass, list);
+ }
+
+ return list;
+}
+