Here is implemented an offline mode interface for the web staff client.
It is made available during both network and server outages by using the
UpUp[1] service worker wrapper.
We leverage Lovefield[2] for local storage of library settings, configuration
data, offline transactions, and the standalone offline block list.
In order to make use of the offline interface, users should first log into
the web staff client and navigate to the "Search -> Search for Patrons"
interface, perform a search, select a user from the results, and open the
Patron Editor interface. This will allow the offline interface to collect
all the relevant configuration information for the workstation. In addition,
the offline interface available from the Circulation menu provides a "Download
block list" button when accessed while logged in.
[1]https://www.talater.com/upup/
[2]https://google.github.io/lovefield/
Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Conflicts:
Open-ILS/src/templates/staff/base_js.tt2
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
my $cgi = new CGI;
my $basedir = $config{base_dir} || die "Offline config error: no base_dir defined\n";
my $bootstrap = $config{bootstrap} || die "Offline config error: no bootstrap defined\n";
+my $webclient = $cgi->param('wc');
my $wsname = $cgi->param('ws');
my $org = $cgi->param('org');
my $authtoken = $cgi->param('ses') || "";
my $barcode = $command->{user}->{card}->{barcode};
delete $command->{user}->{card};
+ delete $command->{user}->{cards} if $command->{user}->{cards};
$logger->info("offline: creating new user with barcode $barcode");
delete $command->{user}->{survey_responses};
$actor->survey_responses(\@sresp) if @sresp;
+ my $bid = undef;
# extract the billing address
if( my $addr = $command->{user}->{billing_address} ) {
+ $bid = $command->{user}->{billing_address}->{id};
$billing_address = Fieldmapper::actor::user_address->new;
$billing_address->$_($addr->{$_}) for keys %$addr;
$billing_address->isnew(1);
$logger->debug("offline: read billing address ".$billing_address->street1);
}
+ my $mid = undef;
# extract the mailing address
if( my $addr = $command->{user}->{mailing_address} ) {
- $mailing_address = Fieldmapper::actor::user_address->new;
- $mailing_address->$_($addr->{$_}) for keys %$addr;
- $mailing_address->isnew(1);
- $mailing_address->id(-2);
- $mailing_address->usr(-1);
+ $mid = $command->{user}->{mailing_address}->{id};
+ if ($webclient && $mid != $bid) {
+ $mailing_address = Fieldmapper::actor::user_address->new;
+ $mailing_address->$_($addr->{$_}) for keys %$addr;
+ $mailing_address->isnew(1);
+ $mailing_address->id(-2);
+ $mailing_address->usr(-1);
+ $logger->debug("offline: read mailing address ".$mailing_address->street1);
+ } elsif (!$webclient) {
+ $mailing_address = Fieldmapper::actor::user_address->new;
+ $mailing_address->$_($addr->{$_}) for keys %$addr;
+ $mailing_address->isnew(1);
+ $mailing_address->id(-2);
+ $mailing_address->usr(-1);
+ $logger->debug("offline: read mailing address ".$mailing_address->street1);
+ }
delete $command->{user}->{mailing_address};
- $logger->debug("offline: read mailing address ".$mailing_address->street1);
}
# make sure we have values for both
push( @{$actor->addresses}, $billing_address )
unless $billing_address->id eq $mailing_address->id;
+
+ my $aid = -3;
+ for my $a ( @{$command->{user}->{addresses}} ) {
+ next if ($a->{id} == $bid || $a->{id} == $mid);
+ # extract all other addresses
+ my $addr = Fieldmapper::actor::user_address->new;
+ $addr->$_($a->{$_}) for keys %$a;
+ $addr->isnew(1);
+ $addr->id($aid);
+ $addr->usr(-1);
+ $logger->debug("offline: read other address ".$addr->street1);
+ $aid--;
+ push( @{$actor->addresses}, $addr );
+ }
# pull all of the rest of the data from the command blob
- $actor->$_( $command->{user}->{$_} ) for keys %{$command->{user}};
+ $actor->$_( $command->{user}->{$_} ) for grep { $_ ne 'addresses' } keys %{$command->{user}};
# calculate the expire date for the patron based on the profile group
my ($grp) = grep {$_->id == $actor->profile} @$user_groups;
<option value="renew">[% l('Renew') %]</option>
<option value="transit_list">[% l('Transit List') %]</option>
<option value="transit_slip">[% l('Transit Slip') %]</option>
+ <option value="offline_checkout">[% l('Offline Checkout') %]</option>
+ <option value="offline_renew">[% l('Offline Renew') %]</option>
+ <option value="offline_checkin">[% l('Offline Checkin') %]</option>
+ <option value="offline_in_house_use">[% l('Offline In-house Use') %]</option>
</select>
<label for="print_context">[% l('Force Printer Context') %]</label>
<select class="form-control" ng-model="print.template_context">
+<script src="/upup.min.js"></script>
+<script>
+UpUp.start({
+ 'content-url': '[% ctx.base_path %]/staff/offline-interface',
+ 'cache-version': '[% USE date(format = '%Y-%m-%d'); date.format; %]',
+ 'service-worker-url': '/upup.sw.min.js',
+ 'assets': [
+ '/IDL2js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/bootstrap.min.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/hotkeys.min.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/ngToast.min.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/ngToast-animations.min.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/tree-control.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/tree-control-attribute.css',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/css/tablesort.css',
+ '[% ctx.base_path %]/staff/css/print.css',
+ '[% ctx.base_path %]/staff/css/cat.css',
+ '[% ctx.base_path %]/staff/css/style.css',
+ '[% ctx.base_path %]/staff/css/circ.css',
+ '[% ctx.media_prefix %]/js/dojo/opensrf/md5.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/jquery.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-route.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ui-bootstrap-tpls.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/hotkeys.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-file-saver.bundle.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-location-update.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-animate.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-sanitize.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-cookies.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ngToast.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-tree-control.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/iframeResizer.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/ng-order-object-by.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-tablesort.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/js/lovefield.min.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/fonts/glyphicons-halflings-regular.woff',
+ '[% ctx.media_prefix %]/js/ui/default/staff/build/fonts/glyphicons-halflings-regular.woff2',
+ '[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js',
+ '[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js',
+ '[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_ws.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/core.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/strings.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/event.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/net.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/env.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/org.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/hatch.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/print.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/audio.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/coresvc.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/user.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/statusbar.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/date.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/op_change.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/services/file.js',
+ '[% ctx.media_prefix %]/js/ui/default/staff/offline.js',
+ '[% ctx.base_path %]/staff/share/t_alert_dialog',
+ '[% ctx.base_path %]/staff/share/t_confirm_dialog',
+ '[% ctx.base_path %]/staff/share/t_datetime',
+ '[% ctx.base_path %]/staff/share/t_progress_dialog',
+ '[% ctx.base_path %]/staff/share/print_templates/t_offline_in_house_use',
+ '[% ctx.base_path %]/staff/share/print_templates/t_offline_checkout',
+ '[% ctx.base_path %]/staff/share/print_templates/t_offline_checkin',
+ '[% ctx.base_path %]/staff/share/print_templates/t_offline_renew',
+ '/images/question-mark.png'
+ ]
+});
+</script>
+
<script src="/IDL2js"></script>
<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.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/date.js"></script>
<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/op_change.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js"></script>
[% ELSE %]
s.OPT_IN_DIALOG_TITLE = "[% l('Verify Permission to Share Personal Information') %]";
s.OPT_IN_DIALOG = "[% l('Does patron [_1], [_2] from [_3] ([_4]) consent to having their personal information shared with your library?', '{{family_name}}', '{{first_given_name}}', '{{org_name}}', '{{org_shortname}}') %]";
s.OPT_IN_RESTRICTED = "[% l("This patron's record is not viewable at your library.") %]";
+ s.OFFLINE_SESSION_DESC = "[% l('Offline session description') %]";
+ s.OFFLINE_SESSION_CREATE_FAILED = "[% l('Offline session creation failed') %]";
+ s.OFFLINE_SESSION_PROCESSING_FAILED = "[% l('Offline session processing failed') %]";
+ s.OFFLINE_SESSION_UPLOAD_FAILED = "[% l('Offline transaction upload failed') %]";
+ s.PATRON_NOT_FOUND = "[% l('Patron not found') %]";
+ s.PATRON_BLOCKED = "[% l('Patron blocked') %]";
+ s.BAD_BARCODE = "[% l('Bad item barcode') %]";
+ s.BAD_BARCODE_CD = "[% l('Item barcode does not have a correct check digit.') %]";
+ s.BAD_PATRON_BARCODE = "[% l('Bad patron barcode') %]";
+ s.BAD_PATRON_BARCODE_CD = "[% l('Patron barcode does not have a correct check digit.') %]";
+ s.ITEM_NOT_FOUND = "[% l('Item not found') %]";
+ s.CONFIRM_CLEAR_PENDING = "[% l('Clear pending transactions') %]";
+ s.CONFIRM_CLEAR_PENDING_BODY = "[% l('Are you certain you want to clear these pending offline transactions? This action is irreversible. Transactions cannot be recovered after clearing!') %]";
}]);
</script>
ng-disabled="edit_passthru.hide_save_actions()"
ng-click="edit_passthru.save()">[% l('Save') %]</button>
</span>
- <span class="pad-all-min">
+ <span ng-if="!offline" class="pad-all-min">
<button type="button" class="btn btn-default"
ng-disabled="edit_passthru.hide_save_actions()"
ng_click="edit_passthru.save({clone:true})">[% l('Save & Clone') %]</button>
[% DOC_IMG = '/images/question-mark.png' %]
<!-- register banner -->
-<div ng-if="!patron_id" class='patron-reg-fixed-bar'>
+<div ng-if="!patron_id" ng-class='{"patron-reg-fixed-bar":!offline}'>
<div class="container-fluid" style="text-align:center">
<div class="alert alert-info alert-less-pad strong-text-2">
<div class="col-md-6 patron-reg-example">
<button class="btn btn-default" ng-show="!patron.isnew"
ng-click="replace_card()">[% l('Replace Barcode') %]</button>
- <button class="btn btn-default"
+ <button class="btn btn-default" ng-if="!patron.isnew"
ng-click="cards_dialog()">[% l('See All') %]</button>
<div ng-show="dupe_barcode" class="patron-reg-validation-alert">
<span>[% l('Barcode is already in use') %]</span>
</div>
</div>
<div class="col-md-3">
- <button class="btn btn-default" ng-disabled="!perms.CREATE_USER_GROUP_LINK"
+ <button class="btn btn-default" ng-if="!offline" ng-disabled="!perms.CREATE_USER_GROUP_LINK"
ng-click="secondary_groups_dialog()">[% l('Secondary Groups') %]</button>
</div>
</div>
</div>
</div>
+<div ng-if="!offline">
+
<div class="alert alert-success row" role="alert">
<div class="col-md-6">[% l('User Settings') %]</div>
</div>
</div>
</div>
+</div> <!-- end offline test -->
+
<!-- addresses -->
<div ng-repeat="addr in patron.addresses">
class="btn btn-success">[% l('New Address') %]</button>
</div>
-<div class="alert alert-success row" role="alert"
+<div ng-if="!offline">
+<div class="alert alert-success row" role="alert"
ng-show="show_field('stat_cats') || hasRequiredStatCat" ng-if="stat_cats.length > 0">
<div class="col-md-6">[% l('Statistical Categories') %]</div>
</div>
</div><!-- show/hide wrapper -->
</div>
+</div>
<!-- surveys -->
# 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/';
+# path to build files (js, css, fonts). No / at end, because the user supplies it
+WEB_BUILD_PATH = ctx.media_prefix _ '/js/ui/default/staff/build';
%]
[% BLOCK APP_JS %]
<!-- splash / login page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js"></script>
<script src="[% ctx.media_prefix %]/js/ui/default/staff/app.js"></script>
[% END %]
</a>
<ul uib-dropdown-menu>
- <li>
+ <li ng-if="username">
<a href="./circ/patron/bcsearch" target="_self"
eg-accesskey="[% l('f1') %]"
eg-accesskey-desc="[% l('Check Out') %]">
[% l('Check Out') %]
</a>
</li>
- <li>
+ <li ng-if="!username">
+ <a href="" ng-click="rs.active_tab('checkout')" target="_self"
+ eg-accesskey="[% l('f1') %]"
+ eg-accesskey-desc="[% l('Check Out') %]">
+ <span class="glyphicon glyphicon-export"></span>
+ [% l('Check Out') %]
+ </a>
+ </li>
+ <li ng-if="username">
<a href="./circ/checkin/checkin" target="_self"
eg-accesskey="[% l('f2') %]"
eg-accesskey-desc="[% l('Check In') %]">
[% l('Check In') %]
</a>
</li>
+ <li ng-if="!username">
+ <a href="" ng-click="rs.active_tab('checkin')" target="_self"
+ eg-accesskey="[% l('f2') %]"
+ eg-accesskey-desc="[% l('Check In') %]">
+ <span class="glyphicon glyphicon-import"></span>
+ [% l('Check In') %]
+ </a>
+ </li>
<li>
<a href="./circ/checkin/capture" target="_self"
eg-accesskey="[% l('shift+f2') %]"
[% l('Pull List for Hold Requests') %]
</a>
</li>
- <li>
+ <li ng-if="username">
<a href="./circ/renew/renew" target="_self"
eg-accesskey="[% l('ctrl+f2') %]"
eg-accesskey-desc="[% l('Renew items') %]">
[% l('Renew Items') %]
</a>
</li>
- <li>
+ <li ng-if="!username">
+ <a href="" ng-click="rs.active_tab('renew')" target="_self"
+ eg-accesskey="[% l('ctrl+f2') %]"
+ eg-accesskey-desc="[% l('Renew items') %]">
+ <span class="glyphicon glyphicon-refresh"></span>
+ [% l('Renew Items') %]
+ </a>
+ </li>
+ <li ng-if="username">
<a href="./circ/patron/register" target="_self"
eg-accesskey="[% l('shift+f1') %]"
eg-accesskey-desc="[% l('Register Patron') %]">
[% l('Register Patron') %]
</a>
</li>
+ <li ng-if="!username">
+ <a href="" ng-click="rs.active_tab('register')" target="_self"
+ eg-accesskey="[% l('shift+f1') %]"
+ eg-accesskey-desc="[% l('Register Patron') %]">
+ <span class="glyphicon glyphicon-user"></span>
+ [% l('Register Patron') %]
+ </a>
+ </li>
<li>
<a href="./circ/patron/last" target="_self"
eg-accesskey="[% l('f8') %]"
<span>[% l('Verify Credentials') %]</span>
</a>
</li>
- <li>
+ <li ng-if="username">
<a href="./circ/in_house_use/index" target="_self"
eg-accesskey="[% l('f6') %]"
eg-accesskey-desc="[% l('Record In-House Use') %]">
<span>[% l('Record In-House Use') %]</span>
</a>
</li>
+ <li ng-if="!username">
+ <a href="" ng-click="rs.active_tab('in_house_use')" target="_self"
+ eg-accesskey="[% l('f6') %]"
+ eg-accesskey-desc="[% l('Record In-House Use') %]">
+ <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('Reprint Last Receipt') %]</span>
</a>
</li>
+ <li class="divider"></li>
+ <li>
+ <a href="./offline-interface/session" target="_self">
+ <span class="glyphicon glyphicon-alert"></span>
+ <span>[% l('Offline Circulation') %]</span>
+ </a>
+ </li>
</ul>
</li><!-- circ -->
--- /dev/null
+[%
+ WRAPPER "staff/base.tt2";
+ ctx.page_title = l("Offline");
+ ctx.page_app = "egOffline";
+%]
+
+
+<script type="text/ng-template" id="offline-template">
+
+<div class="row">
+ <div class="col-md-12">
+ <div class="input-group">
+ <div class="input-group-addon"><b>[% l('Workstation') %]</b></div>
+ <div class="input-group-addon">
+ <select class="form-control" required
+ ng-model="workstation"
+ ng-options="ws.id as ws.name for ws in workstations"></select>
+ </div>
+ <div class="input-group-addon"><b>[% l('Working location') %]</b></div>
+ <div class="input-group-addon">
+ <eg-org-selector sticky-setting="eg.org.offline_location" selected="org"></eg-org-selector>
+ </div>
+ <div class="input-group-addon">
+ <button
+ class="btn btn-primary"
+ ng-disabled="all_xact.length == 0 || active_tab == 'register'"
+ ng-click="save()">
+ [% l('Save Transactions') %]
+ </button>
+ <button
+ class="btn btn-default"
+ ng-disabled="!printed"
+ ng-click="reprintLast()">
+ [% l('Reprint Last Receipt') %]
+ </button>
+ <button
+ class="btn btn-default"
+ ng-if="logged_in"
+ ng-click="downloadBlockList()">
+ [% l('Download block list') %]
+ </button>
+ <button
+ class="btn btn-default"
+ ng-disabled="pending_xacts.length == 0"
+ eg-line-exporter
+ ng-if="!logged_in"
+ default-file-name="pending.xacts"
+ json-array="pending_xacts"
+ >[% l('Export Transactions') %]</button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div class="row col-md-offset-3 col-md-6 pad-vert">
+ <div ng-show="logged_in && active_tab != 'session'" class="alert alert-danger">
+ <h2>[% l('Warning') %]</h2>
+ [% l('You are about to enter offline mode. If you proceed, you will be logged out.') %]
+ <br/>
+ <br/>
+ <button class="btn btn-danger" ng-click="logout()">[% l('Proceed') %]</button>
+ </div>
+</div>
+
+<div class="row col-md-12 pad-vert">
+ <div class="col-md-12">
+ <uib-tabset active="active_tab">
+ <!-- note that non-numeric index values must be enclosed in single-quotes,
+ otherwise selecting the active table won't work cleanly -->
+ <uib-tab index="'checkout'" heading="[% l('Checkout') %]">
+
+ <div class="row">
+
+ <!-- left-hand side -->
+ <div class="col-md-6" style="border-right:solid 1px;">
+ <div class="row">
+ <div class="col-md-1"></div>
+ <div class="col-md-4">
+ [% l('Due Date:') %]
+ </div>
+ <div class="col-md-4">
+ <eg-date-input id="co_duedate" ng-model="shared.due_date" out-of-range="shared.outOfRange" min-date="minDate"></eg-date-input>
+ </div>
+ <div class="col-md-3">
+ <select class="form-control" ng-model="shared.due_date_offset" ng-change="resetDueDate()">
+ <option value="">[% l('No Offset') %]</option>
+ <option value="3">[% l('Today + 3 days') %]</option>
+ <option value="7">[% l('Today + 7 days') %]</option>
+ <option value="14">[% l('Today + 14 days') %]</option>
+ <option value="30">[% l('Today + 30 days') %]</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1"></div>
+ <div class="col-md-4">
+ [% l('Patron barcode:') %]
+ </div>
+ <div class="col-md-7">
+ <input class="form-control" type="text" ng-model="checkout.patron_barcode" next-on-enter="co_barcode"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1">
+ <input type="radio" ng-model="barcode_type" value="barcode" id="bc_radio"/>
+ </div>
+ <div class="col-md-4">
+ <label style="font-weight:normal !important;" for="bc_radio">[% l('Item Barcode:') %]</label>
+ </div>
+ <div class="col-md-7">
+ <input id="co_barcode"
+ class="form-control"
+ ng-init="barcode_type = 'barcode'"
+ ng-disabled="barcode_type != 'barcode'"
+ type="text"
+ ng-model="checkout.barcode"
+ eg-enter="!notEnough('checkout') && add('checkout')"
+ />
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-md-1">
+ <input type="radio" ng-model="barcode_type" value="noncat" id="nc_radio"/>
+ </div>
+ <div class="col-md-4">
+ <label style="font-weight:normal !important;" for="nc_radio">[% l('Non-cataloged Type:') %]</label>
+ </div>
+ <div class="col-md-5">
+ <select
+ class="form-control"
+ ng-disabled="barcode_type != 'noncat'"
+ ng-options="nct.id() as nct.name() for nct in noncats"
+ ng-model="checkout.noncat_type"
+ >
+ <option value="">[% l('Select Non-cataloged Type') %]</option>
+ </select>
+ </div>
+ <div class="col-md-2">
+ <input
+ class="form-control"
+ ng-disabled="barcode_type != 'noncat'"
+ type="number"
+ min="1"
+ max="100"
+ ng-model="checkout.noncat_count"
+ />
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-2">
+ <button class="btn btn-warning" ng-click="clear('checkout')">[% l('Clear') %]</button>
+ </div>
+ <div class="col-md-4">
+ <input id="do_check_co" type="checkbox" ng-model="strict_barcode" ng-click="changeCheck()"></input>
+ <label for="do_check_co">[% l('Strict Barcode') %]</label>
+ </div>
+ <div class="col-md-6">
+ <input id="do_print_co" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+ <label for="do_print_co">[% l('Print receipt') %]</label>
+ <button class="btn btn-primary pull-right" ng-disabled="notEnough('checkout')" ng-click="add('checkout','co_barcode')">[% l('Checkout') %]</button>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- right-hand side -->
+ <div class="col-md-6 container">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Patron barcode') %]</th>
+ <th>[% l('Item barcode') %]</th>
+ <th>[% l('Due date') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in xact_page.checkout track by $index">
+ <td>{{xact.patron_barcode}}</td>
+ <td>{{xact.barcode}}</td>
+ <td>{{xact.due_date | date:'shortDate'}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ </div>
+
+ </uib-tab>
+ <uib-tab index="'renew'" heading="[% l('Renew') %]">
+
+ <div class="row">
+
+ <!-- left-hand side -->
+ <div class="col-md-6" style="border-right:solid 1px;">
+ <div class="row">
+ <div class="col-md-1"></div>
+ <div class="col-md-4">
+ [% l('Due Date:') %]
+ </div>
+ <div class="col-md-4">
+ <eg-date-input ng-model="shared.due_date" out-of-range="shared.outOfRange" min-date="minDate"></eg-date-input>
+ </div>
+ <div class="col-md-3">
+ <select class="form-control" ng-model="shared.due_date_offset" ng-change="resetDueDate()">
+ <option value="">[% l('No Offset') %]</option>
+ <option value="3">[% l('Today + 3 days') %]</option>
+ <option value="7">[% l('Today + 7 days') %]</option>
+ <option value="14">[% l('Today + 14 days') %]</option>
+ <option value="30">[% l('Today + 30 days') %]</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1"></div>
+ <div class="col-md-4">
+ [% l('Patron barcode:') %]
+ </div>
+ <div class="col-md-7">
+ <input class="form-control" type="text" ng-model="renew.patron_barcode" next-on-enter="re_barcode"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1"></div>
+ <div class="col-md-4">
+ [% l('Item Barcode:') %]
+ </div>
+ <div class="col-md-7">
+ <input class="form-control" type="text" ng-model="renew.barcode" id="re_barcode" eg-enter="!notEnough('renew') && add('renew')"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-2">
+ <button class="btn btn-warning" ng-click="clear('renew')">[% l('Clear') %]</button>
+ </div>
+ <div class="col-md-4">
+ <input id="do_check_r" type="checkbox" ng-model="strict_barcode" ng-click="changeCheck()"></input>
+ <label for="do_check_r">[% l('Strict Barcode') %]</label>
+ </div>
+ <div class="col-md-6">
+ <input id="do_print_r" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+ <label for="do_print_r">[% l('Print receipt') %]</label>
+ <button class="btn btn-primary pull-right" ng-disabled="notEnough('renew')" ng-click="add('renew','re_barcode')">[% l('Renew') %]</button>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- right-hand side -->
+ <div class="col-md-6 container">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Patron barcode') %]</th>
+ <th>[% l('Item barcode') %]</th>
+ <th>[% l('Due date') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in xact_page.renew track by $index">
+ <td>{{xact.patron_barcode}}</td>
+ <td>{{xact.barcode}}</td>
+ <td>{{xact.due_date | date:'shortDate'}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ </div>
+
+ </uib-tab>
+ <uib-tab index="'in_house_use'" heading="[% l('In-house Use') %]">
+
+ <div class="row">
+
+ <!-- left-hand side -->
+ <div class="col-md-6 container" style="border-right:solid 1px;">
+
+ <div class="row">
+ <div class="col-md-1"></div>
+ <div class="col-md-5">
+ [% l('Use count:') %]
+ </div>
+ <div class="col-md-6">
+ <input class="form-control" type="number" min="1" max="100" next-on-enter="ihu_barcode" ng-model="in_house_use.count"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1"></div>
+ <div class="col-md-5">
+ [% l('Item Barcode:') %]
+ </div>
+ <div class="col-md-6">
+ <input class="form-control" type="text" ng-model="in_house_use.barcode" eg-enter="add('in_house_use')" id="ihu_barcode"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-2">
+ <button class="btn btn-warning" ng-click="clear('in_house_use')">[% l('Clear') %]</button>
+ </div>
+ <div class="col-md-4">
+ <input id="do_check_ihu" type="checkbox" ng-model="strict_barcode" ng-click="changeCheck()"></input>
+ <label for="do_check_ihu">[% l('Strict Barcode') %]</label>
+ </div>
+ <div class="col-md-6">
+ <input id="do_print_ihu" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+ <label for="do_print_ihu">[% l('Print receipt') %]</label>
+ <button class="btn btn-primary pull-right" ng-disabled="notEnough('in_house_use')" ng-click="add('in_house_use','ihu_barcode')">[% l('Record Use') %]</button>
+ </div>
+ </div>
+
+ </div>
+
+ <!-- right-hand side -->
+ <div class="col-md-6 container">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Item barcode') %]</th>
+ <th>[% l('Use count') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in xact_page.in_house_use track by $index">
+ <td>{{xact.barcode}}</td>
+ <td>{{xact.count}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ </div>
+
+ </uib-tab>
+ <uib-tab index="'checkin'" heading="[% l('Checkin') %]">
+
+ <div class="row">
+
+ <!-- left-hand side -->
+ <div class="col-md-6" style="border-right:solid 1px;">
+
+ <div class="row">
+ <div class="col-md-1"></div>
+ <div class="col-md-5">
+ [% l('Checkin Date:') %]
+ </div>
+ <div class="col-md-6">
+ <eg-date-input ng-model="checkin.backdate"></eg-date-input>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-1"></div>
+ <div class="col-md-5">
+ [% l('Item Barcode:') %]
+ </div>
+ <div class="col-md-6">
+ <input id="ci_barcode" class="form-control" type="text" ng-model="checkin.barcode" eg-enter="!notEnough('checkin') && add('checkin')"/>
+ </div>
+ </div>
+
+ <div class="row pad-vert">
+ <div class="col-md-2">
+ <button class="btn btn-warning" ng-click="clear('checkin')">[% l('Clear') %]</button>
+ </div>
+ <div class="col-md-4">
+ <input id="do_check_ci" type="checkbox" ng-model="strict_barcode" ng-click="changeCheck()"></input>
+ <label for="do_check_ci">[% l('Strict Barcode') %]</label>
+ </div>
+ <div class="col-md-6">
+ <input id="do_print_ci" type="checkbox" ng-model="do_print" ng-click="changePrint()"></input>
+ <label for="do_print_ci">[% l('Print receipt') %]</label>
+ <button class="btn btn-primary pull-right" ng-disabled="notEnough('checkin')" ng-click="add('checkin','ci_barcode')">[% l('Checkin') %]</button>
+
+ </div>
+ </div>
+
+ </div>
+
+ <!-- right-hand side -->
+ <div class="col-md-6 container">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Item barcode') %]</th>
+ <th>[% l('Effective Checkin date') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in xact_page.checkin track by $index">
+ <td>{{xact.barcode}}</td>
+ <td>{{xact.backdate | date:'shortDate'}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ </div>
+
+ </uib-tab>
+ <uib-tab index="'register'" heading="[% l('Register Patron') %]">
+ <div ng-controller="PatronRegCtrl">
+ <div>[% INCLUDE 'staff/circ/patron/t_edit.tt2' %]</div>
+ </div>
+ </uib-tab>
+ <uib-tab ng-if="logged_in" index="'session'" heading="[% l('Session Management') %]">
+ <div class="col-md-12" ng-controller="OfflineSessionCtrl">
+ <uib-tabset active="active_session_tab">
+ <uib-tab index="'pending'" heading="[% l('Pending Transactions') %]">
+ <div class="row">
+ <div class="col-md-12 container">
+ <button
+ class="btn btn-default"
+ ng-disabled="pending_xacts.length == 0"
+ eg-line-exporter
+ default-file-name="pending.xacts"
+ json-array="pending_xacts"
+ >[% l('Export Transactions') %]</button>
+ <div class="btn-group">
+ <span class="btn btn-default btn-file">
+ [% l('Import Transactions') %]
+ <input type="file" eg-file-reader container="imported_pending_xacts.data">
+ </span>
+ </div>
+ <button class="btn btn-warning pull-right" ng-click="clear_pending()">[% l('Clear Transactions') %]</button>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12 container">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Type') %]</th>
+ <th>[% l('Timestamp') %]</th>
+ <th>[% l('Patron Barcode') %]</th>
+ <th>[% l('Item Barcode') %]</th>
+ <th>[% l('Non-cataloged Type') %]</th>
+ <th>[% l('Checkout Date') %]</th>
+ <th>[% l('Due Date') %]</th>
+ <th>[% l('Checkin Date') %]</th>
+ <th>[% l('First Name') %]</th>
+ <th>[% l('Last Name') %]</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in pending_xacts track by $index">
+ <td>{{xact.type}}</td>
+ <td>{{createDate(xact.timestamp, true) | date:'short'}}</td>
+ <td>{{xact.patron_barcode || xact.user.card.barcode}}</td>
+ <td>{{xact.barcode}}</td>
+ <td>{{lookupNoncatTypeName(xact.noncat_type)}}</td>
+ <td>{{createDate(xact.checkout_time) | date:'short'}}</td>
+ <td>{{createDate(xact.due_date) | date:'shortDate'}}</td>
+ <td>{{createDate(xact.backdate) | date:'shortDate'}}</td>
+ <td>{{xact.user.first_given_name}}</td>
+ <td>{{xact.user.family_name}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </uib-tab>
+ <uib-tab index="'offline_sessions'" heading="[% l('Offline Sessions') %]">
+ <div class="row">
+ <div class="col-md-12">
+ <button
+ class="btn btn-primary"
+ ng-disabled="!logged_in"
+ ng-click="createSession()">[% l('Create Session') %]</button>
+ <button
+ class="btn btn-default pull-right"
+ ng-disabled="!logged_in"
+ ng-click="refreshSessions()">[% l('Refresh') %]</button>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12"><h2>[% l('Session List') %]</h2></div>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <table class="table" ts-wrapper>
+ <thead>
+ <tr>
+ <th ts-criteria="org">[% l('Organization') %]</th>
+ <th ts-criteria="creator">[% l('Created By') %]</th>
+ <th ts-criteria="description">[% l('Description') %]</th>
+ <th ts-criteria="create_time|parseInt" ts-default="descending">[% l('Date Created') %]</th>
+ <th>[% l('Upload Count') %]</th>
+ <th>[% l('Transactions Processed') %]</th>
+ <th ts-criteria="end_time|parseInt">[% l('Date Completed') %]</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ts-repeat
+ ng-repeat="ses in sessions track by $index"
+ ng-click="setSession(ses, $index)"
+ ng-class="{'bg-info':current_session_index==$index}"
+ >
+ <td>{{ses.org}}</td>
+ <td>{{ses.creator}}</td>
+ <td>{{ses.description}}</td>
+ <td>{{createDate(ses.create_time, true) | date:'short'}}</td>
+ <td>{{ses.total}}</td>
+ <td>{{ses.num_complete}}</td>
+ <td>{{createDate(ses.end_time, true) | date:'short'}}</td>
+ <td>
+ <button
+ class="btn btn-info btn-xs"
+ ng-disabled="!logged_in || pending_xacts.length == 0 || ses.end_time"
+ ng-click="uploadPending(ses, $index)"
+ >[% l('Upload') %]</button>
+ <button
+ class="btn btn-warning btn-xs"
+ ng-disabled="!logged_in || ses.total == 0 || ses.end_time"
+ ng-click="processSession(ses, $index)"
+ >[% l('Process') %]</button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-md-12"><hr/></div>
+ </div>
+ <div class="row">
+ <div class="col-md-12"><h2>[% l('Exception List') %]</h2></div>
+ </div>
+ <div class="row">
+ <div class="col-md-12">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>[% l('Workstation') %]</th>
+ <th>[% l('Type') %]</th>
+ <th>[% l('Timestamp') %]</th>
+ <th>[% l('Event Name') %]</th>
+ <th>[% l('Patron Barcode') %]</th>
+ <th>[% l('Item Barcode') %]</th>
+ <th>[% l('Non-cataloged Type') %]</th>
+ <th>[% l('Checkout Date') %]</th>
+ <th>[% l('Due Date') %]</th>
+ <th>[% l('Checkin Date') %]</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="xact in current_session.exceptions track by $index">
+ <td>{{xact.command._workstation}}</td>
+ <td>{{xact.command.type}}</td>
+ <td>{{createDate(xact.command.timestamp, true) | date:'short'}}</td>
+ <td>{{xact.event.textcode}}</td>
+ <td>{{xact.command.patron_barcode || xact.command.user.card.barcode}}</td>
+ <td>{{xact.command.barcode}}</td>
+ <td>{{lookupNoncatTypeName(xact.command.noncat_type)}}</td>
+ <td>{{createDate(xact.command.checkout_time) | date:'short'}}</td>
+ <td>{{createDate(xact.command.due_date) | date:'shortDate'}}</td>
+ <td>{{createDate(xact.command.backdate) | date:'shortDate'}}</td>
+ <td>
+ <button
+ class="btn btn-info btn-xs"
+ ng-disabled="!logged_in || !xact.command.barcode"
+ ng-click="retrieveItem(xact.command.barcode)">[% l('Item') %]</button>
+ <button
+ class="btn btn-info btn-xs"
+ ng-disabled="!logged_in || (!xact.command.patron_barcode && xact.command.user.card.barcode)"
+ ng-click="retrievePatron(xact.command.patron_barcode)">[% l('Patron') %]</button>
+ <button
+ class="btn btn-info btn-xs"
+ ng-disabled="!logged_in"
+ ng-click="retrieveDetails(xact)">[% l('Debug') %]</button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </uib-tab>
+ </uib-tabset>
+ </div>
+ </uib-tab>
+ </uib-tabset>
+ </div>
+</div>
+
+</script>
+
+[% BLOCK APP_JS %]
+<!-- offline page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/offline.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-tablesort.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+ s.OFFLINE_BLOCKLIST_SUCCESS = "[% l('Offline blocklist downloaded') %]";
+ s.OFFLINE_BLOCKLIST_FAIL = "[% l('Error downloading offline blocklist') %]";
+ s.DUPLICATE_BARCODE = "[% l('Duplicate item barcode') %]";
+
+ s.ALLOW = "[% l('Allow') %]";
+ s.REJECT = "[% l('Reject') %]";
+
+ s.REG_ADDR_TYPE = "[% l('Mailing') %]";
+ s.REG_INVALID_FIELDS =
+ "[% l('Please enter valid values for all required fields.') %]"
+ s.REG_ADDR_REQUIRED =
+ "[% l('An address is required during registration.') %]"
+
+ s.PATRON_BLOCKED_WHY = {};
+ s.PATRON_BLOCKED_WHY.D = "[% l('Patron has penalties') %]";
+ s.PATRON_BLOCKED_WHY.L = "[% l('Barcode is reported Lost') %]";
+ s.PATRON_BLOCKED_WHY.E = "[% l('Patron account is Expired') %]";
+ s.PATRON_BLOCKED_WHY.B = "[% l('Patron account is Barred') %]";
+
+}]);
+</script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+<link rel="stylesheet" href="[% ctx.media_prefix %]/js/ui/default/staff/build/css/tablesort.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
--- /dev/null
+<!--
+Template for printing offline checkout receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+ includes:
+
+ * barcode
+ * backdate
+ * timestamp
+
+-->
+<div>
+ <div>[% l('You checked in the following [_1] items:', '{{transactions.length}}') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkin in transactions">
+ <div>[% l('Barcode: [_1] Checkin date: [_2]',
+ '{{checkin.barcode}}',
+ '{{checkin.backdate | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{today | date:'short'}}</div>
+<br/>
+
--- /dev/null
+<!--
+Template for printing offline checkout receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+ includes:
+
+ * barcode
+ * patron_barcode
+ * due_date
+
+-->
+<div>
+ <div>[% l('Patron [_1]', '{{transactions[0].patron_barcode}}') %]</div>
+ <div>[% l('You checked out the following [_1] items:', '{{transactions.length}}') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkout in transactions">
+ <div>[% l('Barcode: [_1] Due: [_2]',
+ '{{checkout.barcode}}',
+ '{{checkout.due_date | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{today | date:'short'}}</div>
+<br/>
+
--- /dev/null
+<!--
+Template for printing offline in-house use receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+ includes:
+
+ * barcode
+ * count
+
+-->
+<div>
+ <div>[% l('You recorded use for the following [_1] items:', '{{transactions.length}}') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkout in transactions">
+ <div>[% l('Barcode [_1] used [_2] times',
+ '{{checkout.barcode}}',
+ '{{checkout.count}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{today | date:'short'}}</div>
+<br/>
+
--- /dev/null
+<!--
+Template for printing offline renew receipts; fields available include:
+
+* transactions - list of loans made during this session. Each
+ includes:
+
+ * barcode
+ * due_date
+
+-->
+<div>
+ <div>[% l('You renewed the following [_1] items:', '{{transactions.length}}') %]</div>
+ <hr/>
+ <ol>
+ <li ng-repeat="checkout in transactions">
+ <div>[% l('Barcode: [_1] Due: [_2]',
+ '{{checkout.barcode}}',
+ '{{checkout.due_date | date:"short"}}') %]</div>
+ </li>
+ </ol>
+ <hr/>
+ <div>{{today | date:'short'}}</div>
+<br/>
+
<!-- Date Picker -->
<div class="input-group">
<input type="text"
+ id="{{id}}"
class="form-control"
ng-show="!hideDatePicker"
uib-datepicker-popup="{{date_format}}"
is-open="datePickerIsOpen"
+ datepicker-options="options"
ng-model="ngModel"
ng-change="ngChange"
ng-blur="ngBlur"
</uib-timepicker>
</span>
</div>
+
+ <div>
+ <span ng-show="outOfRange" class="label label-danger">[% l('Input is out of range.') %]</span>
+ </div>
</div>
</div>
</div>
+ <div class="form-group">
+ <div class="col-md-offset-4 col-md-6">
+ <span ng-show="pendingXacts" class="label label-warning">[% l('Unprocessed offline transactions waiting for upload') %]</span>
+ </div>
+ </div>
+
</form>
</fieldset>
</div>
--- /dev/null
+The MIT License (MIT)
+
+Copyright (c) 2016 Tal Ater
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
'node_modules/iframe-resizer/js/iframeResizer.map',
'node_modules/iframe-resizer/js/iframeResizer.contentWindow.min.js',
'node_modules/angular-order-object-by/src/ng-order-object-by.js',
+ 'node_modules/angular-tablesort/js/angular-tablesort.js',
'node_modules/lovefield/dist/lovefield.min.js',
'node_modules/lovefield/dist/lovefield.min.js.map',
'node_modules/moment/min/moment-with-locales.min.js',
'node_modules/ngtoast/dist/ngToast-animations.min.css',
'node_modules/angular-tree-control/css/tree-control.css',
'node_modules/angular-tree-control/css/tree-control-attribute.css',
+ 'node_modules/angular-tablesort/tablesort.css'
]
}]
},
'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.eot',
'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.svg',
'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf',
- 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff'
+ 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff',
+ 'node_modules/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2'
]
}]
},
'build/css/ngToast.min.css',
'build/css/ngToast-animations.min.css',
'build/css/tree-control.css',
- 'build/css/tree-control-attribute.css',
+ 'build/css/tree-control-attribute.css'
]
}
}
rename: function (dst, src) {
return src.replace('.js', '.min.js');
}
- }],
+ }]
},
build: {
src: [
'services/ui.js',
'services/date.js',
'services/op_change.js',
+ 'services/file.js'
],
dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js'
},
// to more easily detect if concat order is incorrect
concat: {
options: {
- separator: ';',
+ separator: ';'
}
},
// 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',
+ 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',
+ command : 'rm test/data/IDL2js.js'
}
},
// unit tests configuration
karma : {
unit: {
- configFile: 'test/karma.conf.js',
+ configFile: 'test/karma.conf.js'
//background: true // for now, visually babysit unit tests
}
}
/* 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','$location','$window','egCore','egLovefield',
+ function($scope , $location , $window , egCore , egLovefield) {
+ egLovefield.havePendingOfflineXacts() .then(function(eh){
+ $scope.pendingXacts = eh;
+ });
+
$scope.focusMe = true;
$scope.args = {};
$scope.workstations = [];
angular.module('egCoreMod')
// toss tihs onto egCoreMod since the page app may vary
-.factory('patronRegSvc', ['$q', 'egCore', function($q, egCore) {
+.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) {
var service = {
field_doc : {}, // config.idl_field_doc
});
});
});
+
+ egLovefield.setListInOfflineCache('asv', service.surveys)
+ egLovefield.setListInOfflineCache('asvq', service.survey_questions)
+ egLovefield.setListInOfflineCache('asva', service.survey_answers)
+
});
}
);
});
service.stat_cats = cats;
+ return egLovefield.setStatCatsCache(cats);
});
};
// some org settings require the retrieval of additional data
service.process_org_settings = function(settings) {
- var promises = [];
+ var promises = [egLovefield.setSettingsCache(settings)];
if (settings['sms.enable']) {
// fetch SMS carriers
}
service.get_field_doc = function() {
+ var to_cache = [];
return egCore.pcrud.search('fdoc', {
fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
- .then(null, null, function(doc) {
- if (!service.field_doc[doc.fm_class()]) {
- service.field_doc[doc.fm_class()] = {};
+ .then(
+ function () {
+ return egLovefield.setListInOfflineCache('fdoc', to_cache)
+ },
+ null,
+ function(doc) {
+ if (!service.field_doc[doc.fm_class()]) {
+ service.field_doc[doc.fm_class()] = {};
+ }
+ service.field_doc[doc.fm_class()][doc.field()] = doc;
+ to_cache.push(doc);
}
- service.field_doc[doc.fm_class()][doc.field()] = doc;
- });
+ );
+
};
service.get_user_settings = function() {
]
}, {}, {atomic : true}).then(function(setting_types) {
+ egCore.env.absorbList(setting_types, 'cust'); // why not...
+
angular.forEach(setting_types, function(stype) {
service.user_setting_types[stype.name()] = stype;
if (static_types.indexOf(stype.name()) == -1) {
--- /dev/null
+/**
+ * App to drive the offline UI
+ */
+
+lf.isOffline = true;
+
+angular.module('egOffline', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'ngToast', 'tableSort'])
+
+.config(
+ ['$routeProvider','$locationProvider','$compileProvider',
+function($routeProvider , $locationProvider , $compileProvider) {
+
+ $locationProvider.html5Mode(true);
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/);
+
+ /**
+ * 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('/offline-interface/:tab', {
+ templateUrl: 'offline-template',
+ controller: 'OfflineCtrl',
+ resolve : resolver
+ });
+
+ // default page
+ $routeProvider.otherwise({
+ templateUrl : 'offline-template',
+ controller : 'OfflineCtrl',
+ resolve : resolver
+ });
+}])
+
+.controller('OfflineSessionCtrl',
+ ['$scope','$window','egCore','$routeParams','$http','$q','$timeout','egPromptDialog','ngToast','egProgressDialog',
+ function($scope , $window , egCore , $routeParams , $http , $q , $timeout , egPromptDialog , ngToast , egProgressDialog) {
+ $scope.active_session_tab = 'pending';
+
+ $scope.lookupNoncatTypeName = function (type) {
+ var nc = $scope.noncats.filter(function(n){ return n.id() == type })[0];
+ if (nc) return nc.name();
+ return '';
+ }
+
+ $scope.createDate = function (ts, epoch) {
+ if (!ts) return '';
+ if (epoch) ts = ts * 1000;
+ return new Date(ts);
+ }
+
+ $scope.setSession = function (s, ind) {
+ $scope.current_session = s;
+ $scope.current_session_index = ind;
+
+ return $scope.refreshExceptions(s);
+ }
+
+ $scope.createSession = function () {
+
+ return egPromptDialog.open(
+ egCore.strings.OFFLINE_SESSION_DESC, '',
+ {ok : function(value) {
+ if (value) {
+
+ return $http.get(formURL({action:'create',desc:value})).then(function(res) {
+ if (res.data.ilsevent == "0") return $q.when(res.data.payload);
+ return $q.reject();
+ }).then(function (seskey) {
+ return $scope.refreshSessions().then(function() {
+ if (seskey) {
+ var s = $scope.sessions.filter(function(s){ s.key == seskey })[0];
+ var ind = $scope.sessions.length - 1; // sorted by create time, so new one is last
+ return $scope.setSession(s, ind);
+ }
+ });
+ }, function() {
+ ngToast.warning(egCore.strings.OFFLINE_SESSION_CREATE_FAILED);
+ });
+ }
+ }}
+ );
+ }
+
+ $scope.processSession = function (s, ind) {
+ return $scope.setSession(s, ind).then(function() {
+ egProgressDialog.open();
+
+ return $http.get(
+ formURL({action:'execute',seskey:$scope.current_session.key})
+ ).then(function(res) {
+ if (res.data.ilsevent == "0") return $q.when(res.data.payload);
+ return $q.reject();
+ }).then(function () {
+ egProgressDialog.close();
+ return $scope.refreshSessions()
+ .then(function(){ return $scope.refreshExceptions(s) });
+ },function () {
+ egProgressDialog.close();
+ return $scope.refreshSessions().then(function() {
+ ngToast.warning(egCore.strings.OFFLINE_SESSION_PROCESSING_FAILED);
+ });
+ });
+ });
+ }
+
+ $scope.refreshExceptions = function (s) {
+ return $http.get(
+ formURL({
+ action : 'status',
+ status_type : 'exceptions',
+ seskey : s.key
+ })
+ ).then(function(res) {
+ if (res.data.ilsevent) {
+ $scope.current_session.exceptions = [];
+ } else {
+ $scope.current_session.exceptions = res.data;
+ }
+ return $q.when();
+ });
+ }
+
+ $scope.refreshSessions = function () {
+
+ return $http.get(formURL({action:'status',status_type:'sessions'})).then(function(res) {
+ if (res.data) {
+ $scope.sessions = res.data;
+ return $q.when();
+ }
+ return $q.reject();
+ }).then(function() {
+ var creator_list = [$q.when()];
+ angular.forEach($scope.sessions, function (s) {
+ s.total = 0;
+ s.org = egCore.org.get(s.org).shortname();
+ creator_list.push(egCore.pcrud.retrieve('au',s.creator).then(function(u) {
+ s.creator = u.family_name();
+ }));
+ angular.forEach(s.scripts, function(sc) {
+ s.total += sc.count;
+ });
+ });
+
+ return $q.all(creator_list);
+ });
+ }
+
+ $scope.reprintLast = function () {
+ egCore.print.reprintLast();
+ }
+
+
+ $scope.uploadPending = function (s, ind) {
+ return $scope.setSession(s, ind).then(function() {
+
+ egProgressDialog.open();
+ return $scope.createOfflineXactBlob().then(function(blob) {
+
+ var form = new FormData();
+ form.append("ses", egCore.auth.token());
+ form.append("org", $scope.org.id());
+ form.append("ws", $scope.current_workstation_name());
+ form.append("wc", 1);
+ form.append("action", "load");
+ form.append("seskey", $scope.current_session.key);
+ form.append("file", blob, "file");
+
+ return $http.post(
+ '/cgi-bin/offline/offline.pl?' + new Date().getTime(),
+ form,
+ {
+ transformRequest: angular.identity,
+ headers: {'Content-Type': undefined}
+ }
+ ).then(function(res) {
+ egProgressDialog.close();
+ if (res.data.ilsevent == "0") {
+ return $scope.clear_pending(true).then(function() {
+ return $scope.refreshSessions();
+ });
+ } else {
+ ngToast.warning(egCore.strings.OFFLINE_SESSION_UPLOAD_FAILED);
+ return $scope.refreshSessions();
+ }
+ },function () { egProgressDialog.close() });
+ });
+ });
+ }
+
+ $scope.retrieveDetails = function (x) {
+ alert(JSON.stringify(x, null, 2)); // egAlertDialog kills pretty printing
+ }
+
+ $scope.retrieveItem = function (bc) {
+ return egCore.pcrud.search('acp',{deleted: 'f', barcode: bc}).then(function(copy) {
+ if (copy) {
+ return $window.open(
+ egCore.env.basePath +
+ '/cat/item/' + copy.id(),
+ '_blank'
+ ).focus();
+ }
+
+ ngToast.warning(egCore.strings.ITEM_NOT_FOUND);
+ });
+ }
+
+ $scope.retrievePatron = function (bc) {
+ return egCore.pcrud.search('ac',{barcode: bc}).then(function(card) {
+ if (card) {
+ return $window.open(
+ egCore.env.basePath +
+ '/circ/patron/' + card.usr() + '/checkout',
+ '_blank'
+ ).focus();
+ }
+
+ ngToast.warning(egCore.strings.PATRON_NOT_FOUND);
+ });
+ }
+
+ function formURL (params) {
+ var url = '/cgi-bin/offline/offline.pl?' + new Date().getTime();
+
+ var defaults = {
+ org : $scope.org ? $scope.org.id() : null,
+ ws : $scope.current_workstation_name(),
+ wc : 1,
+ ses : egCore.auth.token()
+ }
+
+ angular.extend(params, defaults)
+
+ var first = true;
+ for (var k in params) {
+ url += '&' + k + '=' + window.encodeURIComponent(params[k]);
+ }
+ return url;
+ }
+
+ $scope.$watch('org',function(n){if (n) $scope.refreshSessions()});
+
+ }
+])
+
+.controller('OfflineCtrl',
+ ['$q','$scope','$window','$location','$rootScope','egCore','egLovefield','$routeParams','$timeout','$http','ngToast','egConfirmDialog','egUnloadPrompt',
+ function($q , $scope , $window , $location , $rootScope , egCore , egLovefield , $routeParams , $timeout , $http , ngToast , egConfirmDialog , egUnloadPrompt) {
+ $scope.active_tab = $routeParams.tab || 'checkout';
+
+ // Immediately redirect if we're really offline
+ if (!$window.navigator.onLine) {
+ if ($location.path().match(/session$/)) {
+ var path = $location.path();
+ return $location.path(path.replace('session','checkout'));
+ }
+ }
+
+ var today = new Date();
+ today.setHours(0);
+ today.setMinutes(0);
+ today.setSeconds(0);
+ today.setMilliseconds(0);
+
+ $scope.minDate = today;
+ $scope.blocked_patron = null;
+ $scope.bad_barcode = null;
+ $scope.barcode_type = 'barcode';
+ $scope.focusMe = true;
+ $scope.shared = { outOfRange : false, due_date : null, due_date_offset : '' };
+ $scope.workstation_obj = null;
+ $scope.workstation = '';
+ $scope.workstation_owner = '';
+ $scope.workstations = [];
+ $scope.org = null;
+ $scope.do_print = Boolean($scope.active_tab == 'checkout');
+ $scope.do_print_changed = false;
+ $scope.printed = false;
+
+ $scope.imported_pending_xacts = { data : '' };
+
+ $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
+ $scope.all_xact = [];
+ $scope.noncats = [];
+
+ $scope.checkout = { noncat_type : '' };
+ $scope.renew = { noncat_type : '' };
+ $scope.in_house_use = {count : 1};
+ $scope.checkin = { backdate : new Date() };
+
+ $scope.current_workstation_owning_lib = function () {
+ return $scope.workstations.filter(function(w) {
+ return $scope.workstation == w.id
+ })[0].owning_lib;
+ }
+
+ $scope.current_workstation_name = function () {
+ return $scope.workstations.filter(function(w) {
+ return $scope.workstation == w.id
+ })[0].name;
+ }
+
+ $scope.$watch('workstation', function (n,o) {
+ if (egCore.env.aou)
+ $scope.org = egCore.org.get($scope.current_workstation_owning_lib());
+ });
+
+ $scope.changeCheck = function () {
+ $scope.strict_barcode = !$scope.strict_barcode;
+ $scope.do_check_changed = true;
+ egCore.hatch.setItem('eg.offline.strict_barcode', $scope.strict_barcode)
+ }
+
+ $scope.changePrint = function () {
+ $scope.do_print = !$scope.do_print;
+ $scope.do_print_changed = true;
+ egCore.hatch.setItem('eg.offline.print_receipt', $scope.do_print)
+ }
+
+ $scope.logged_in = egCore.auth.token() ? true : false;
+
+ if (!$scope.logged_in && $routeParams.tab == 'session')
+ $scope.active_tab = 'checkout';
+
+ egCore.hatch.getItem('eg.offline.print_receipt')
+ .then(function(setting) {
+ $scope.do_print = setting;
+ if (setting !== undefined) $scope.do_print_changed = true;
+ });
+
+ egCore.hatch.getItem('eg.offline.strict_barcode')
+ .then(function(setting) {
+ $scope.strict_barcode = setting;
+ if (setting !== undefined) $scope.do_check_changed = true;
+ });
+
+ egCore.hatch.getItem('eg.workstation.all')
+ .then(function(all) {
+ if (all && all.length) {
+ $scope.workstations = all;
+
+ 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.workstation = match.id;
+ } 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) {
+ var ws_obj = all.filter(function(w) {
+ return ws == w.name
+ })[0];
+
+ $scope.workstation_obj = ws_obj;
+ $scope.workstation = ws_obj.id;
+ $scope.workstation_owner = ws_obj.owning_lib;
+
+ return egLovefield.reconstituteList('cnct').then(function () {
+ $scope.noncats = egCore.env.cnct.list;
+ });
+ });
+ }
+ }
+ });
+
+ $scope.downloadBlockList = function () {
+ var url = '/standalone/list.txt?ses='
+ + egCore.auth.token()
+ + '&' + new Date().getTime();
+ return $http.get(url).then(
+ function (res) {
+ if (res.data) {
+ var lines = res.data.split('\n');
+ egLovefield.destroyOfflineBlocks().then(function(){
+ angular.forEach(lines, function (l) {
+ var parts = l.split(' ');
+ egLovefield.addOfflineBlock(parts[0], parts[1]);
+ });
+ return $q.when();
+ }).then(function(){
+ ngToast.create(egCore.strings.OFFLINE_BLOCKLIST_SUCCESS);
+ });
+ }
+ },function(){
+ ngToast.warning(egCore.strings.OFFLINE_BLOCKLIST_FAIL);
+ egCore.audio.play('warning.offline.blocklist_fail');
+ }
+ );
+ }
+
+ $scope.createOfflineXactBlob = function () {
+ return egLovefield.retrievePendingOfflineXacts().then(function(list) {
+ var flat_list = [];
+ angular.forEach(list, function (i) {
+ flat_list.push(JSON.stringify(i) + '\n');
+ });
+
+ var blob = new Blob(flat_list, {type: 'text/plain'});
+
+ return $q.when(blob)
+ });
+ }
+
+ $scope.pending_xacts = [];
+ $scope.retrieve_pending = function () {
+ return egLovefield.retrievePendingOfflineXacts().then(function(list) {
+ $scope.pending_xacts = list;
+ return $q.when(list);
+ });
+ }
+
+ $scope.save = function () {
+ var promises = [$q.when()];
+ angular.forEach($scope.all_xact, function (x) {
+ promises.push(egLovefield.addOfflineXact(x));
+ });
+
+ var prints = [$q.when()];
+ if ($scope.do_print) {
+ angular.forEach(['checkin','checkout','renew','in_house_use'], function(xtype) {
+ if ($scope.xact_page[xtype].length > 0) {
+ prints.push(egCore.print.print({
+ context : 'offline',
+ template : 'offline_'+xtype,
+ scope : {
+ transactions : $scope.xact_page[xtype]
+ }
+ }));
+ }
+ });
+ }
+
+ return $q.all(promises.concat(prints)).finally(function() {
+ egUnloadPrompt.clear();
+ if (prints.length > 1) $scope.printed = true;
+ $scope.all_xact = [];
+ $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
+ angular.forEach(['checkout','renew'], function (xtype) {
+ $scope[xtype].patron_barcode = '';
+ });
+ $scope.retrieve_pending();
+ });
+ }
+
+ $rootScope.save_offline_xacts = function () { return $scope.save() };
+ $rootScope.active_tab = function (t) { $scope.active_tab = t };
+
+ $scope.logout = function () {
+ egCore.auth.logout();
+ $window.location.href = location.href;
+ }
+
+ $scope.clear_pending = function (skip_confirm) {
+ if (skip_confirm) {
+ return egLovefield.destroyPendingOfflineXacts().then(function () {
+ return $scope.retrieve_pending();
+ });
+ }
+ return egConfirmDialog.open(
+ egCore.strings.CONFIRM_CLEAR_PENDING,
+ egCore.strings.CONFIRM_CLEAR_PENDING_BODY,
+ {}
+ ).result.then(function() {
+ return egLovefield.destroyPendingOfflineXacts().then(function () {
+ return $scope.retrieve_pending();
+ });
+ });
+
+ }
+
+ $scope.retrieve_pending();
+ $scope.$watch('active_tab', function (n,o) {
+ if (n != o && !$scope.do_check_changed && n != 'checkout') $scope.strict_barcode = false;
+ if (n != o && !$scope.do_check_changed && n == 'checkout') $scope.strict_barcode = true;
+ if (n != o && !$scope.do_print_changed && n != 'checkout') $scope.do_print = false;
+ if (n != o && !$scope.do_print_changed && n == 'checkout') $scope.do_print = true;
+ if (n != o && n == 'session') $scope.retrieve_pending();
+ });
+
+ $scope.$watch('imported_pending_xacts.data', function (n, o) {
+ if (n != 0) {
+ var lines = n.split('\n');
+ var promises = [];
+
+ angular.forEach(lines, function (l) {
+ if (!l) return;
+
+ try {
+ promises.push(
+ egLovefield.addOfflineXact(JSON.parse(l))
+ );
+ } catch (err) {
+ ngToast.warning(err);
+ }
+ });
+
+ $q.all(promises).then(function () { $scope.retrieve_pending() });
+ }
+ });
+
+ $scope.resetDueDate = function (xtype) {
+ $scope.shared.due_date = new Date();
+ $scope.shared.due_date.setDate($scope.shared.due_date.getDate() + parseInt($scope.shared.due_date_offset));
+ }
+
+ $scope.notEnough = function (xtype) {
+
+ if (xtype == 'checkout') {
+ if ($scope.shared.outOfRange) return true;
+ if (
+ $scope.checkout.patron_barcode &&
+ ($scope.shared.due_date || $scope.shared.due_date_offset) &&
+ ($scope.checkout.barcode || ($scope.checkout.noncat_type && $scope.checkout.noncat_count))
+ ) return false;
+ return true;
+ }
+
+ if (xtype == 'renew') {
+ if ($scope.shared.outOfRange) return true;
+ if (
+ $scope.renew.barcode &&
+ ($scope.shared.due_date || $scope.shared.due_date_offset)
+ ) return false;
+ return true;
+ }
+
+ if (xtype == 'in_house_use') {
+ if (
+ $scope.in_house_use.barcode && $scope.in_house_use.count
+ ) return false;
+ return true;
+ }
+
+ if (xtype == 'checkin') {
+ if (
+ $scope.checkin.barcode && $scope.checkin.backdate
+ ) return false;
+ return true;
+ }
+ }
+
+ $scope.clear = function (xtype) {
+ $scope[xtype] = {};
+ if (xtype=="in_house_use") $scope[xtype].count = 1;
+ }
+
+ $scope.add = function (xtype,next_focus) {
+
+ var barcode = $scope[xtype].barcode;
+ if (barcode) {
+ if ($scope.xact_page[xtype].filter(function(x){ return x.barcode == barcode }).length > 0) {
+ ngToast.warning(egCore.strings.DUPLICATE_BARCODE);
+ egCore.audio.play('warning.offline.duplicate_barcode');
+ $scope[xtype].barcode = '';
+ if (next_focus) $('#'+next_focus).focus();
+ return;
+ }
+ }
+
+ var pbarcode = $scope[xtype].patron_barcode;
+ if (pbarcode) {
+ egLovefield.testOfflineBlock(pbarcode).then(function (blocked) {
+ if (blocked) {
+ egCore.audio.play('warning.offline.blocked_patron');
+ egConfirmDialog.open(
+ egCore.strings.PATRON_BLOCKED,
+ egCore.strings.PATRON_BLOCKED_WHY[blocked],
+ {}, egCore.strings.ALLOW, egCore.strings.REJECT
+ ).result.then(
+ function(){ // forced
+ $scope.blocked_patron = null;
+ _add_impl(xtype,true)
+ if (next_focus) $('#'+next_focus).focus();
+ },function(){ // stopped
+ $scope.blocked_patron = xtype;
+ if (next_focus) $('#'+next_focus).focus();
+ return;
+ }
+ );
+ } else {
+ $scope.blocked_patron = null;
+ _add_impl(xtype,true)
+ if (next_focus) $('#'+next_focus).focus();
+ }
+ });
+ } else {
+ _add_impl(xtype);
+ if (next_focus) $('#'+next_focus).focus();
+ }
+ }
+
+ function _add_impl (xtype,digest) {
+ var pbarcode = $scope[xtype].patron_barcode;
+ var backdate = $scope[xtype].backdate;
+
+ if ($scope.strict_barcode && pbarcode) {
+ if (!check_barcode(pbarcode)) {
+ $scope.bad_barcode = xtype;
+ egCore.audio.play('warning.offline.bad_barcode');
+ return egConfirmDialog.open(
+ egCore.strings.BAD_PATRON_BARCODE,
+ egCore.strings.BAD_PATRON_BARCODE_CD,
+ {}, egCore.strings.ALLOW, egCore.strings.REJECT
+ ).result.then(
+ function(){ // forced
+ $scope.blocked_patron = null;
+ return _add_impl2(xtype,digest)
+ },function(){ // stopped
+ $scope.blocked_patron = xtype;
+ }
+ );
+ }
+ }
+
+ if ($scope.strict_barcode && $scope[xtype].barcode) {
+ if (!check_barcode($scope[xtype].barcode)) {
+ $scope.bad_barcode = xtype;
+ egCore.audio.play('warning.offline.bad_barcode');
+ return egConfirmDialog.open(
+ egCore.strings.BAD_BARCODE,
+ egCore.strings.BAD_BARCODE_CD,
+ {}, egCore.strings.ALLOW, egCore.strings.REJECT
+ ).result.then(
+ function(){ // forced
+ $scope.blocked_patron = null;
+ return _add_impl2(xtype,digest)
+ },function(){ // stopped
+ $scope.blocked_patron = xtype;
+ }
+ );
+ }
+ }
+
+ return _add_impl2(xtype,digest);
+ }
+
+ function _add_impl2 (xtype,digest) {
+ var pbarcode = $scope[xtype].patron_barcode;
+ var backdate = $scope[xtype].backdate;
+
+ $scope.bad_barcode = null;
+
+ var now = new Date().getTime();
+ now = now / 1000;
+
+ if ($scope[xtype].noncat_type) $scope[xtype].noncat = 1;
+
+ if ($scope.shared.due_date && (xtype == 'checkout' || xtype == 'renew')) {
+ $scope[xtype].due_date = $scope.shared.due_date.toISOString();
+ $scope[xtype].checkout_time = new Date().toISOString();
+ }
+
+ var xact = { timestamp : parseInt(now), type : xtype, delta : 0 };
+
+ $scope.xact_page[xtype].push(
+ angular.extend(xact, $scope[xtype])
+ );
+
+ $scope.all_xact.push(xact)
+ egUnloadPrompt.attach($rootScope);
+
+ $scope[xtype] = {};
+
+ if (pbarcode) $scope[xtype].patron_barcode = pbarcode;
+ if (backdate) $scope[xtype].backdate = backdate;
+ if (xtype=="in_house_use") $scope[xtype].count = 1;
+
+ if (digest) $timeout(function(){$scope.$apply()});
+ }
+
+ 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 barcode_checkdigit(stripped_barcode).toString() == last_digit;
+ }
+
+ 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;
+ }
+
+ }
+])
+
+// dummy service so standalone patron editor can reference it
+.factory('patronSvc', function() { return { /* dummy */ } })
+
+.factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) {
+
+ egLovefield.isOffline = true;
+
+ var service = {
+ org : null, // will come from workstation org
+ field_doc : {}, // config.idl_field_doc
+ profiles : [], // permission groups
+ edit_profiles : [], // perm groups we can modify
+ sms_carriers : [],
+ user_settings : {}, // applied user settings
+ user_setting_types : {}, // config.usr_setting_type
+ opt_in_setting_types : {}, // config.usr_setting_type for event-def opt-in
+ surveys : [],
+ survey_questions : {},
+ survey_answers : {},
+ survey_responses : {}, // survey.responses for loaded patron in progress
+ stat_cats : [],
+ stat_cat_entry_maps : {}, // cat.id to selected value
+ virt_id : -1, // virtual ID for new objects
+ init_done : false // have we loaded our initialization data?
+ };
+
+ service.offlineMode = function () {
+ return lf.isOffline;
+ }
+
+ // launch a series of parallel data retrieval calls
+ service.init = function(scope) {
+
+ // Data loaded here only needs to be retrieved the first time this
+ // tab becomes active within the current instance of the patron app.
+ // In other words, navigating between patron tabs will not cause
+ // all of this data to be reloaded. Navigating to a separate app
+ // and returning will cause the data to be reloaded.
+ if (service.init_done) return $q.when();
+ service.init_done = true;
+
+ return $q.all([
+ service.get_field_doc(),
+ service.get_perm_groups(),
+ service.get_ident_types(),
+ service.get_user_settings(),
+ service.get_org_settings(),
+ service.get_stat_cats(),
+ service.get_surveys(),
+ service.get_net_access_levels()
+ ]);
+ };
+
+ service.get_linked_addr_users = function(addrs) {
+ return $q.when();
+ }
+
+ service.apply_secondary_groups = function(user_id, group_ids) {
+ return $q.when(true);
+ }
+
+ // See note above about not loading egUser.
+ // TODO: i18n
+ service.format_name = function(last, first, middle) {
+ return last + ', ' + first + (middle ? ' ' + middle : '');
+ }
+
+ service.check_dupe_username = function(usrname) {
+ return $q.when(false);
+ }
+
+ // determine which user groups our user is not allowed to modify
+ service.set_edit_profiles = function() {
+ service.edit_profiles = egCore.env.pgt.list.filter(
+ function (p) { return p.application_perm() == 'group_application.user.patron' }
+ );
+ return $q.when;
+ }
+
+ // resolves to a hash of perm-name => boolean value indicating
+ // wether the user has the permission at org_id.
+ service.has_perms_for_org = function(org_id) {
+
+ var perms_needed = [
+ 'UPDATE_USER',
+ 'CREATE_USER',
+ 'CREATE_USER_GROUP_LINK',
+ 'UPDATE_PATRON_COLLECTIONS_EXEMPT',
+ 'UPDATE_PATRON_CLAIM_RETURN_COUNT',
+ 'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
+ 'UPDATE_PATRON_ACTIVE_CARD',
+ 'UPDATE_PATRON_PRIMARY_CARD'
+ ];
+
+ var hash = {};
+ angular.forEach(perms_needed, function (p) {
+ hash[p] = true;
+ });
+
+ return $q.when(hash);
+ }
+
+ service.get_surveys = function() {
+ return egLovefield.reconstituteList('asv').then(function(offline) {
+ return egLovefield.reconstituteList('asvq')
+ .then(function(){
+ return egLovefield.reconstituteList('asva');
+ }).then(function() {
+ angular.forEach(egCore.env.asv.list, function (s) {
+ s.questions( egCore.env.asva.list.filter( function (a) {
+ return q.survey().id == s.id();
+ }));
+ });
+
+ angular.forEach(egCore.env.asvq.list, function (q) {
+ q.survey( egCore.env.asv.map[ q.survey().id ] );
+ q.answers( egCore.env.asva.list.filter( function (a) {
+ return q.id() == a.question();
+ }));
+ });
+
+ angular.forEach(egCore.env.asva.list, function (a) {
+ a.question( egCore.env.asvq.map[ a.question().id ] );
+ });
+
+ service.surveys = egCore.env.asv.list;
+ service.survey_questions = egCore.env.asvq.list;
+ service.survey_answers = egCore.env.asva.list;
+
+ return $q.when();
+ });
+ });
+ }
+
+ service.get_stat_cats = function() {
+ return egLovefield.getStatCatsCache().then(
+ function(cats) {
+ service.stat_cats = cats;
+ return $q.when();
+ }
+ );
+ };
+
+ service.get_org_settings = function() {
+ return egLovefield.getSettingsCache().then(
+ function (list) {
+ var hash = {};
+ angular.forEach(list, function (s) {
+ hash[s.name] = s.value;
+ });
+ service.org_settings = hash;
+ if (egCore && egCore.env && !egCore.env.aous) {
+ egCore.env.aous = hash;
+ console.log('setting egCore.env.aous');
+ }
+ return $q.when();
+ }
+ );
+ };
+
+ service.get_ident_types = function() {
+ return egLovefield.reconstituteList('cit').then(function() {
+ service.ident_types = egCore.env.cit.list;
+ return $q.when();
+ });
+ };
+
+ service.get_net_access_levels = function() {
+ return egLovefield.reconstituteList('cnal').then(function() {
+ service.net_access_levels = egCore.env.cnal.list;
+ return $q.when();
+ });
+ }
+
+ service.get_perm_groups = function() {
+ if (egCore.env.pgt) {
+ service.profiles = egCore.env.pgt.list;
+ return service.set_edit_profiles();
+ } else {
+ return egLovefield.reconstituteTree('pgt').then(function(offline) {
+ service.profiles = egCore.env.pgt.list;
+ return service.set_edit_profiles();
+ });
+ }
+ }
+
+ service.get_field_doc = function() {
+ return egLovefield.getListFromOfflineCache('fdoc').then(function (list) {
+ angular.forEach(list, function(doc) {
+ service.field_doc[doc.fm_class()][doc.field()] = doc;
+ });
+ return $q.when();
+ });
+ };
+
+ service.get_user_settings = function() {
+ var static_types = [
+ 'circ.holds_behind_desk',
+ 'circ.collections.exempt',
+ 'opac.hold_notify',
+ 'opac.default_phone',
+ 'opac.default_pickup_location',
+ 'opac.default_sms_carrier',
+ 'opac.default_sms_notify'];
+
+ angular.forEach(static_types, function (t) {
+ service.user_settings[t] = null;
+ });
+
+ return egLovefield.getListFromOfflineCache('cust').then(function (list) {
+ angular.forEach(list, function(stype) {
+ service.user_setting_types[stype.name()] = stype;
+ if (static_types.indexOf(stype.name()) == -1) {
+ service.opt_in_setting_types[stype.name()] = stype;
+ }
+ if (stype.reg_default() != undefined) {
+ service.user_settings[setting.name()] =
+ setting.reg_default();
+ }
+ });
+ return $q.when();
+ });
+ }
+
+ service.invalidate_field = function(patron, field) {
+ return;
+ }
+
+ service.dupe_patron_search = function(patron, type, value) {
+ return $q.when({ search : search, count : 0 });
+ }
+
+ service.init_patron = function(current) {
+
+ if (!current)
+ return service.init_new_patron();
+
+ service.patron = current;
+ return service.init_existing_patron(current)
+ }
+
+ service.ingest_address = function(patron, addr) {
+ addr.valid = addr.valid == 't';
+ addr.within_city_limits = addr.within_city_limits == 't';
+ addr._is_mailing = (patron.mailing_address &&
+ addr.id == patron.mailing_address.id);
+ addr._is_billing = (patron.billing_address &&
+ addr.id == patron.billing_address.id);
+ }
+
+ /*
+ * Existing patron objects reqire some data munging before insertion
+ * into the scope.
+ *
+ * 1. Turn everything into a hash
+ * 2. ... Except certain fields (selectors) whose widgets require objects
+ * 3. Bools must be Boolean, not t/f.
+ */
+ service.init_existing_patron = function(current) {
+
+ service.existing_patron = current;
+
+ var patron = egCore.idl.toHash(current);
+
+ patron.home_ou = egCore.org.get(patron.home_ou.id);
+ patron.expire_date = new Date(Date.parse(patron.expire_date));
+ patron.dob = service.parse_dob(patron.dob);
+ patron.profile = current.profile(); // pre-hash version
+ patron.net_access_level = current.net_access_level();
+ patron.ident_type = current.ident_type();
+ patron.groups = current.groups(); // pre-hash
+
+ angular.forEach(
+ ['juvenile', 'barred', 'active', 'master_account'],
+ function(field) { patron[field] = patron[field] == 't'; }
+ );
+
+ angular.forEach(patron.cards, function(card) {
+ card.active = card.active == 't';
+ if (card.id == patron.card.id) {
+ patron.card = card;
+ card._primary = 'on';
+ }
+ });
+
+ angular.forEach(patron.addresses,
+ function(addr) { service.ingest_address(patron, addr) });
+
+ service.get_linked_addr_users(patron.addresses);
+
+ // Remove stat cat entries that link to out-of-scope stat
+ // cats. With this, we avoid unnecessarily updating (or worse,
+ // modifying) stat cat values that are not ours to modify.
+ patron.stat_cat_entries = patron.stat_cat_entries.filter(
+ function(map) {
+ return Boolean(
+ // service.stat_cats only contains in-scope stat cats.
+ service.stat_cats.filter(function(cat) {
+ return (cat.id() == map.stat_cat.id) })[0]
+ );
+ }
+ );
+
+ // toss entries for existing stat cat maps into our living
+ // stat cat entry map, which is modified within the template.
+ angular.forEach(patron.stat_cat_entries, function(map) {
+ service.stat_cat_entry_maps[map.stat_cat.id] = map.stat_cat_entry;
+ });
+
+ return patron;
+ }
+
+ service.init_new_patron = function() {
+ var addr = {
+ id : service.virt_id--,
+ isnew : true,
+ valid : true,
+ address_type : egCore.strings.REG_ADDR_TYPE,
+ _is_mailing : true,
+ _is_billing : true,
+ within_city_limits : false,
+ country : service.org_settings['ui.patron.default_country'],
+ };
+
+ var card = {
+ id : service.virt_id--,
+ isnew : true,
+ active : true,
+ _primary : 'on'
+ };
+
+ var home_ou = egCore.org.get(service.org);
+
+ var user = {
+ isnew : true,
+ active : true,
+ card : card,
+ cards : [card],
+ home_ou : home_ou,
+ stat_cat_entries : [],
+ groups : [],
+ addresses : [addr]
+ };
+
+ if (service.clone_user)
+ service.copy_clone_data(user);
+
+ if (service.stage_user)
+ service.copy_stage_data(user);
+
+ return user;
+ }
+
+ // dob is always YYYY-MM-DD
+ // Dates of birth do not contain timezone info, which can lead to
+ // inconcistent timezone handling, potentially representing
+ // different points in time, depending on the implementation.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
+ // See "Differences in assumed time zone"
+ // TODO: move this into egDate ?
+ service.parse_dob = function(dob) {
+ if (!dob) return null;
+ var parts = dob.split('-');
+ var d = new Date(); // always local time zone, yay.
+ d.setFullYear(parts[0]);
+ d.setMonth(parts[1] - 1);
+ d.setDate(parts[2]);
+ return d;
+ }
+
+ service.copy_stage_data = function(user) {
+ var cuser = service.stage_user;
+
+ // copy the data into our new user object
+
+ for (var key in egCore.idl.classes.stgu.field_map) {
+ if (egCore.idl.classes.au.field_map[key] &&
+ !egCore.idl.classes.stgu.field_map[key].virtual) {
+ if (cuser.user[key]() !== null)
+ user[key] = cuser.user[key]();
+ }
+ }
+
+ if (user.home_ou) user.home_ou = egCore.org.get(user.home_ou);
+ if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
+ if (user.ident_type)
+ user.ident_type = egCore.env.cit.map[user.ident_type];
+ user.dob = service.parse_dob(user.dob);
+
+ // Clear the usrname if it looks like a UUID
+ if (user.usrname.replace(/-/g,'').match(/[0-9a-f]{32}/))
+ user.usrname = '';
+
+ // Don't use stub address if we have one from the staged user.
+ if (cuser.mailing_addresses.length || cuser.billing_addresses.length)
+ user.addresses = [];
+
+ // is_mailing=false implies is_billing
+ function addr_from_stage(stage_addr) {
+ if (!stage_addr) return;
+ var cls = stage_addr.classname;
+
+ var addr = {
+ id : service.virt_id--,
+ usr : user.id,
+ isnew : true,
+ valid : true,
+ _is_mailing : cls == 'stgma',
+ _is_billing : cls == 'stgba'
+ };
+
+ user.mailing_address = addr;
+ user.addresses.push(addr);
+
+ for (var key in egCore.idl.classes[cls].field_map) {
+ if (egCore.idl.classes.aua.field_map[key] &&
+ !egCore.idl.classes[cls].field_map[key].virtual) {
+ if (stage_addr[key]() !== null)
+ addr[key] = stage_addr[key]();
+ }
+ }
+ }
+
+ addr_from_stage(cuser.mailing_addresses[0]);
+ addr_from_stage(cuser.billing_addresses[0]);
+
+ if (user.addresses.length == 1) {
+ // If there is only one address,
+ // use it as both mailing and billing.
+ var addr = user.addresses[0];
+ addr._is_mailing = addr._is_billing = true;
+ user.mailing_address = user.billing_address = addr;
+ }
+
+ if (cuser.cards.length) {
+ user.card = {
+ id : service.virt_id--,
+ barcode : cuser.cards[0].barcode(),
+ isnew : true,
+ active : true,
+ _primary : 'on'
+ };
+
+ user.cards.push(user.card);
+ if (user.usrname == '')
+ user.usrname = card.barcode;
+ }
+
+ angular.forEach(cuser.settings, function(setting) {
+ service.user_settings[setting.setting()] = Boolean(setting.value());
+ });
+ }
+
+ // copy select values from the cloned user to the new user.
+ // user is a hash
+ service.copy_clone_data = function(user) {
+ var clone_user = service.clone_user;
+
+ // flesh the home org locally
+ user.home_ou = egCore.org.get(clone_user.home_ou());
+ if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
+
+ if (!clone_user.billing_address() &&
+ !clone_user.mailing_address())
+ return; // no addresses to copy or link
+
+ // if the cloned user has any addresses, we don't need
+ // the stub address created in init_new_patron.
+ user.addresses = [];
+
+ var copy_addresses =
+ service.org_settings['circ.patron_edit.clone.copy_address'];
+
+ var clone_fields = [
+ 'day_phone',
+ 'evening_phone',
+ 'other_phone',
+ 'usrgroup'
+ ];
+
+ angular.forEach(clone_fields, function(field) {
+ user[field] = clone_user[field]();
+ });
+
+ if (copy_addresses) {
+ var bill_addr, mail_addr;
+
+ // copy the billing and mailing addresses into new addresses
+ function clone_addr(addr) {
+ var new_addr = egCore.idl.toHash(addr);
+ new_addr.id = service.virt_id--;
+ new_addr.usr = user.id;
+ new_addr.isnew = true;
+ new_addr.valid = true;
+ user.addresses.push(new_addr);
+ return new_addr;
+ }
+
+ if (bill_addr = clone_user.billing_address()) {
+ var addr = clone_addr(bill_addr);
+ addr._is_billing = true;
+ user.billing_address = addr;
+ }
+
+ if (mail_addr = clone_user.mailing_address()) {
+
+ if (bill_addr && bill_addr.id() == mail_addr.id()) {
+ user.mailing_address = user.billing_address;
+ user.mailing_address._is_mailing = true;
+ } else {
+ var addr = clone_addr(mail_addr);
+ addr._is_mailing = true;
+ user.mailing_address = addr;
+ }
+
+ if (!bill_addr) {
+ // if there is no billing addr, use the mailing addr
+ user.billing_address = user.mailing_address;
+ user.billing_address._is_billing = true;
+ }
+ }
+
+
+ } else {
+
+ // link the billing and mailing addresses
+ var addr;
+ if (addr = clone_user.billing_address()) {
+ user.billing_address = egCore.idl.toHash(addr);
+ user.billing_address._is_billing = true;
+ user.addresses.push(user.billing_address);
+ user.billing_address._linked_owner_id = clone_user.id();
+ user.billing_address._linked_owner = service.format_name(
+ clone_user.family_name(),
+ clone_user.first_given_name(),
+ clone_user.second_given_name()
+ );
+ }
+
+ if (addr = clone_user.mailing_address()) {
+ if (user.billing_address &&
+ addr.id() == user.billing_address.id) {
+ // mailing matches billing
+ user.mailing_address = user.billing_address;
+ user.mailing_address._is_mailing = true;
+ } else {
+ user.mailing_address = egCore.idl.toHash(addr);
+ user.mailing_address._is_mailing = true;
+ user.addresses.push(user.mailing_address);
+ user.mailing_address._linked_owner_id = clone_user.id();
+ user.mailing_address._linked_owner = service.format_name(
+ clone_user.family_name(),
+ clone_user.first_given_name(),
+ clone_user.second_given_name()
+ );
+ }
+ }
+ }
+ }
+
+ // translate the patron back into IDL form
+ service.save_user = function(phash) {
+
+ var patron = egCore.idl.fromHash('au', phash);
+
+ patron.home_ou(patron.home_ou().id());
+ patron.expire_date(patron.expire_date().toISOString());
+ patron.profile(patron.profile().id());
+ if (patron.dob())
+ patron.dob(patron.dob().toISOString().replace(/T.*/,''));
+ if (patron.ident_type())
+ patron.ident_type(patron.ident_type().id());
+ if (patron.net_access_level())
+ patron.net_access_level(patron.net_access_level().id());
+
+ angular.forEach(
+ ['juvenile', 'barred', 'active', 'master_account'],
+ function(field) { patron[field](phash[field] ? 't' : 'f'); }
+ );
+
+ var card_hashes = patron.cards();
+ patron.cards([]);
+ angular.forEach(card_hashes, function(chash) {
+ var card = egCore.idl.fromHash('ac', chash)
+ card.usr(patron.id());
+ card.active(chash.active ? 't' : 'f');
+ patron.cards().push(card);
+ if (chash._primary) {
+ patron.card(card);
+ }
+ });
+
+ var addr_hashes = patron.addresses();
+ patron.addresses([]);
+ angular.forEach(addr_hashes, function(addr_hash) {
+ if (!addr_hash.isnew && !addr_hash.isdeleted)
+ addr_hash.ischanged = true;
+ var addr = egCore.idl.fromHash('aua', addr_hash);
+ patron.addresses().push(addr);
+ addr.valid(addr.valid() ? 't' : 'f');
+ addr.within_city_limits(addr.within_city_limits() ? 't' : 'f');
+ if (addr_hash._is_mailing) patron.mailing_address(addr);
+ if (addr_hash._is_billing) patron.billing_address(addr);
+ });
+
+ patron.survey_responses([]);
+ angular.forEach(service.survey_responses, function(answer) {
+ var question = service.survey_questions[answer.question()];
+ var resp = new egCore.idl.asvr();
+ resp.isnew(true);
+ resp.survey(question.survey());
+ resp.question(question.id());
+ resp.answer(answer.id());
+ resp.usr(patron.id());
+ resp.answer_date('now');
+ patron.survey_responses().push(resp);
+ });
+
+ // re-object-ify the patron stat cat entry maps
+ var maps = [];
+ angular.forEach(patron.stat_cat_entries(), function(entry) {
+ var e = egCore.idl.fromHash('actscecm', entry);
+ e.stat_cat(e.stat_cat().id);
+ maps.push(e);
+ });
+ patron.stat_cat_entries(maps);
+
+ // service.stat_cat_entry_maps maps stats to values
+ // patron.stat_cat_entries is an array of stat_cat_entry_usr_map's
+ angular.forEach(
+ service.stat_cat_entry_maps, function(value, cat_id) {
+
+ // see if we already have a mapping for this entry
+ var existing = patron.stat_cat_entries().filter(
+ function(e) { return e.stat_cat() == cat_id })[0];
+
+ if (existing) { // we have a mapping
+ // if the existing mapping matches the new one,
+ // there' nothing left to do
+ if (existing.stat_cat_entry() == value) return;
+
+ // mappings differ. delete the old one and create
+ // a new one below.
+ existing.isdeleted(true);
+ }
+
+ var newmap = new egCore.idl.actscecm();
+ newmap.target_usr(patron.id());
+ newmap.isnew(true);
+ newmap.stat_cat(cat_id);
+ newmap.stat_cat_entry(value);
+ patron.stat_cat_entries().push(newmap);
+ });
+
+ if (!patron.isnew()) patron.ischanged(true);
+
+ return egLovefield.addOfflineXact({
+ user : egCore.idl.toHash(patron),
+ timestamp : parseInt(new Date().getTime() / 1000),
+ type : 'register',
+ delta : 0
+ }).then(function (success) {
+ if (success) return patron;
+ });
+ }
+
+ service.remove_staged_user = function() {
+ if (!service.stage_user) return $q.when();
+ return egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.user.stage.delete',
+ egCore.auth.token(),
+ service.stage_user.user.row_id()
+ );
+ }
+
+ service.save_user_settings = function(new_user, user_settings) {
+ return;
+ }
+
+ // Applies field-specific validation regex's from org settings
+ // to form fields. Be careful not remove any pattern data we
+ // are not explicitly over-writing in the provided patterns obj.
+ service.set_field_patterns = function(patterns) {
+ if (service.org_settings['opac.username_regex']) {
+ patterns.au.usrname =
+ new RegExp(service.org_settings['opac.username_regex']);
+ }
+
+ if (service.org_settings['opac.barcode_regex']) {
+ patterns.ac.barcode =
+ new RegExp(service.org_settings['opac.barcode_regex']);
+ }
+
+ if (service.org_settings['global.password_regex']) {
+ patterns.au.passwd =
+ new RegExp(service.org_settings['global.password_regex']);
+ }
+
+ var phone_reg = service.org_settings['ui.patron.edit.phone.regex'];
+ if (phone_reg) {
+ // apply generic phone regex first, replace below as needed.
+ patterns.au.day_phone = new RegExp(phone_reg);
+ patterns.au.evening_phone = new RegExp(phone_reg);
+ patterns.au.other_phone = new RegExp(phone_reg);
+ }
+
+ // the remaining patterns fit a well-known key name pattern
+
+ angular.forEach(service.org_settings, function(val, key) {
+ if (!val) return;
+ var parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
+ if (!parts) return;
+ var cls = parts[1];
+ var name = parts[2];
+ patterns[cls][name] = new RegExp(val);
+ });
+ }
+
+ return service;
+}])
+
+.controller('PatronRegCtrl',
+ ['$scope','$routeParams','$q','$uibModal','$window','egCore',
+ 'patronSvc','patronRegSvc','egUnloadPrompt','egAlertDialog',
+ 'egWorkLog','$timeout','egLovefield','$rootScope',
+function($scope , $routeParams , $q , $uibModal , $window , egCore ,
+ patronSvc , patronRegSvc , egUnloadPrompt, egAlertDialog ,
+ egWorkLog , $timeout , egLovefield , $rootScope) {
+
+ $scope.rs = $rootScope;
+ if ($scope.workstation_obj) patronRegSvc.org = $scope.workstation_obj.owning_lib;
+ $scope.offline = true;
+
+ $scope.page_data_loaded = false;
+ $scope.clone_id = patronRegSvc.clone_id = $routeParams.clone_id;
+ $scope.stage_username =
+ patronRegSvc.stage_username = $routeParams.stage_username;
+ $scope.patron_id =
+ patronRegSvc.patron_id = $routeParams.edit_id || $routeParams.id;
+
+ // for existing patrons, disable barcode input by default
+ $scope.disable_bc = $scope.focus_usrname = Boolean($scope.patron_id);
+ $scope.focus_bc = !Boolean($scope.patron_id);
+ $scope.address_alerts = [];
+ $scope.dupe_counts = {};
+
+ // map of perm name to true/false for perms the logged in user
+ // has at the currently selected patron home org unit.
+ $scope.perms = {};
+
+ $scope.edit_passthru = {};
+
+ // 0=all, 1=suggested, 2=all
+ $scope.edit_passthru.vis_level = 2;
+
+ // Apply default values for new patrons during initial registration
+ // prs is shorthand for patronSvc
+ function set_new_patron_defaults(prs) {
+ if (!$scope.patron.passwd) {
+ // passsword may originate from staged user.
+ $scope.generate_password();
+ }
+ $scope.hold_notify_phone = true;
+ $scope.hold_notify_email = true;
+
+ // staged users may be loaded w/ a profile.
+ $scope.set_expire_date();
+
+ if (prs.org_settings['ui.patron.default_ident_type']) {
+ // $scope.patron needs this field to be an object
+ var id = prs.org_settings['ui.patron.default_ident_type'];
+ var ident_type = $scope.ident_types.filter(
+ function(type) { return type.id() == id })[0];
+ $scope.patron.ident_type = ident_type;
+ }
+ if (prs.org_settings['ui.patron.default_inet_access_level']) {
+ // $scope.patron needs this field to be an object
+ var id = prs.org_settings['ui.patron.default_inet_access_level'];
+ var level = $scope.net_access_levels.filter(
+ function(lvl) { return lvl.id() == id })[0];
+ $scope.patron.net_access_level = level;
+ }
+ if (prs.org_settings['ui.patron.default_country']) {
+ $scope.patron.addresses[0].country =
+ prs.org_settings['ui.patron.default_country'];
+ }
+ }
+
+ // A null or undefined pattern leads to exceptions. Before the
+ // patterns are loaded from the server, default all patterns
+ // to an innocuous regex. To avoid re-creating numerous
+ // RegExp objects, cache the stub RegExp after initial creation.
+ // note: angular docs say ng-pattern accepts a regexp or string,
+ // but as of writing, it only works with a regexp object.
+ // (Likely an angular 1.2 vs. 1.4 issue).
+ var field_patterns = {au : {}, ac : {}, aua : {}};
+ $scope.field_pattern = function(cls, field) {
+ if (!field_patterns[cls][field])
+ field_patterns[cls][field] = new RegExp('.*');
+ return field_patterns[cls][field];
+ }
+
+ patronRegSvc.offlineMode($scope.offline); // force offline if ng-init'd to do so
+ patronRegSvc.init().then(function() {
+ // called after initTab and patronRegSvc.init have completed
+
+ var prs = patronRegSvc; // brevity
+ // in standalone mode, we have no patronSvc
+ $scope.patron = prs.init_patron(patronSvc ? patronSvc.current : null);
+ $scope.field_doc = prs.field_doc;
+ $scope.edit_profiles = prs.edit_profiles;
+ $scope.ident_types = prs.ident_types;
+ $scope.net_access_levels = prs.net_access_levels;
+ $scope.user_setting_types = prs.user_setting_types;
+ $scope.opt_in_setting_types = prs.opt_in_setting_types;
+ $scope.org_settings = prs.org_settings;
+ $scope.sms_carriers = prs.sms_carriers;
+ $scope.stat_cats = prs.stat_cats;
+ $scope.surveys = prs.surveys;
+ $scope.survey_responses = prs.survey_responses;
+ $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps;
+ $scope.stage_user = prs.stage_user;
+ $scope.stage_user_requestor = prs.stage_user_requestor;
+
+ $scope.user_settings = prs.user_settings;
+ // clone the user settings back into the patronRegSvc so
+ // we have a copy of the original state of the settings.
+ prs.user_settings = {};
+ angular.forEach($scope.user_settings, function(val, key) {
+ prs.user_settings[key] = val;
+ });
+
+ extract_hold_notify();
+ $scope.handle_home_org_changed();
+
+ if ($scope.org_settings['ui.patron.edit.default_suggested'])
+ $scope.edit_passthru.vis_level = 1;
+
+ if ($scope.patron.isnew)
+ set_new_patron_defaults(prs);
+
+ $scope.page_data_loaded = true;
+
+ prs.set_field_patterns(field_patterns);
+ apply_username_regex();
+ });
+
+ // update the currently displayed field documentation
+ $scope.set_selected_field_doc = function(cls, field) {
+ $scope.selected_field_doc = $scope.field_doc[cls][field];
+ }
+
+ // returns the tree depth of the selected profile group tree node.
+ $scope.pgt_depth = function(grp) {
+ var d = 0;
+ while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+ return d;
+ }
+
+ // IDL fields used for labels in the UI.
+ $scope.idl_fields = {
+ au : egCore.idl.classes.au.field_map,
+ ac : egCore.idl.classes.ac.field_map,
+ aua : egCore.idl.classes.aua.field_map
+ };
+
+ // field visibility cache. Some fields are universally required.
+ // 3 == value universally required
+ // 2 == field is visible by default
+ // 1 == field is suggested by default
+ var field_visibility = {};
+ var default_field_visibility = {
+ 'ac.barcode' : 3,
+ 'au.usrname' : 3,
+ 'au.passwd' : 3,
+ 'au.first_given_name' : 3,
+ 'au.family_name' : 3,
+ 'au.ident_type' : 3,
+ 'au.home_ou' : 3,
+ 'au.profile' : 3,
+ 'au.expire_date' : 3,
+ 'au.net_access_level' : 3,
+ 'aua.address_type' : 3,
+ 'aua.post_code' : 3,
+ 'aua.street1' : 3,
+ 'aua.street2' : 2,
+ 'aua.city' : 3,
+ 'aua.county' : 2,
+ 'aua.state' : 2,
+ 'aua.country' : 3,
+ 'aua.valid' : 2,
+ 'aua.within_city_limits' : 2,
+ 'stat_cats' : 1,
+ 'surveys' : 1
+ };
+
+ // Returns true if the selected field should be visible
+ // given the current required/suggested/all setting.
+ // The visibility flag applied to each field as a result of calling
+ // this function also sets (via the same flag) the requiredness state.
+ $scope.show_field = function(field_key) {
+ // org settings have not been received yet.
+ if (!$scope.org_settings) return false;
+
+ if (field_visibility[field_key] == undefined) {
+ // compile and cache the visibility for the selected field
+
+ var req_set = 'ui.patron.edit.' + field_key + '.require';
+ var sho_set = 'ui.patron.edit.' + field_key + '.show';
+ var sug_set = 'ui.patron.edit.' + field_key + '.suggest';
+
+ if ($scope.org_settings[req_set]) {
+ field_visibility[field_key] = 3;
+
+ } else if ($scope.org_settings[sho_set]) {
+ field_visibility[field_key] = 2;
+
+ } else if ($scope.org_settings[sug_set]) {
+ field_visibility[field_key] = 1;
+ }
+ }
+
+ if (field_visibility[field_key] == undefined) {
+ // No org settings were applied above. Use the default
+ // settings if present or assume the field has no
+ // visibility flags applied.
+ field_visibility[field_key] =
+ default_field_visibility[field_key] || 0;
+ }
+
+ return field_visibility[field_key] >= $scope.edit_passthru.vis_level;
+ }
+
+ // See $scope.show_field().
+ // A field with visbility level 3 means it's required.
+ $scope.field_required = function(cls, field) {
+
+ // Value in the password field is not required
+ // for existing patrons.
+ if (field == 'passwd' && $scope.patron && !$scope.patron.isnew)
+ return false;
+
+ return (field_visibility[cls + '.' + field] == 3 || default_field_visibility[cls + '.' + field] == 3);
+ }
+
+ // generates a random 4-digit password
+ $scope.generate_password = function() {
+ $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000;
+ }
+
+ $scope.set_expire_date = function() {
+ if (!$scope.patron.profile) return;
+ var seconds = egCore.date.intervalToSeconds(
+ $scope.patron.profile.perm_interval());
+ var now_epoch = new Date().getTime();
+ $scope.patron.expire_date = new Date(
+ now_epoch + (seconds * 1000 /* milliseconds */))
+ }
+
+ // grp is the pgt object
+ $scope.set_profile = function(grp) {
+ $scope.patron.profile = grp;
+ $scope.set_expire_date();
+ $scope.field_modified();
+ }
+
+ $scope.invalid_profile = function() {
+ return !(
+ $scope.patron &&
+ $scope.patron.profile &&
+ $scope.patron.profile.usergroup() == 't'
+ );
+ }
+
+ $scope.new_address = function() {
+ var addr = egCore.idl.toHash(new egCore.idl.aua());
+ patronRegSvc.ingest_address($scope.patron, addr);
+ addr.id = patronRegSvc.virt_id--;
+ addr.isnew = true;
+ addr.valid = true;
+ addr.within_city_limits = true;
+ addr.country = $scope.org_settings['ui.patron.default_country'];
+ $scope.patron.addresses.push(addr);
+ }
+
+ // keep deleted addresses out of the patron object so
+ // they won't appear in the UI. They'll be re-inserted
+ // when the patron is updated.
+ deleted_addresses = [];
+ $scope.delete_address = function(id) {
+
+ if ($scope.patron.isnew &&
+ $scope.patron.addresses.length == 1 &&
+ $scope.org_settings['ui.patron.registration.require_address']) {
+ egAlertDialog.open(egCore.strings.REG_ADDR_REQUIRED);
+ return;
+ }
+
+ var addresses = [];
+ angular.forEach($scope.patron.addresses, function(addr) {
+ if (addr.id == id) {
+ if (id > 0) {
+ addr.isdeleted = true;
+ deleted_addresses.push(addr);
+ }
+ } else {
+ addresses.push(addr);
+ }
+ });
+ $scope.patron.addresses = addresses;
+ }
+
+ $scope.post_code_changed = function(addr) {
+ if ($scope.offline) return;
+ egCore.net.request(
+ 'open-ils.search', 'open-ils.search.zip', addr.post_code)
+ .then(function(resp) {
+ if (!resp) return;
+ if (resp.city) addr.city = resp.city;
+ if (resp.state) addr.state = resp.state;
+ if (resp.county) addr.county = resp.county;
+ if (resp.alert) alert(resp.alert);
+ });
+ }
+
+ $scope.replace_card = function() {
+ $scope.patron.card.active = false;
+ $scope.patron.card.ischanged = true;
+ $scope.disable_bc = false;
+
+ var new_card = egCore.idl.toHash(new egCore.idl.ac());
+ new_card.id = patronRegSvc.virt_id--;
+ new_card.isnew = true;
+ new_card.active = true;
+ new_card._primary = 'on';
+ $scope.patron.card = new_card;
+ $scope.patron.cards.push(new_card);
+ }
+
+ $scope.day_phone_changed = function(phone) {
+ if (phone && $scope.patron.isnew &&
+ $scope.org_settings['patron.password.use_phone']) {
+ $scope.patron.passwd = phone.substr(-4);
+ }
+ }
+
+ $scope.barcode_changed = function(bc) {
+ if (!bc) return;
+ if (!$scope.patron.usrname)
+ $scope.patron.usrname = bc;
+ }
+
+ $scope.cards_dialog = function() {
+ $uibModal.open({
+ templateUrl: './circ/patron/t_patron_cards_dialog',
+ controller:
+ ['$scope','$uibModalInstance','cards','perms',
+ function($scope , $uibModalInstance , cards , perms) {
+ // scope here is the modal-level scope
+ $scope.args = {cards : cards};
+ $scope.perms = perms;
+ $scope.ok = function() { $uibModalInstance.close($scope.args) }
+ $scope.cancel = function () { $uibModalInstance.dismiss() }
+ }],
+ resolve : {
+ cards : function() {
+ // scope here is the controller-level scope
+ return $scope.patron.cards;
+ },
+ perms : function() {
+ return $scope.perms;
+ }
+ }
+ }).result.then(
+ function(args) {
+ angular.forEach(args.cards, function(card) {
+ card.ischanged = true; // assume cards need updating, OK?
+ if (card._primary == 'on' &&
+ card.id != $scope.patron.card.id) {
+ $scope.patron.card = card;
+ }
+ });
+ }
+ );
+ }
+
+ $scope.set_addr_type = function(addr, type) {
+ var addrs = $scope.patron.addresses;
+ if (addr['_is_'+type]) {
+ angular.forEach(addrs, function(a) {
+ if (a.id != addr.id) a['_is_'+type] = false;
+ });
+ } else {
+ // unchecking mailing/billing means we have to randomly
+ // select another address to fill that role. Select the
+ // first address in the list (that does not match the
+ // modifed address)
+ for (var i = 0; i < addrs.length; i++) {
+ if (addrs[i].id != addr.id) {
+ addrs[i]['_is_' + type] = true;
+ break;
+ }
+ }
+ }
+ }
+
+
+ // Translate hold notify preferences from the form/scope back into a
+ // single user setting value for opac.hold_notify.
+ function compress_hold_notify() {
+ var hold_notify = '';
+ var splitter = '';
+ if ($scope.hold_notify_phone) {
+ hold_notify = 'phone';
+ splitter = ':';
+ }
+ if ($scope.hold_notify_email) {
+ hold_notify = splitter + 'email';
+ splitter = ':';
+ }
+ if ($scope.hold_notify_sms) {
+ hold_notify = splitter + 'sms';
+ splitter = ':';
+ }
+ $scope.user_settings['opac.hold_notify'] = hold_notify;
+ }
+
+ // dialog for selecting additional permission groups
+ $scope.secondary_groups_dialog = function() {
+ $uibModal.open({
+ templateUrl: './circ/patron/t_patron_groups_dialog',
+ controller:
+ ['$scope','$uibModalInstance','linked_groups','pgt_depth',
+ function($scope , $uibModalInstance , linked_groups , pgt_depth) {
+
+ $scope.pgt_depth = pgt_depth;
+ $scope.args = {
+ linked_groups : linked_groups,
+ edit_profiles : patronRegSvc.edit_profiles,
+ new_profile : patronRegSvc.edit_profiles[0]
+ };
+
+ // add a new group to the linked groups list
+ $scope.link_group = function($event, grp) {
+ var found = false; // avoid duplicates
+ angular.forEach($scope.args.linked_groups,
+ function(g) {if (g.id() == grp.id()) found = true});
+ if (!found) $scope.args.linked_groups.push(grp);
+ $event.preventDefault(); // avoid close
+ }
+
+ // remove a group from the linked groups list
+ $scope.unlink_group = function($event, grp) {
+ $scope.args.linked_groups =
+ $scope.args.linked_groups.filter(function(g) {
+ return g.id() != grp.id()
+ });
+ $event.preventDefault(); // avoid close
+ }
+
+ $scope.ok = function() { $uibModalInstance.close($scope.args) }
+ $scope.cancel = function () { $uibModalInstance.dismiss() }
+ }],
+ resolve : {
+ linked_groups : function() { return $scope.patron.groups },
+ pgt_depth : function() { return $scope.pgt_depth }
+ }
+ }).result.then(
+ function(args) {
+
+ if ($scope.patron.isnew) {
+ // groups must be linked for new patrons after the
+ // patron is created.
+ $scope.patron.groups = args.linked_groups;
+ return;
+ }
+
+ // update links groups for existing users in real time.
+ var ids = args.linked_groups.map(function(g) {return g.id()});
+ patronRegSvc.apply_secondary_groups($scope.patron.id, ids)
+ .then(function(success) {
+ if (success)
+ $scope.patron.groups = args.linked_groups;
+ });
+ }
+ );
+ }
+
+ function extract_hold_notify() {
+ notify = $scope.user_settings['opac.hold_notify'];
+ if (!notify) return;
+ $scope.hold_notify_phone = Boolean(notify.match(/phone/));
+ $scope.hold_notify_email = Boolean(notify.match(/email/));
+ $scope.hold_notify_sms = Boolean(notify.match(/sms/));
+ }
+
+ $scope.invalidate_field = function(field) {
+ patronRegSvc.invalidate_field($scope.patron, field);
+ }
+
+ address_alert = function(addr) {
+ if ($scope.offline) return;
+ var args = {
+ street1: addr.street1,
+ street2: addr.street2,
+ city: addr.city,
+ state: addr.state,
+ county: addr.county,
+ country: addr.country,
+ post_code: addr.post_code,
+ mailing_address: addr._is_mailing,
+ billing_address: addr._is_billing
+ }
+
+ egCore.net.request(
+ 'open-ils.actor',
+ 'open-ils.actor.address_alert.test',
+ egCore.auth.token(), egCore.auth.user().ws_ou(), args
+ ).then(function(res) {
+ $scope.address_alerts = res;
+ });
+ }
+
+ $scope.dupe_value_changed = function(type, value) {
+ $scope.dupe_counts[type] = 0;
+ patronRegSvc.dupe_patron_search($scope.patron, type, value)
+ .then(function(res) {
+ $scope.dupe_counts[type] = res.count;
+ if (res.count) {
+ $scope.dupe_search_encoded =
+ encodeURIComponent(js2JSON(res.search));
+ } else {
+ $scope.dupe_search_encoded = '';
+ }
+ });
+ }
+
+ // Dummy function in offline mode
+ $scope.handle_home_org_changed = function() {}
+
+ // This is called with every character typed in a form field,
+ // since that's the only way to gaurantee something has changed.
+ // See handle_field_changed for ng-change vs. ng-blur.
+ $scope.field_modified = function() {
+ // Call attach with every field change, regardless of whether
+ // it's been called before. This will allow for re-attach after
+ // the user clicks through the unload warning. egUnloadPrompt
+ // will ensure we only attach once.
+ egUnloadPrompt.attach($rootScope);
+ }
+
+ // also monitor when form is changed *by the user*, as using
+ // an ng-change handler doesn't work with eg-date-input
+ $scope.$watch('reg_form.$pristine', function(newVal, oldVal) {
+ if (!newVal) egUnloadPrompt.attach($rootScope);
+ });
+
+ // username regex (if present) must be removed any time
+ // the username matches the barcode to avoid firing the
+ // invalid field handlers.
+ function apply_username_regex() {
+ var regex = $scope.org_settings['opac.username_regex'];
+ if (regex) {
+ if ($scope.patron.card.barcode) {
+ // username must match the regex or the barcode
+ field_patterns.au.usrname =
+ new RegExp(
+ regex + '|^' + $scope.patron.card.barcode + '$');
+ } else {
+ // username must match the regex
+ field_patterns.au.usrname = new RegExp(regex);
+ }
+ } else {
+ // username can be any format.
+ field_patterns.au.usrname = new RegExp('.*');
+ }
+ }
+
+ // obj could be the patron, an address, etc.
+ // This is called any time a form field achieves then loses focus.
+ // It does not necessarily mean the field has changed.
+ // The alternative is ng-change, but it's called with each character
+ // typed, which would be overkill for many of the actions called here.
+ $scope.handle_field_changed = function(obj, field_name) {
+ if (!obj) return;
+
+ var cls = obj.classname; // set by egIdl
+ var value = obj[field_name];
+
+ // Hush!
+ //console.log('changing field ' + field_name + ' to ' + value);
+
+ switch (field_name) {
+ case 'day_phone' :
+ if ($scope.patron.day_phone &&
+ $scope.patron.isnew &&
+ $scope.org_settings['patron.password.use_phone']) {
+ $scope.patron.passwd = phone.substr(-4);
+ }
+ break;
+
+ case 'barcode':
+ apply_username_regex();
+ $scope.barcode_changed(value);
+ break;
+
+ case 'dob':
+ maintain_juvenile_flag();
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ // patron.juvenile is set to true if the user was born after
+ function maintain_juvenile_flag() {
+ if ( !($scope.patron && $scope.patron.dob) ) return;
+
+ var juv_interval =
+ $scope.org_settings['global.juvenile_age_threshold']
+ || '18 years';
+
+ var base = new Date();
+
+ base.setTime(base.getTime() -
+ Number(egCore.date.intervalToSeconds(juv_interval) + '000'));
+
+ $scope.patron.juvenile = ($scope.patron.dob > base);
+ }
+
+ // returns true (disable) for orgs that cannot have users.
+ $scope.disable_home_org = function(org_id) {
+ if (!org_id) return;
+ var org = egCore.org.get(org_id);
+ return (
+ org &&
+ org.ou_type() &&
+ org.ou_type().can_have_users() == 'f'
+ );
+ }
+
+ // Returns true if the Save and Save & Clone buttons should be disabled.
+ $scope.edit_passthru.hide_save_actions = function() {
+ return false;
+ }
+
+ // Returns true if any input elements are tagged as invalid
+ // via Angular patterns or required attributes.
+ function form_has_invalid_fields() {
+ return $('#patron-reg-container .ng-invalid').length > 0;
+ }
+
+ function form_is_incomplete() {
+ return (
+ $scope.dupe_username ||
+ $scope.dupe_barcode ||
+ form_has_invalid_fields()
+ );
+
+ }
+
+ $scope.edit_passthru.save = function(save_args) {
+ if (!save_args) save_args = {};
+
+ if (form_is_incomplete()) {
+ // User has not provided valid values for all required fields.
+ return egAlertDialog.open(egCore.strings.REG_INVALID_FIELDS);
+ }
+
+ // remove page unload warning prompt
+ egUnloadPrompt.clear();
+
+ // toss the deleted addresses back into the patron's list of
+ // addresses so it's included in the update
+ $scope.patron.addresses =
+ $scope.patron.addresses.concat(deleted_addresses);
+
+ compress_hold_notify();
+
+ var updated_user;
+
+ patronRegSvc.save_user($scope.patron)
+ .then($scope.rs.save_offline_xacts)
+ .then(function(new_user) {
+ // reload the current page
+ $window.location.href = location.href;
+ });
+ }
+}])
"angular-mocks": "~1.5.0",
"angular-route": "~1.5.0",
"angular-tree-control": "~0.2.28",
+ "angular-tablesort": "^1.4.1",
"angular-order-object-by": "rxfork/ngOrderObjectBy#npm",
"lovefield": "*",
"moment": "*",
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();
- service.check_workstation(deferred);
-
- } else {
- // authtoken test failed
- egHatch.clearLoginSessionItems();
- deferred.reject();
- }
- });
+ if (lf.isOffline && !$location.path().match(/\/session/) ) {
+ // Just stop here if we're in the offline interface but not on the session tab
+ $timeout(function(){deferred.resolve()});
+ } else if (lf.isOffline && $location.path().match(/\/session/) && !$window.navigator.onLine) {
+ // Likewise, if we're in the offline interface on the session tab and the network is down.
+ // The session tab itself will redirect appropriately due to no network.
+ $timeout(function(){deferred.resolve()});
+ } else {
+ // Otherwise, check the token. This will freeze all other interfaces, which is what we want.
+ 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();
+ service.check_workstation(deferred);
+
+ } else {
+ // authtoken test failed
+ egHatch.clearLoginSessionItems();
+ deferred.reject();
+ }
+ });
+ }
} else {
// no authtoken to test
// env fetcher
.factory('egEnv',
- ['$q','$window','egAuth','egPCRUD','egIDL',
-function($q, $window , egAuth, egPCRUD, egIDL) {
+ ['$q','$window','$injector','egAuth','egPCRUD','egIDL',
+function($q, $window , $injector , egAuth, egPCRUD, egIDL) {
var service = {
// collection of custom loader functions
- loaders : []
+ loaders : [],
+
+ // Add class hints to this list when offline does not need them and
+ // if they cause "Maximum call stack size exceeded" console errors.
+ // If offline does need a list that causes problems, a custom loader
+ // will be necessary.
+ // We'll start with authority-related classes causing problems in the
+ // staff catalog.
+ ignoreOffline : ['at','acs','abaafm','aba','acsbf','acsaf']
};
/** given a tree-shaped collection, captures the tree and
* flattens the tree for absorption.
*/
- service.absorbTree = function(tree, class_) {
+ service.absorbTree = function(tree, class_, noOffline) {
+ if (service[class_] && service[class_].loaded) return;
+
var list = [];
function squash(node) {
list.push(node);
angular.forEach(node.children(), squash);
}
squash(tree);
- var blob = service.absorbList(list, class_);
+ var blob = service.absorbList(list, class_, noOffline);
blob.tree = tree;
};
+ var egLovefield; // we'll inject it manually
+
/** caches the object list both as the list and an id => object map */
- service.absorbList = function(list, class_) {
+ service.absorbList = function(list, class_, noOffline) {
+ if (service[class_] && service[class_].loaded) return service[class_];
+
var blob;
var pkey = egIDL.classes[class_].pkey;
blob = {list : list, map : {}};
}
+ if (!noOffline && service.ignoreOffline.indexOf(class_) < 0) {
+ if (!egLovefield) {
+ egLovefield = $injector.get('egLovefield');
+ }
+ console.debug('About to cache a list of ' + class_ + ' objects...');
+ egLovefield.isCacheGood(class_).then(function(good) {
+ if (!good) egLovefield.setListInOfflineCache(class_, blob.list);
+ });
+ }
+
angular.forEach(list, function(item) {blob.map[item[pkey]()] = item});
service[class_] = blob;
+ service[class_].loaded = true;
return blob;
};
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);
- }
-
- // sort orgs at each level by shortname
- function sort_aou(node) {
- node.children(node.children().sort(function(a, b) {
- return a.shortname() < b.shortname() ? -1 : 1;
- }));
- angular.forEach(node.children(), sort_aou);
+ if (!egLovefield) {
+ egLovefield = $injector.get('egLovefield');
}
- return egPCRUD.search('aou', {parent_ou : null},
- {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
- ).then(
- function(tree) {
- sort_aou(tree);
- $window.sessionStorage.setItem(
- 'eg.env.aou.tree', js2JSON(tree));
+ return egLovefield.reconstituteTree('aou').then(function(offline) {
+ if (offline) return $q.when();
+ if (service.aou && service.aou.loaded) return $q.when();
+
+ // 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);
}
- );
+
+ // sort orgs at each level by shortname
+ function sort_aou(node) {
+ node.children(node.children().sort(function(a, b) {
+ return a.shortname() < b.shortname() ? -1 : 1;
+ }));
+ angular.forEach(node.children(), sort_aou);
+ }
+
+ return egPCRUD.search('aou', {parent_ou : null},
+ {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+ ).then(
+ function(tree) {
+ sort_aou(tree);
+ $window.sessionStorage.setItem(
+ 'eg.env.aou.tree', js2JSON(tree));
+ service.absorbTree(tree, 'aou');
+ return $q.when();
+ }
+ );
+ });
},
};
defaultFileName: '='
},
link: function (scope, element, attributes) {
+ var name = scope.defaultFileName || 'evergreen-json-export';
element.bind('click', function (clickEvent) {
if (scope.generator) {
scope.generator().then(function(value) {
var data = new Blob([JSON.stringify(value)], {type : 'application/json'});
- FileSaver.saveAs(data, scope.defaultFileName);
+ FileSaver.saveAs(data, name);
});
} else {
var data = new Blob([JSON.stringify(scope.container)], {type : 'application/json'});
- FileSaver.saveAs(data, scope.defaultFileName);
+ FileSaver.saveAs(data, name);
}
});
}
}
}])
+
+// The following directives use a attr instead of binding to get the default file name!
+.directive('egStringExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) {
+ return {
+ scope: {
+ contentType: '=',
+ string: '=',
+ generator: '=',
+ defaultFileName: '@'
+ },
+ link: function (scope, element, attributes) {
+ var type = scope.contentType || 'text/plain';
+ var name = scope.defaultFileName || 'evergreen-string-export';
+ element.bind('click', function (clickEvent) {
+ if (scope.generator) {
+ scope.generator().then(function(value) {
+ var data = new Blob([value], {type : type});
+ FileSaver.saveAs(data, name);
+ });
+ } else {
+ var data = new Blob([scope.string], {type : type});
+ FileSaver.saveAs(data, name);
+ }
+ });
+ }
+ }
+}])
+
+.directive('egLineExporter', ['FileSaver', 'Blob', function(FileSaver, Blob) {
+ return {
+ scope: {
+ contentType: '=',
+ jsonArray: '=',
+ defaultFileName: '@'
+ },
+ link: function (scope, element, attributes) {
+ element.bind('click', function (clickEvent) {
+ var type = scope.contentType || 'text/plain';
+ var fname = scope.defaultFileName || 'evergreen-string-export';
+ FileSaver.saveAs(
+ new Blob(
+ scope.jsonArray.map(function (line) {
+ return JSON.stringify(line) + '\n';
+ }),
+ {type : type}
+ ),
+ fname
+ );
+ });
+ }
+ }
+}])
+
;
$window.localStorage.setItem(key, jsonified);
}
+ service.appendItem = function(key, value) {
+ if (!service.useSettings())
+ return $q.when(service.appendLocalItem(key, value));
+
+ if (service.hatchAvailable)
+ return service.appendRemoteItem(key, value);
+
+ if (service.keyIsOnCall(key)) {
+ console.warn("Unable to appendItem in Hatch: " +
+ key + ". Setting in local storage instead");
+
+ return $q.when(service.appendLocalItem(key, value));
+ }
+
+ console.error("Unable to appendItem in Hatch: " + key);
+ return $q.reject();
+ }
+
+ // append the value to a stored or new item
+ service.appendRemoteItem = function(key, value) {
+ service.keyCache[key] = value;
+ return service.attemptHatchDelivery({
+ key : key,
+ content : value,
+ action : 'append',
+ });
+ }
+
+ service.appendLocalItem = function(key, value, jsonified) {
+ if (jsonified === undefined )
+ jsonified = JSON.stringify(value);
+
+ var old_value = $window.localStorage.getItem(key) || '';
+ $window.localStorage.setItem( key, old_value + jsonified );
+ }
+
// Set the value for the given key.
// "LoginSession" items are removed when the user logs out or the
// browser is closed.
else
obj.a[i][j] = angular.copy(thing[j]);
}
- } else {
- obj.a[i] = angular.copy(thing);
}
}
}
--- /dev/null
+var osb = lf.schema.create('offline', 2);
+
+osb.createTable('Object').
+ addColumn('type', lf.Type.STRING). // class hint
+ addColumn('id', lf.Type.STRING). // obj id
+ addColumn('object', lf.Type.OBJECT).
+ addPrimaryKey(['type','id']);
+
+osb.createTable('CacheDate').
+ addColumn('type', lf.Type.STRING). // class hint
+ addColumn('cachedate', lf.Type.DATE_TIME). // when was it last updated
+ addPrimaryKey(['type']);
+
+osb.createTable('Setting').
+ addColumn('name', lf.Type.STRING).
+ addColumn('value', lf.Type.STRING).
+ addPrimaryKey(['name']);
+
+osb.createTable('StatCat').
+ addColumn('id', lf.Type.INTEGER).
+ addColumn('value', lf.Type.OBJECT).
+ addPrimaryKey(['id']);
+
+osb.createTable('OfflineXact').
+ addColumn('seq', lf.Type.INTEGER).
+ addColumn('value', lf.Type.OBJECT).
+ addPrimaryKey(['seq'], true);
+
+osb.createTable('OfflineBlocks').
+ addColumn('barcode', lf.Type.STRING).
+ addColumn('reason', lf.Type.STRING).
+ addPrimaryKey(['barcode']);
+
+lf.connecting = true;
+osb.connect().then(function (db) {
+ lf.offlineDB = db;
+ lf.connecting = false;
+});
+
+/**
+ * Core Service - egLovefield
+ *
+ * Lovefield wrapper factory for low level offline stuff
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egLovefield', ['$q','$rootScope','egCore','$timeout',
+ function($q , $rootScope , egCore , $timeout) {
+
+ var service = {};
+
+ function connectOrGo (resolver) {
+ if (lf.offlineDB) {
+ return resolver();
+ }
+
+ // apparently, this might take a while...
+ if (lf.connecting) return $timeout(function() {
+ return connectOrGo(resolver);
+ });
+
+ console.log('egLovefield connecting to offline DB');
+
+ try {
+ return osb.connect().then(function (db) {
+ lf.offlineDB = db;
+ return resolver();
+ });
+ } catch (err) {
+ alert('attempted reconnect failure: ' + err.toString());
+ }
+ }
+
+ service.isCacheGood = function (type) {
+
+ return connectOrGo(function() {
+ var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
+
+ return lf.offlineDB.
+ select(cacheDate.cachedate).
+ from(cacheDate).
+ where(cacheDate.type.eq(type)).
+ exec().then(function(results) {
+ if (results.length == 0) {
+ return $q.when(false);
+ }
+
+ var now = new Date();
+
+ // hard-coded 1 day offline cache timeout
+ return $q.when((now.getTime() - results[0]['cachedate'].getTime()) <= 86400000);
+ })
+ });
+ }
+
+ service.destroyPendingOfflineXacts = function () {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineXact');
+ return lf.offlineDB.
+ delete().
+ from(table).
+ exec();
+ });
+ }
+
+ service.havePendingOfflineXacts = function () {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineXact');
+ return lf.offlineDB.
+ select(table.reason).
+ from(table).
+ exec().
+ then(function(list) {
+ return $q.when(Boolean(list.length > 0))
+ });
+ });
+ }
+
+ service.retrievePendingOfflineXacts = function () {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineXact');
+ return lf.offlineDB.
+ select(table.value).
+ from(table).
+ exec().
+ then(function(list) {
+ return $q.when(list.map(function(x) { return x.value }))
+ });
+ });
+ }
+
+ service.destroyOfflineBlocks = function () {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+ return $q.when(
+ lf.offlineDB.
+ delete().
+ from(table).
+ exec()
+ );
+ });
+ }
+
+ service.addOfflineBlock = function (barcode, reason) {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+ return $q.when(
+ lf.offlineDB.
+ insertOrReplace().
+ into(table).
+ values([ table.createRow({ barcode : barcode, reason : reason }) ]).
+ exec()
+ );
+ });
+ }
+
+ // Returns a promise with true for blocked, false for not blocked
+ service.testOfflineBlock = function (barcode) {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineBlocks');
+ return lf.offlineDB.
+ select(table.reason).
+ from(table).
+ where(table.barcode.eq(barcode)).
+ exec().then(function(list) {
+ if(list.length > 0) return $q.when(list[0].reason);
+ return $q.when(null);
+ });
+ });
+ }
+
+ service.addOfflineXact = function (obj) {
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('OfflineXact');
+ return $q.when(
+ lf.offlineDB.
+ insertOrReplace().
+ into(table).
+ values([ table.createRow({ value : obj }) ]).
+ exec()
+ );
+ });
+ }
+
+ service.setStatCatsCache = function (statcats) {
+ if (lf.isOffline) return $q.when();
+
+ return connectOrGo(function() {
+ var table = lf.offlineDB.getSchema().table('StatCat');
+ var rlist = [];
+
+ angular.forEach(statcats, function (val) {
+ rlist.push(table.createRow({
+ id : val.id(),
+ value : egCore.idl.toHash(val)
+ }));
+ });
+ return lf.offlineDB.
+ insertOrReplace().
+ into(table).
+ values(rlist).
+ exec();
+ });
+ }
+
+ service.getStatCatsCache = function () {
+ return connectOrGo(function() {
+
+ var table = lf.offlineDB.getSchema().table('StatCat');
+ var result = [];
+ return lf.offlineDB.
+ select(table.value).
+ from(table).
+ exec().then(function(list) {
+ angular.forEach(list, function (s) {
+ var sc = egCore.idl.fromHash('actsc', s.value);
+
+ if (angular.isArray(sc.default_entries())) {
+ sc.default_entries(
+ sc.default_entries().map( function (k) {
+ return egCore.idl.fromHash('actsced', k);
+ })
+ );
+ }
+
+ if (angular.isArray(sc.entries())) {
+ sc.entries(
+ sc.entries().map( function (k) {
+ return egCore.idl.fromHash('actsce', k);
+ })
+ );
+ }
+
+ result.push(sc);
+ });
+ return $q.when(result);
+ });
+
+ });
+ }
+
+ service.setSettingsCache = function (settings) {
+ if (lf.isOffline) return $q.when();
+
+ return connectOrGo(function() {
+
+ var table = lf.offlineDB.getSchema().table('Setting');
+ var rlist = [];
+
+ angular.forEach(settings, function (val, key) {
+ rlist.push(
+ table.createRow({
+ name : key,
+ value : JSON.stringify(val)
+ })
+ );
+ });
+
+ return lf.offlineDB.
+ insertOrReplace().
+ into(table).
+ values(rlist).
+ exec();
+ });
+ }
+
+ service.getSettingsCache = function (settings) {
+ return connectOrGo(function() {
+
+ var table = lf.offlineDB.getSchema().table('Setting');
+
+ var search_pred = table.name.isNotNull();
+ if (settings && settings.length) {
+ search_pred = table.name.in(settings);
+ }
+
+ return lf.offlineDB.
+ select(table.name, table.value).
+ from(table).
+ where(search_pred).
+ exec().then(function(list) {
+ angular.forEach(list, function (s) {
+ s.value = JSON.parse(s.value)
+ });
+ return $q.when(list);
+ });
+ });
+ }
+
+ service.setListInOfflineCache = function (type, list) {
+ if (lf.isOffline) return $q.when();
+
+ return connectOrGo(function() {
+
+ service.isCacheGood(type).then(function(good) {
+ if (!good) {
+ var object = lf.offlineDB.getSchema().table('Object');
+ var cacheDate = lf.offlineDB.getSchema().table('CacheDate');
+ var pkey = egCore.idl.classes[type].pkey;
+
+ angular.forEach(list, function(item) {
+ var row = object.createRow({
+ type : type,
+ id : '' + item[pkey](),
+ object : egCore.idl.toHash(item)
+ });
+ lf.offlineDB.insertOrReplace().into(object).values([row]).exec();
+ });
+
+ var row = cacheDate.createRow({
+ type : type,
+ cachedate : new Date()
+ });
+
+ console.log('egLovefield saving ' + type + ' list');
+ lf.offlineDB.insertOrReplace().into(cacheDate).values([row]).exec();
+ }
+ })
+ });
+ }
+
+ service.getListFromOfflineCache = function(type) {
+ return connectOrGo(function() {
+
+ var object = lf.offlineDB.getSchema().table('Object');
+
+ return lf.offlineDB.
+ select(object.object).
+ from(object).
+ where(object.type.eq(type)).
+ exec().then(function(results) {
+ return $q.when(results.map(function(item) {
+ return egCore.idl.fromHash(type,item['object'])
+ }));
+ });
+ });
+ }
+
+ service.reconstituteList = function(type) {
+ if (lf.isOffline) {
+ console.log('egLovefield reading ' + type + ' list');
+ return service.getListFromOfflineCache(type).then(function (list) {
+ egCore.env.absorbList(list, type, true)
+ return $q.when(true);
+ });
+ }
+ return $q.when(false);
+ }
+
+ service.reconstituteTree = function(type) {
+ if (lf.isOffline) {
+ console.log('egLovefield reading ' + type + ' tree');
+
+ var pkey = egCore.idl.classes[type].pkey;
+ var parent_field = 'parent';
+
+ if (type == 'aou') {
+ parent_field = 'parent_ou';
+ }
+
+ return service.getListFromOfflineCache(type).then(function (list) {
+ var hash = {};
+ var top = null;
+ angular.forEach(list, function (item) {
+
+ // Special case for aou, to reconstitue ou_type
+ if (type == 'aou') {
+ if (item.ou_type()) {
+ item.ou_type( egCore.idl.fromHash('aout', item.ou_type()) );
+ }
+ }
+
+ hash[''+item[pkey]()] = item;
+ if (!item[parent_field]()) {
+ top = item;
+ } else if (angular.isObject(item[parent_field]())) {
+ // un-objectify the parent
+ item[parent_field](
+ item[parent_field]()[pkey]()
+ );
+ }
+ });
+
+ angular.forEach(list, function (item) {
+ item.children([]); // just clear it out if there's junk in there
+
+ if (item[parent_field]()) {
+ item[parent_field]( hash[''+item[parent_field]()] );
+ }
+
+ item.children( list.filter(function (kid) {
+ return kid[parent_field]() == item[pkey]();
+ }) );
+ });
+
+ egCore.env.absorbTree(top, type, true)
+ return $q.when(true)
+ });
+ }
+ return $q.when(false);
+ }
+
+ return service;
+}]);
+
restrict : 'AE',
transclude : true,
templateUrl : 'eg-navbar-template',
- link : function(scope, element, attrs) {
+ controller:['$scope','$window','$location','$timeout','hotkeys','$rootScope',
+ 'egCore','$uibModal','ngToast','egOpChange','$element',
+ function($scope , $window , $location , $timeout , hotkeys , $rootScope ,
+ egCore , $uibModal , ngToast , egOpChange , $element) {
- // 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'),
- elm
- );
- }
- angular.forEach(elm.children(), inspect);
- }
- inspect(element);
- },
-
- controller:['$scope','$window','$location','$timeout','hotkeys',
- 'egCore','$uibModal','ngToast','egOpChange',
- function($scope , $window , $location , $timeout , hotkeys ,
- egCore , $uibModal , ngToast, egOpChange) {
+ $scope.rs = $rootScope;
$scope.reprintLast = function (e) {
egCore.print.reprintLast();
return e.preventDefault();
}
- function navTo(path) {
- // Strip the leading "./" if any.
+ function navTo(path) {
path = path.replace(/^\.\//,'');
- var reg = new RegExp($location.path());
$window.location.href = egCore.env.basePath + path;
}
// adds a keyboard shortcut
// http://chieffancypants.github.io/angular-hotkeys/
- $scope.addHotkey = function(key, path, desc, elm) {
+ $scope.addHotkey = function(key, path, desc, elm) {
angular.forEach(key.split(' '), function (k) {
hotkeys.add({
combo: k,
description: desc,
callback: function(e) {
e.preventDefault();
- if (path) return navTo(path);
+ if (path) return navTo(path,route);
return $timeout(function(){$(elm).trigger('click')});
}
});
});
};
+ 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'),
+ elm
+ );
+ }
+ angular.forEach(elm.children(), inspect);
+ }
+ $timeout(function(){inspect($element)});
+
$scope.retrieveLastRecord = function() {
var last_record = egCore.hatch.getLocalItem("eg.cat.last_record_retrieved");
if (last_record) {
angular.module('egCoreMod')
.factory('egOrg',
- ['$q','egEnv','egAuth','egNet',
-function($q, egEnv, egAuth, egNet) {
+ ['$q','egEnv','egAuth','egNet','$injector',
+function($q, egEnv, egAuth, egNet , $injector) {
var service = {};
return list;
}
+ var egLovefield = null;
// 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) {
+ if (!egLovefield) {
+ egLovefield = $injector.get('egLovefield');
+ }
+
+ // allow non-array
+ if (!angular.isArray(names)) names = [names];
+
+ if (lf.isOffline) {
+ return egLovefield.getSettingsCache(names)
+ .then(function(settings) {
+ var hash = {};
+ angular.forEach(settings, function (s) {
+ hash[s.name] = s.value;
+ });
+ return $q.when(hash);
+ });
+ }
+
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.
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 egLovefield.setSettingsCache(settings).then(function() {
+ // 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;
}
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()));
+
+ if (!lf.isOffline) {
+ scope.staff = egIDL.toHash(egAuth.user());
+ scope.current_location =
+ egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+ }
return service.fetch_includes(scope);
}
// returns true if we are staying on the current page
// false if we are redirecting to login
service.expiredAuthHandler = function() {
+ if (lf.isOffline) return true; // Only set by the offline UI
+
console.debug('egStartup.expiredAuthHandler()');
egAuth.logout(); // clean up
+ '</ul>'
+ '</div>',
- controller : ['$scope','$timeout','egCore','egStartup',
- function($scope , $timeout , egCore , egStartup) {
+ controller : ['$scope','$timeout','egCore','egStartup','egLovefield','$q',
+ function($scope , $timeout , egCore , egStartup , egLovefield , $q) {
if ($scope.alldisabled) {
$scope.disable_button = $scope.alldisabled == 'true' ? true : false;
//
// controller() runs before link().
// This post-startup code runs after link().
- egStartup.go().then(function() {
-
- $scope.orgList = egCore.org.list().map(function(org) {
- return {
- id : org.id(),
- shortname : org.shortname(),
- depth : org.ou_type().depth()
+ egStartup.go(
+ ).then(
+ function() {
+ return egCore.env.classLoaders.aou();
+ }
+ ).then(
+ function() {
+
+ $scope.orgList = egCore.org.list().map(function(org) {
+ return {
+ id : org.id(),
+ shortname : org.shortname(),
+ depth : org.ou_type().depth()
+ }
+ });
+
+
+ // Apply default values
+
+ if ($scope.stickySetting) {
+ var orgId = egCore.hatch.getLocalItem($scope.stickySetting);
+ if (orgId) {
+ $scope.selected = egCore.org.get(orgId);
+ }
}
- });
-
- // Apply default values
-
- if ($scope.stickySetting) {
- var orgId = egCore.hatch.getLocalItem($scope.stickySetting);
- if (orgId) {
- $scope.selected = egCore.org.get(orgId);
+
+ if (!$scope.selected && !$scope.nodefault && egCore.auth.user()) {
+ $scope.selected =
+ egCore.org.get(egCore.auth.user().ws_ou());
}
+
+ fire_orgsel_onchange(); // no-op if nothing is selected
}
-
- if (!$scope.selected && !$scope.nodefault) {
- $scope.selected =
- egCore.org.get(egCore.auth.user().ws_ou());
- }
-
- fire_orgsel_onchange(); // no-op if nothing is selected
- });
+ );
/**
* Fire onchange handler after a timeout, so the
}
})
+.directive('nextOnEnter', function () {
+ return function (scope, element, attrs) {
+ element.bind("keydown keypress", function (event) {
+ if(event.which === 13) {
+ $('#'+attrs.nextOnEnter).focus();
+ event.preventDefault();
+ }
+ });
+ };
+})
+
/* http://eric.sau.pe/angularjs-detect-enter-key-ngenter/ */
.directive('egEnter', function () {
return function (scope, element, attrs) {
function(egStrings, egCore) {
return {
scope : {
+ id : '@',
closeText : '@',
ngModel : '=',
ngChange : '=',
ngBlur : '=',
+ minDate : '=?',
+ maxDate : '=?',
ngDisabled : '=',
ngRequired : '=',
hideDatePicker : '=',
- dateFormat : '=?'
+ dateFormat : '=?',
+ outOfRange : '=?'
},
require: 'ngModel',
templateUrl: './share/t_datetime',
replace: true,
+ controller : ['$scope', function($scope) {
+ $scope.options = {
+ minDate : $scope.minDate,
+ maxDate : $scope.maxDate
+ };
+
+ var maxDateObj = $scope.maxDate ? new Date($scope.maxDate) : null;
+ var minDateObj = $scope.minDate ? new Date($scope.minDate) : null;
+
+ if ($scope.outOfRange !== undefined && (maxDateObj || minDateObj)) {
+ $scope.$watch('ngModel', function (n,o) {
+ if (n && n != o) {
+ var bad = false;
+ var newdate = new Date(n);
+ if (maxDateObj && newdate.getTime() > maxDateObj.getTime()) bad = true;
+ if (minDateObj && newdate.getTime() < minDateObj.getTime()) bad = true;
+ $scope.outOfRange = bad;
+ }
+ });
+ }
+ }],
link : function(scope, elm, attrs) {
if (!scope.closeText)
scope.closeText = egStrings.EG_DATE_INPUT_CLOSE_TEXT;
--- /dev/null
+//! UpUp
+//! version : 0.3.0
+//! author : Tal Ater @TalAter
+//! license : MIT
+//! https://github.com/TalAter/UpUp
+(function(a){"use strict";var b=this,c=navigator.serviceWorker;if(!c)return b.UpUp=null,a;var d={"service-worker-url":"upup.sw.min.js"},e=!1;b.UpUp={start:function(a){this.addSettings(a),c.register(d["service-worker-url"],{scope:"./"}).then(function(a){e&&console.log("Service worker registration successful with scope: %c"+a.scope,"font-weight: bold; color: #00f;"),(a.installing||c.controller).postMessage({action:"set-settings",settings:d})}).catch(function(a){e&&console.log("Service worker registration failed: %c"+a,"font-weight: bold; color: #00f;")})},addSettings:function(b){b=b||{},"string"==typeof b&&(b={content:b}),["content","content-url","assets","service-worker-url","cache-version"].forEach(function(c){b[c]!==a&&(d[c]=b[c])})},debug:function(a){e=!(arguments.length>0)||!!a}}}).call(this);
+//# sourceMappingURL=upup.min.js.map
\ No newline at end of file
--- /dev/null
+//! UpUp Service Worker
+//! version : 0.3.0
+//! author : Tal Ater @TalAter
+//! license : MIT
+//! https://github.com/TalAter/UpUp
+var _CACHE_NAME_PREFIX="upup-cache",_calculateHash=function(a){a=a.toString();var b,c,d=0,e=a.length;if(0===e)return d;for(b=0;b<e;b++)c=a.charCodeAt(b),d=(d<<5)-d+c,d|=0;return d};self.addEventListener("message",function(a){"set-settings"===a.data.action&&_parseSettingsAndCache(a.data.settings)}),self.addEventListener("fetch",function(a){a.respondWith(fetch(a.request).catch(function(){return caches.match(a.request).then(function(b){return b||("navigate"===a.request.mode||"GET"===a.request.method&&a.request.headers.get("accept").includes("text/html")?caches.match("sw-offline-content"):void 0)})}))});var _parseSettingsAndCache=function(a){var b=_CACHE_NAME_PREFIX+"-"+(a["cache-version"]?a["cache-version"]+"-":"")+_calculateHash(a.content+a["content-url"]+a.assets);return caches.open(b).then(function(b){return a.assets&&b.addAll(a.assets.map(function(a){return new Request(a,{mode:"no-cors"})})),a["content-url"]?fetch(a["content-url"],{mode:"no-cors"}).then(function(a){return b.put("sw-offline-content",a)}):a.content?b.put("sw-offline-content",_buildResponse(a.content)):b.put("sw-offline-content",_buildResponse("You are offline"))}).then(function(){return caches.keys().then(function(a){return Promise.all(a.map(function(a){if(a.startsWith(_CACHE_NAME_PREFIX)&&b!==a)return caches.delete(a)}))})})},_buildResponse=function(a){return new Response(a,{headers:{"Content-Type":"text/html"}})};
+//# sourceMappingURL=upup.sw.min.js.map
\ No newline at end of file