]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/ui/default/staff/offline.js
LP2061136 - Stamping 1405 DB upgrade script
[working/Evergreen.git] / Open-ILS / web / js / ui / default / staff / offline.js
1 /**
2  * App to drive the offline UI
3  */
4
5 lf.isOffline = true;
6
7 angular.module('egOffline', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'ngToast', 'tableSort'])
8
9 .config(
10        ['$routeProvider','$locationProvider','$compileProvider',
11 function($routeProvider , $locationProvider , $compileProvider) {
12
13     $locationProvider.html5Mode(true);
14     $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|mailto|blob):/);
15
16     /**
17      * Route resolvers allow us to run async commands
18      * before the page controller is instantiated.
19      */
20     var resolver = {delay : ['egCore', 'egLovefield',
21         function(egCore, egLovefield) {
22             // the 'offline' schema is only active in the offline UI.
23             egLovefield.activeSchemas.push('offline');
24             return egCore.startup.go();
25         }
26     ]};
27
28     $routeProvider.when('/offline-interface/:tab', {
29         templateUrl: 'offline-template',
30         controller: 'OfflineCtrl',
31         resolve : resolver
32     });
33
34     // default page 
35     $routeProvider.otherwise({
36         templateUrl : 'offline-template',
37         controller : 'OfflineCtrl',
38         resolve : resolver
39     });
40 }])
41
42 .controller('OfflineSessionCtrl', 
43            ['$scope','$window','egCore','$routeParams','$http','$q','$timeout','egPromptDialog','ngToast','egProgressDialog',
44     function($scope , $window , egCore , $routeParams , $http , $q , $timeout , egPromptDialog , ngToast , egProgressDialog) {
45         $scope.active_session_tab = 'pending';
46
47         $scope.lookupNoncatTypeName = function (type) {
48             var nc =  $scope.noncats.filter(function(n){ return n.id() == type })[0];
49             if (nc) return nc.name();
50             return '';
51         }
52
53         $scope.createDate = function (ts, epoch) {
54             if (!ts) return '';
55             if (epoch) ts = ts * 1000;
56             return new Date(ts);
57         }
58
59         $scope.setSession = function (s, ind) {
60             $scope.current_session = s;
61             $scope.current_session_index = ind;
62
63             return $scope.refreshExceptions(s);
64         }
65
66         $scope.createSession = function () {
67
68             return egPromptDialog.open(
69                 egCore.strings.OFFLINE_SESSION_DESC, '',
70                 {ok : function(value) {
71                     if (value) {
72
73                         return $http.get(formURL({action:'create',desc:value})).then(function(res) {
74                             if (res.data.ilsevent == "0") return $q.when(res.data.payload);
75                             return $q.reject();
76                         }).then(function (seskey) {
77                             return $scope.refreshSessions().then(function() {
78                                 if (seskey) {
79                                     var s = $scope.sessions.filter(function(s){ s.key == seskey })[0];
80                                     var ind = $scope.sessions.length - 1; // sorted by create time, so new one is last
81                                     return $scope.setSession(s, ind);
82                                 }
83                             });
84                         }, function() {
85                             ngToast.warning(egCore.strings.OFFLINE_SESSION_CREATE_FAILED);
86                         });
87                     }
88                 }}
89             );
90         }
91
92         $scope.processSession = function (s, ind) {
93             return $scope.setSession(s, ind).then(function() {
94                 egProgressDialog.open();
95
96                 return $http.get(
97                     formURL({action:'execute',seskey:$scope.current_session.key})
98                 ).then(function(res) {
99                     if (res.data.ilsevent == "0") return $q.when(res.data.payload);
100                     return $q.reject();
101                 }).then(function () {
102                     egProgressDialog.close();
103                     return $scope.refreshSessions()
104                         .then(function(){ return $scope.refreshExceptions(s) });
105                 },function () {
106                     egProgressDialog.close();
107                     return $scope.refreshSessions().then(function() {
108                         ngToast.warning(egCore.strings.OFFLINE_SESSION_PROCESSING_FAILED);
109                     });
110                 });
111             });
112         }
113
114         $scope.refreshExceptions = function (s) {
115             return $http.get(
116                 formURL({
117                     action      : 'status',
118                     status_type : 'exceptions',
119                     seskey      : s.key
120                 })
121             ).then(function(res) {
122                 if (res.data.ilsevent) {
123                     $scope.current_session.exceptions = [];
124                 } else {
125                     $scope.current_session.exceptions = res.data;
126                 }
127                 return $q.when();
128             });
129         }
130
131         $scope.sessions = [];
132         $scope.refreshSessions = function () {
133
134             return $http.get(formURL({action:'status',status_type:'sessions'})).then(function(res) {
135                 if (angular.isArray(res.data)) {
136                     $scope.sessions = res.data;
137                     return $q.when();
138                 }
139                 return $q.reject();
140             }).then(function() {
141                 var creator_list = [$q.when()];
142                 angular.forEach($scope.sessions, function (s) {
143                     s.total = 0;
144                     s.org = egCore.org.get(s.org).shortname();
145                     creator_list.push(egCore.pcrud.retrieve('au',s.creator).then(function(u) {
146                         s.creator = u.family_name();
147                     }));
148                     angular.forEach(s.scripts, function(sc) {
149                         s.total += sc.count;
150                     });
151                 });
152
153                 return $q.all(creator_list);
154             });
155         }
156
157         $scope.reprintLast = function () {
158             egCore.print.reprintLast();
159         }
160
161
162         $scope.uploadPending = function (s, ind) {
163             return $scope.setSession(s, ind).then(function() {
164
165                 egProgressDialog.open();
166                 return $scope.createOfflineXactBlob().then(function(blob) {
167
168                     var form = new FormData();
169                     form.append("ses", egCore.auth.token());
170                     form.append("org", $scope.org.id());
171                     form.append("ws", $scope.current_workstation_name());
172                     form.append("wc", 1);
173                     form.append("action", "load");
174                     form.append("seskey", $scope.current_session.key);
175                     form.append("file", blob, "file");
176
177                     return $http.post(
178                         '/cgi-bin/offline/offline.pl?' + new Date().getTime(),
179                         form,
180                         {
181                             transformRequest: angular.identity,
182                             headers: {'Content-Type': undefined}
183                         }
184                     ).then(function(res) {
185                         egProgressDialog.close();
186                         if (res.data.ilsevent == "0") {
187                             return $scope.clear_pending(true).then(function() {
188                                 return $scope.refreshSessions();
189                             });
190                         } else {
191                             ngToast.warning(egCore.strings.OFFLINE_SESSION_UPLOAD_FAILED);
192                             return $scope.refreshSessions();
193                         }
194                     },function () { egProgressDialog.close() });
195                 });
196             });
197         }
198
199         $scope.retrieveDetails = function (x) {
200             alert(JSON.stringify(x, null, 2)); // egAlertDialog kills pretty printing
201         }
202
203         $scope.retrieveItem = function (bc) {
204             return egCore.pcrud.search('acp',{deleted: 'f', barcode: bc}).then(function(copy) {
205                 if (copy) {
206                     return $window.open(
207                         egCore.env.basePath +
208                         '/cat/item/' + copy.id(),
209                         '_blank'
210                     ).focus();
211                 }
212
213                 ngToast.warning(egCore.strings.ITEM_NOT_FOUND);
214             });
215         }
216
217         $scope.retrievePatron = function (bc) {
218             return egCore.pcrud.search('ac',{barcode: bc}).then(function(card) {
219                 if (card) {
220                     return $window.open(
221                         egCore.env.basePath +
222                         '/circ/patron/' + card.usr() + '/checkout',
223                         '_blank'
224                     ).focus();
225                 }
226
227                 ngToast.warning(egCore.strings.PATRON_NOT_FOUND);
228             });
229         }
230
231         function formURL (params) {
232             var url = '/cgi-bin/offline/offline.pl?' + new Date().getTime();
233
234             var defaults = {
235                 org : $scope.org ? $scope.org.id() : null,
236                 ws  : $scope.current_workstation_name(),
237                 wc  : 1,
238                 ses : egCore.auth.token()
239             }
240
241             angular.extend(params, defaults)
242
243             var first = true;
244             for (var k in params) {
245                 url += '&' + k + '=' + window.encodeURIComponent(params[k]);
246             }
247             return url;
248         }
249
250         $scope.$watch('org',function(n){if (n) $scope.refreshSessions()});
251
252     }
253 ])
254
255 .controller('OfflineCtrl', 
256            ['$q','$scope','$window','$location','$rootScope','egCore',
257             'egLovefield','$routeParams','$timeout','$http','ngToast',
258             'egConfirmDialog','egUnloadPrompt','egProgressDialog', '$filter',
259     function($q , $scope , $window , $location , $rootScope , egCore , 
260              egLovefield , $routeParams , $timeout , $http , ngToast , 
261              egConfirmDialog , egUnloadPrompt, egProgressDialog, $filter) {
262
263         // Immediately redirect if we're really offline
264         if (!$window.navigator.onLine) {
265             if ($location.path().match(/session$/)) {
266                 var path = $location.path();
267                 console.log('internal redirect');
268                 return $location.path(path.replace('session','checkout'));
269             }
270         }
271
272         var today = new Date();
273         today.setHours(0);
274         today.setMinutes(0);
275         today.setSeconds(0);
276         today.setMilliseconds(0);
277
278         $scope.minDate = today;
279         $scope.blocked_patron = null;
280         $scope.bad_barcode = null;
281         $scope.barcode_type = 'barcode';
282         $scope.focusMe = true;
283         $scope.shared = { outOfRange : false, due_date : null, due_date_offset : '' };
284         $scope.workstation_obj = null;
285         $scope.workstation = '';
286         $scope.workstation_owner = '';
287         $scope.workstations = [];
288         $scope.org = null;
289         $scope.do_print = Boolean($scope.active_tab == 'checkout');
290         $scope.do_print_changed = false;
291         $scope.printed = false;
292         $scope.imported_pending_xacts = { data : '' };
293
294         $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
295         $scope.all_xact = [];
296         $scope.noncats = [];
297
298         $scope.checkout = { noncat_type : '' };
299         $scope.renew = { noncat_type : '' };
300         $scope.in_house_use = {count : 1};
301         $scope.checkin = { backdate : new Date() };
302
303         egLovefield.getOfflineBlockDate().then(
304             function(blockListDateResp) {
305                 if (blockListDateResp) {
306                     $scope.blockListDate =
307                         Math.round(blockListDateResp.getTime() / 1000);
308                 }
309             },
310             function() {
311                 console.error("Error when retrieving block list download date");
312             }
313         );
314
315         $scope.current_workstation_owning_lib = function () {
316             return $scope.workstations.filter(function(w) {
317                 return $scope.workstation == w.id
318             })[0].owning_lib;
319         }
320
321         $scope.current_workstation_name = function () {
322             return $scope.workstations.filter(function(w) {
323                 return $scope.workstation == w.id
324             })[0].name;
325         }
326
327         $scope.$watch('workstation', function (n,o) {
328             if (egCore.env.aou)
329                 $scope.org = egCore.org.get($scope.current_workstation_owning_lib());
330         });
331
332         $scope.changeCheck = function () {
333             $scope.strict_barcode = !$scope.strict_barcode;
334             $scope.do_check_changed = true;
335             egCore.hatch.setItem('eg.offline.strict_barcode', $scope.strict_barcode)
336         }
337
338         $scope.changePrint = function () {
339             $scope.do_print = !$scope.do_print;
340             $scope.do_print_changed = true;
341             egCore.hatch.setItem('eg.offline.print_receipt', $scope.do_print)
342         }
343
344         $scope.lookupNoncatTypeName = function (type) {
345             var nc =  $scope.noncats.filter(function(n){ return n.id() == type })[0];
346             if (nc) return nc.name();
347             return '';
348         }
349
350         $scope.logged_in = egCore.auth.token() ? true : false;
351
352
353         $scope.active_tab = $routeParams.tab;
354         $timeout(function(){
355             if (!$scope.logged_in) {
356                 $scope.active_tab = 'checkout';
357             } else {
358                 $scope.active_tab = 'session';
359             }
360         });
361         
362         egCore.hatch.getItem('eg.offline.print_receipt')
363         .then(function(setting) {
364             $scope.do_print = setting;
365             if (setting !== undefined) $scope.do_print_changed = true;
366         });
367
368         egCore.hatch.getItem('eg.offline.strict_barcode')
369         .then(function(setting) {
370             $scope.strict_barcode = setting;
371             if (setting !== undefined) $scope.do_check_changed = true;
372         });
373
374         egCore.hatch.getWorkstations()
375         .then(function(all) {
376             if (all && all.length) {
377                 $scope.workstations = all;
378
379                 if (ws = $location.search().ws) {
380                     // user requested a workstation via URL
381                     var match = all.filter(
382                         function(w) {return ws == w.name} )[0];
383
384                     if (match) {
385                         // requested WS registered on this client
386                         $scope.workstation_obj = match;
387                         $scope.workstation = match.id;
388                         $scope.workstation_owner = match.owning_lib;
389                     } else {
390                         // the requested WS is not registered on this client
391                         $scope.wsNotRegistered = true;
392                     }
393                 } else {
394                     // no workstation requested; use the default
395                     egCore.hatch.getDefaultWorkstation()
396                     .then(function(ws) {
397                         var ws_obj = all.filter(function(w) {
398                             return ws == w.name
399                         })[0];
400
401                         $scope.workstation_obj = ws_obj;
402                         $scope.workstation = ws_obj.id;
403                         $scope.workstation_owner = ws_obj.owning_lib;
404
405                         return egLovefield.reconstituteList('cnct').then(function () {
406                             $scope.noncats = egCore.env.cnct.list;
407                         });
408                     });
409                 }
410             } 
411         });
412
413         $scope.buildingBlockList = false;
414         $scope.downloadBlockList = function () {
415             $scope.buildingBlockList = true;
416             egProgressDialog.open();
417             egLovefield.populateBlockList().then(
418                 function(){
419                     egLovefield.setOfflineBlockDate();
420                     ngToast.create(egCore.strings.OFFLINE_BLOCKLIST_SUCCESS);
421                 },
422                 function(){
423                     ngToast.warning(egCore.strings.OFFLINE_BLOCKLIST_FAIL);
424                     egCore.audio.play('warning.offline.blocklist_fail');
425                 }
426             )['finally'](function() {
427                 $scope.buildingBlockList = false;
428                 egProgressDialog.close();
429             });
430         }
431
432         $scope.createOfflineXactBlob = function () {
433             return egLovefield.retrievePendingOfflineXacts().then(function(list) {
434                 var flat_list = [];
435                 angular.forEach(list, function (i) {
436                     flat_list.push(JSON.stringify(i) + '\n');
437                 });
438
439                 var blob = new Blob(flat_list, {type: 'text/plain'});
440
441                 return $q.when(blob)
442             });
443         }
444
445         $scope.pending_xacts = [];
446         $scope.retrieve_pending = function () {
447             return egLovefield.retrievePendingOfflineXacts().then(function(list) {
448                 $scope.pending_xacts = list;
449                 return $q.when(list);
450             });
451         }
452
453         $scope.save = function () {
454             var promises = [$q.when()];
455             angular.forEach($scope.all_xact, function (x) {
456                 promises.push(egLovefield.addOfflineXact(x));
457             });
458
459             var prints = [$q.when()];
460             if ($scope.do_print) {
461                 angular.forEach(['checkin','checkout','renew','in_house_use'], function(xtype) {
462                     if ($scope.xact_page[xtype].length > 0) {
463                         prints.push(egCore.print.print({
464                             context : 'offline', 
465                             template : 'offline_'+xtype,
466                             scope : {
467                                 transactions    : $scope.xact_page[xtype]
468                             }
469                         }));
470                     }
471                 });
472             }
473
474             return $q.all(promises.concat(prints)).finally(function() {
475                 egUnloadPrompt.clear();
476                 if (prints.length > 1) $scope.printed = true;
477                 $scope.all_xact = [];
478                 $scope.xact_page = { checkin:[], checkout:[], renew:[], in_house_use:[] };
479                 angular.forEach(['checkout','renew'], function (xtype) {
480                     $scope[xtype].patron_barcode = '';
481                 });
482                 $scope.retrieve_pending();
483             });
484         }
485
486         $rootScope.save_offline_xacts = function () { return $scope.save() };
487         $rootScope.active_tab = function (t) { $scope.active_tab = t };
488
489         $scope.logout = function () {
490             egCore.auth.logout();
491             $window.location.href = location.href;
492         }
493
494         $scope.clear_pending = function (skip_confirm) {
495             if (skip_confirm) {
496                 return egLovefield.destroyPendingOfflineXacts().then(function () {
497                     return $scope.retrieve_pending();
498                 });
499             }
500             return egConfirmDialog.open(
501                 egCore.strings.CONFIRM_CLEAR_PENDING,
502                 egCore.strings.CONFIRM_CLEAR_PENDING_BODY,
503                 {}
504             ).result.then(function() {
505                 return egLovefield.destroyPendingOfflineXacts().then(function () {
506                     return $scope.retrieve_pending();
507                 });
508             });
509
510         }
511
512         $scope.retrieve_pending();
513         $scope.$watch('active_tab', function (n,o) {
514             console.log('watch caught change to active_tab: ' + o + ' -> ' + n);
515             if (n != o && !$scope.do_check_changed && n != 'checkout') $scope.strict_barcode = false;
516             if (n != o && !$scope.do_check_changed && n == 'checkout') $scope.strict_barcode = true;
517             if (n != o && !$scope.do_print_changed && n != 'checkout') $scope.do_print = false;
518             if (n != o && !$scope.do_print_changed && n == 'checkout') $scope.do_print = true;
519             if (n != o && n == 'session') $scope.retrieve_pending();
520         });
521
522         $scope.$watch('imported_pending_xacts.data', function (n, o) {
523             if (n != 0) {
524                 var lines = n.split('\n');
525                 var promises = [];
526
527                 angular.forEach(lines, function (l) {
528                     if (!l) return;
529
530                     try {
531                         promises.push(
532                             egLovefield.addOfflineXact(JSON.parse(l))
533                         );
534                     } catch (err) {
535                         ngToast.warning(err);
536                     }
537                 });
538
539                 $q.all(promises).then(function () { $scope.retrieve_pending() });
540             }
541         });
542
543         $scope.resetDueDate = function (xtype) {
544             $scope.shared.due_date = new Date();
545             $scope.shared.due_date.setDate($scope.shared.due_date.getDate() + parseInt($scope.shared.due_date_offset));
546         }
547
548         $scope.notEnough = function (xtype) {
549
550             if (xtype == 'checkout') {
551                 if ($scope.shared.outOfRange) return true;
552                 if (
553                     $scope.checkout.patron_barcode &&
554                     ($scope.shared.due_date || $scope.shared.due_date_offset) &&
555                     ($scope.checkout.barcode || ($scope.checkout.noncat_type && $scope.checkout.noncat_count))
556                 ) return false;
557                 return true;
558             }
559
560             if (xtype == 'renew') {
561                 if ($scope.shared.outOfRange) return true;
562                 if (
563                     $scope.renew.barcode &&
564                     ($scope.shared.due_date || $scope.shared.due_date_offset)
565                 ) return false;
566                 return true;
567             }
568
569             if (xtype == 'in_house_use') {
570                 if (
571                     $scope.in_house_use.barcode && $scope.in_house_use.count
572                 ) return false;
573                 return true;
574             }
575
576             if (xtype == 'checkin') {
577                 if (
578                     $scope.checkin.barcode && $scope.checkin.backdate
579                 ) return false;
580                 return true;
581             }
582         }
583
584         $scope.clear = function (xtype) {
585             $scope[xtype] = {};
586             if (xtype=="in_house_use") $scope[xtype].count = 1;
587         }
588
589         $scope.add = function (xtype,next_focus) {
590
591             var barcode = $scope[xtype].barcode;
592             if (barcode) {
593                 if ($scope.xact_page[xtype].filter(function(x){ return x.barcode == barcode }).length > 0) {
594                     ngToast.warning(egCore.strings.DUPLICATE_BARCODE);
595                     egCore.audio.play('warning.offline.duplicate_barcode');
596                     $scope[xtype].barcode = '';
597                     if (next_focus) $('#'+next_focus).focus();
598                     return;
599                 }
600             }
601
602             var pbarcode = $scope[xtype].patron_barcode;
603             if (pbarcode) {
604                 egLovefield.testOfflineBlock(pbarcode).then(function (blocked) {
605                     if (blocked) {
606                         egCore.audio.play('warning.offline.blocked_patron');
607                         var default_format = 'mediumDate';
608                         egCore.org.settings(['format.date']).then(function(set) {
609                             if (set && set['format.date']) default_format = set['format.date'];
610                             $scope.date_format = default_format;
611                             var fBlockListDate = $scope.blockListDate ?
612                                 $filter('date')(($scope.blockListDate * 1000), $scope.date_format) :
613                                 null;
614                             egConfirmDialog.open(
615                                 egCore.strings.PATRON_BLOCKED,
616                                 egCore.strings.PATRON_BLOCKED_WHY[blocked],
617                                 {formatted_date: fBlockListDate, pbarcode: pbarcode},
618                                 egCore.strings.ALLOW, 
619                                 egCore.strings.REJECT
620                             ).result.then(
621                                 function(){ // forced
622                                     $scope.blocked_patron = null;
623                                     _add_impl(xtype,true)
624                                     if (next_focus) $('#'+next_focus).focus();
625                                 },function(){ // stopped
626                                     $scope.blocked_patron = xtype;
627                                     if (next_focus) $('#'+next_focus).focus();
628                                     return;
629                                 }
630                             );
631                         });
632                     } else {
633                         $scope.blocked_patron = null;
634                         _add_impl(xtype,true)
635                         if (next_focus) $('#'+next_focus).focus();
636                     }
637                 });
638             } else {
639                 _add_impl(xtype);
640                 if (next_focus) $('#'+next_focus).focus();
641             }
642         }
643
644         function _add_impl (xtype,digest) {
645             var pbarcode = $scope[xtype].patron_barcode;
646             var backdate = $scope[xtype].backdate;
647
648             if ($scope.strict_barcode && pbarcode) {
649                 if (!check_barcode(pbarcode)) {
650                     $scope.bad_barcode = xtype;
651                     egCore.audio.play('warning.offline.bad_barcode');
652                     return egConfirmDialog.open(
653                         egCore.strings.BAD_PATRON_BARCODE,
654                         egCore.strings.BAD_PATRON_BARCODE_CD,
655                         {}, egCore.strings.ALLOW, egCore.strings.REJECT
656                     ).result.then(
657                         function(){ // forced
658                             $scope.blocked_patron = null;
659                             return _add_impl2(xtype,digest)
660                         },function(){ // stopped
661                             $scope.blocked_patron = xtype;
662                         }
663                     );
664                 }
665             }
666
667             if ($scope.strict_barcode && $scope[xtype].barcode) {
668                 if (!check_barcode($scope[xtype].barcode)) {
669                     $scope.bad_barcode = xtype;
670                     egCore.audio.play('warning.offline.bad_barcode');
671                     return egConfirmDialog.open(
672                         egCore.strings.BAD_BARCODE,
673                         egCore.strings.BAD_BARCODE_CD,
674                         {}, egCore.strings.ALLOW, egCore.strings.REJECT
675                     ).result.then(
676                         function(){ // forced
677                             $scope.blocked_patron = null;
678                             return _add_impl2(xtype,digest)
679                         },function(){ // stopped
680                             $scope.blocked_patron = xtype;
681                         }
682                     );
683                 }
684             }
685
686             return _add_impl2(xtype,digest);
687         }
688
689         function _add_impl2 (xtype,digest) {
690             var pbarcode = $scope[xtype].patron_barcode;
691             var backdate = $scope[xtype].backdate;
692
693             $scope.bad_barcode = null;
694
695             var now = new Date().getTime();
696             now = now / 1000;
697
698             if ($scope[xtype].noncat_type) $scope[xtype].noncat = 1;
699
700             if ($scope.shared.due_date && (xtype == 'checkout' || xtype == 'renew')) {
701                 $scope[xtype].due_date = $scope.shared.due_date.toISOString();
702                 $scope[xtype].checkout_time = new Date().toISOString();
703             }
704
705             var xact = { timestamp : parseInt(now), type : xtype, delta : 0 };
706
707             $scope.xact_page[xtype].push(
708                 angular.extend(xact, $scope[xtype])
709             );
710
711             $scope.all_xact.push(xact)
712             egUnloadPrompt.attach($rootScope);
713
714             $scope[xtype] = {};
715
716             if (pbarcode) $scope[xtype].patron_barcode = pbarcode;
717             if (backdate) $scope[xtype].backdate = backdate;
718             if (xtype=="in_house_use") $scope[xtype].count = 1;
719
720             if (digest) $timeout(function(){$scope.$apply()});
721         }
722
723         check_barcode = function(bc) {
724             if (bc != Number(bc)) return false;
725             bc = bc.toString();
726             // "16.00" == Number("16.00"), but the . is bad.
727             // Throw out any barcode that isn't just digits
728             if (bc.search(/\D/) != -1) return false;
729             var last_digit = bc.substr(bc.length-1);
730             var stripped_barcode = bc.substr(0,bc.length-1);
731             return barcode_checkdigit(stripped_barcode).toString() == last_digit;
732         }
733     
734         barcode_checkdigit = function(bc) {
735             var reverse_barcode = bc.toString().split('').reverse();
736             var check_sum = 0; var multiplier = 2;
737             for (var i = 0; i < reverse_barcode.length; i++) {
738                 var digit = reverse_barcode[i];
739                 var product = digit * multiplier; product = product.toString();
740                 var temp_sum = 0;
741                 for (var j = 0; j < product.length; j++) {
742                     temp_sum += Number( product[j] );
743                 }
744                 check_sum += Number( temp_sum );
745                 multiplier = ( multiplier == 2 ? 1 : 2 );
746             }
747             check_sum = check_sum.toString();
748             var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10;
749             var check_digit = next_multiple_of_10 - Number(check_sum);
750             if (check_digit == 10) check_digit = 0;
751             return check_digit;
752         }
753
754         function fetch_org_after_tree_exists () {
755             $timeout(function(){
756                 try {
757                     $scope.org = egCore.org.get($scope.current_workstation_owning_lib());
758                 } catch(e) {
759                     fetch_org_after_tree_exists();
760                 }
761             },100);
762         }
763
764         fetch_org_after_tree_exists();
765     }
766 ])
767
768 // dummy service so standalone patron editor can reference it
769 .factory('patronSvc', function() { return { /* dummy */ } })
770
771 .factory('patronRegSvc', ['$q', 'egCore', 'egLovefield', function($q, egCore, egLovefield) {
772
773     egLovefield.isOffline = true;
774
775     var service = {
776         org : null,                // will come from workstation org 
777         field_doc : {},            // config.idl_field_doc
778         profiles : [],             // permission groups
779         edit_profiles : [],        // perm groups we can modify
780         sms_carriers : [],
781         user_settings : {},        // applied user settings
782         user_setting_types : {},   // config.usr_setting_type
783         opt_in_setting_types : {}, // config.usr_setting_type for event-def opt-in
784         surveys : [],
785         survey_questions : {},
786         survey_answers : {},
787         survey_responses : {},     // survey.responses for loaded patron in progress
788         stat_cats : [],
789         stat_cat_entry_maps : {},   // cat.id to selected value
790         virt_id : -1,               // virtual ID for new objects
791         init_done : false           // have we loaded our initialization data?
792     };
793
794     service.offlineMode = function () {
795         return lf.isOffline;
796     }
797
798     // launch a series of parallel data retrieval calls
799     service.init = function(scope) {
800
801         // Data loaded here only needs to be retrieved the first time this
802         // tab becomes active within the current instance of the patron app.
803         // In other words, navigating between patron tabs will not cause
804         // all of this data to be reloaded.  Navigating to a separate app
805         // and returning will cause the data to be reloaded.
806         if (service.init_done) return $q.when();
807         service.init_done = true;
808
809         return $q.all([
810             service.get_field_doc(),
811             service.get_perm_groups(),
812             service.get_ident_types(),
813             service.get_user_settings(),
814             service.get_org_settings(),
815             service.get_stat_cats(),
816             service.get_surveys(),
817             service.get_net_access_levels()
818         ]);
819     };
820
821     service.get_linked_addr_users = function(addrs) {
822         return $q.when();
823     }
824
825     service.apply_secondary_groups = function(user_id, group_ids) {
826         return $q.when(true);
827     }
828
829     // See note above about not loading egUser.
830     // TODO: i18n
831     service.format_name = function(last, first, middle) {
832         return last + ', ' + first + (middle ? ' ' + middle : '');
833     }
834
835     service.check_dupe_username = function(usrname) {
836         return $q.when(false);
837     }
838
839     // determine which user groups our user is not allowed to modify
840     service.set_edit_profiles = function() {
841         service.edit_profiles = egCore.env.pgt.list.filter(
842             function (p) { return p.application_perm() == 'group_application.user.patron' }
843         );
844         return $q.when;
845     }
846
847     // resolves to a hash of perm-name => boolean value indicating
848     // wether the user has the permission at org_id.
849     service.has_perms_for_org = function(org_id) {
850
851         var perms_needed = [
852             'UPDATE_USER',
853             'CREATE_USER',
854             'CREATE_USER_GROUP_LINK', 
855             'UPDATE_PATRON_COLLECTIONS_EXEMPT',
856             'UPDATE_PATRON_CLAIM_RETURN_COUNT',
857             'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
858             'UPDATE_PATRON_ACTIVE_CARD',
859             'UPDATE_PATRON_PRIMARY_CARD'
860         ];
861
862         var hash = {};
863         angular.forEach(perms_needed, function (p) {
864             hash[p] = true;
865         });
866
867         return $q.when(hash);
868     }
869
870     service.get_surveys = function() {
871         return egLovefield.reconstituteList('asv').then(function(offline) {
872             return egLovefield.reconstituteList('asvq')
873                     .then(function(){
874                         return egLovefield.reconstituteList('asva');
875                     }).then(function() {
876                         angular.forEach(egCore.env.asv.list, function (s) {
877                             s.questions( egCore.env.asvq.list.filter( function (q) {
878                                 return q.survey().id == s.id();
879                             }));
880                         });
881
882                         angular.forEach(egCore.env.asvq.list, function (q) {
883                             q.survey( egCore.env.asv.map[ q.survey().id ] );
884                             q.answers( egCore.env.asva.list.filter( function (a) {
885                                 return q.id() == a.question();
886                             }));
887                         });
888
889                         angular.forEach(egCore.env.asva.list, function (a) {
890                             a.question( egCore.env.asvq.map[ a.question().id ] );
891                         });
892
893                         service.surveys = egCore.env.asv.list;
894                         service.survey_questions = egCore.env.asvq.list;
895                         service.survey_answers = egCore.env.asva.list;
896
897                         return $q.when();
898                     });
899         });
900     }
901
902     service.get_stat_cats = function() {
903         return egLovefield.getStatCatsCache().then(
904             function(cats) {
905                 service.stat_cats = cats;
906                 return $q.when();
907             }
908         );
909     };
910
911     service.get_org_settings = function() {
912         return egLovefield.getSettingsCache().then(
913             function (list) {
914                 var hash = {};
915                 angular.forEach(list, function (s) {
916                     hash[s.name] = s.value;
917                 });
918                 service.org_settings = hash;
919                 if (egCore && egCore.env && !egCore.env.aous) {
920                     egCore.env.aous = hash;
921                     console.log('setting egCore.env.aous');
922                 }
923                 return $q.when();
924             }
925         );
926     };
927
928     service.get_ident_types = function() {
929         return egLovefield.reconstituteList('cit').then(function() {
930             service.ident_types = egCore.env.cit.list;
931             return $q.when();
932         });
933     };
934
935     service.get_net_access_levels = function() {
936         return egLovefield.reconstituteList('cnal').then(function() {
937             service.net_access_levels = egCore.env.cnal.list;
938             return $q.when();
939         });
940     }
941
942     service.get_perm_groups = function() {
943         if (egCore.env.pgt) {
944             service.profiles = egCore.env.pgt.list;
945             return service.set_edit_profiles();
946         } else {
947             return egLovefield.reconstituteTree('pgt').then(function(offline) {
948                 service.profiles = egCore.env.pgt.list;
949                 return service.set_edit_profiles();
950             });
951         }
952     }
953
954     service.get_field_doc = function() {
955         return egLovefield.getListFromOfflineCache('fdoc').then(function (list) {
956             angular.forEach(list, function(doc) {
957                 if (!service.field_doc[doc.fm_class()])
958                     service.field_doc[doc.fm_class()] = {};
959                 service.field_doc[doc.fm_class()][doc.field()] = doc;
960             });
961             return $q.when();
962         });
963     };
964
965     service.get_user_settings = function() {
966         var static_types = [
967             'circ.holds_behind_desk', 
968             'circ.collections.exempt', 
969             'opac.hold_notify', 
970             'opac.default_phone', 
971             'opac.default_pickup_location', 
972             'opac.default_sms_carrier', 
973             'opac.default_sms_notify'];
974
975         angular.forEach(static_types, function (t) {
976             service.user_settings[t] = null;
977         });
978
979         return egLovefield.getListFromOfflineCache('cust').then(function (list) {
980             angular.forEach(list, function(stype) {
981                 service.user_setting_types[stype.name()] = stype;
982                 if (static_types.indexOf(stype.name()) == -1) {
983                     service.opt_in_setting_types[stype.name()] = stype;
984                 }
985                 if (stype.reg_default() != undefined) {
986                     service.user_settings[setting.name()] = 
987                         setting.reg_default();
988                 }
989             });
990             return $q.when();
991         });
992     }
993
994     service.invalidate_field = function(patron, field) {
995         return;
996     }
997
998     service.dupe_patron_search = function(patron, type, value) {
999         return $q.when({ search : search, count : 0 });
1000     }
1001
1002     service.init_patron = function(current) {
1003
1004         if (!current)
1005             return service.init_new_patron();
1006
1007         service.patron = current;
1008         return service.init_existing_patron(current)
1009     }
1010
1011     service.ingest_address = function(patron, addr) {
1012         addr.valid = addr.valid == 't';
1013         addr.within_city_limits = addr.within_city_limits == 't';
1014         addr._is_mailing = (patron.mailing_address && 
1015             addr.id == patron.mailing_address.id);
1016         addr._is_billing = (patron.billing_address && 
1017             addr.id == patron.billing_address.id);
1018     }
1019
1020     /*
1021      * Existing patron objects reqire some data munging before insertion
1022      * into the scope.
1023      *
1024      * 1. Turn everything into a hash
1025      * 2. ... Except certain fields (selectors) whose widgets require objects
1026      * 3. Bools must be Boolean, not t/f.
1027      */
1028     service.init_existing_patron = function(current) {
1029
1030         service.existing_patron = current;
1031
1032         var patron = egCore.idl.toHash(current);
1033
1034         patron.home_ou = egCore.org.get(patron.home_ou.id);
1035         patron.expire_date = new Date(Date.parse(patron.expire_date));
1036         patron.dob = service.parse_dob(patron.dob);
1037         patron.profile = current.profile(); // pre-hash version
1038         patron.net_access_level = current.net_access_level();
1039         patron.ident_type = current.ident_type();
1040         patron.groups = current.groups(); // pre-hash
1041
1042         angular.forEach(
1043             ['juvenile', 'barred', 'active', 'master_account'],
1044             function(field) { patron[field] = patron[field] == 't'; }
1045         );
1046
1047         angular.forEach(patron.cards, function(card) {
1048             card.active = card.active == 't';
1049             if (card.id == patron.card.id) {
1050                 patron.card = card;
1051                 card._primary = 'on';
1052             }
1053         });
1054
1055         angular.forEach(patron.addresses, 
1056             function(addr) { service.ingest_address(patron, addr) });
1057
1058         service.get_linked_addr_users(patron.addresses);
1059
1060         // Remove stat cat entries that link to out-of-scope stat
1061         // cats.  With this, we avoid unnecessarily updating (or worse,
1062         // modifying) stat cat values that are not ours to modify.
1063         patron.stat_cat_entries = patron.stat_cat_entries.filter(
1064             function(map) {
1065                 return Boolean(
1066                     // service.stat_cats only contains in-scope stat cats.
1067                     service.stat_cats.filter(function(cat) { 
1068                         return (cat.id() == map.stat_cat.id) })[0]
1069                 );
1070             }
1071         );
1072
1073         // toss entries for existing stat cat maps into our living 
1074         // stat cat entry map, which is modified within the template.
1075         angular.forEach(patron.stat_cat_entries, function(map) {
1076             service.stat_cat_entry_maps[map.stat_cat.id] = map.stat_cat_entry;
1077         });
1078
1079         return patron;
1080     }
1081
1082     service.init_new_patron = function() {
1083         var addr = {
1084             id : service.virt_id--,
1085             isnew : true,
1086             valid : true,
1087             address_type : egCore.strings.REG_ADDR_TYPE,
1088             _is_mailing : true,
1089             _is_billing : true,
1090             within_city_limits : false,
1091             country : service.org_settings['ui.patron.default_country'],
1092         };
1093
1094         var card = {
1095             id : service.virt_id--,
1096             isnew : true,
1097             active : true,
1098             _primary : 'on'
1099         };
1100
1101         var home_ou = egCore.org.get(service.org);
1102
1103         var user = {
1104             isnew : true,
1105             active : true,
1106             card : card,
1107             cards : [card],
1108             home_ou : home_ou,
1109             stat_cat_entries : [],
1110             groups : [],
1111             addresses : [addr]
1112         };
1113
1114         if (service.clone_user)
1115             service.copy_clone_data(user);
1116
1117         if (service.stage_user)
1118             service.copy_stage_data(user);
1119
1120         return user;
1121     }
1122
1123     // dob is always YYYY-MM-DD
1124     // Dates of birth do not contain timezone info, which can lead to
1125     // inconcistent timezone handling, potentially representing
1126     // different points in time, depending on the implementation.
1127     // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse
1128     // See "Differences in assumed time zone"
1129     // TODO: move this into egDate ?
1130     service.parse_dob = function(dob) {
1131         if (!dob) return null;
1132         var parts = dob.split('-');
1133         var d = new Date(); // always local time zone, yay.
1134         d.setFullYear(parts[0]);
1135         d.setMonth(parts[1] - 1);
1136         d.setDate(parts[2]);
1137         return d;
1138     }
1139
1140     service.copy_stage_data = function(user) {
1141         var cuser = service.stage_user;
1142
1143         // copy the data into our new user object
1144
1145         for (var key in egCore.idl.classes.stgu.field_map) {
1146             if (egCore.idl.classes.au.field_map[key] &&
1147                 !egCore.idl.classes.stgu.field_map[key].virtual) {
1148                 if (cuser.user[key]() !== null)
1149                     user[key] = cuser.user[key]();
1150             }
1151         }
1152
1153         if (user.home_ou) user.home_ou = egCore.org.get(user.home_ou);
1154         if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
1155         if (user.ident_type) 
1156             user.ident_type = egCore.env.cit.map[user.ident_type];
1157         user.dob = service.parse_dob(user.dob);
1158
1159         // Clear the usrname if it looks like a UUID
1160         if (user.usrname.replace(/-/g,'').match(/[0-9a-f]{32}/)) 
1161             user.usrname = '';
1162
1163         // Don't use stub address if we have one from the staged user.
1164         if (cuser.mailing_addresses.length || cuser.billing_addresses.length)
1165             user.addresses = [];
1166
1167         // is_mailing=false implies is_billing
1168         function addr_from_stage(stage_addr) {
1169             if (!stage_addr) return;
1170             var cls = stage_addr.classname;
1171
1172             var addr = {
1173                 id : service.virt_id--,
1174                 usr : user.id,
1175                 isnew : true,
1176                 valid : true,
1177                 _is_mailing : cls == 'stgma',
1178                 _is_billing : cls == 'stgba'
1179             };
1180
1181             user.mailing_address = addr;
1182             user.addresses.push(addr);
1183
1184             for (var key in egCore.idl.classes[cls].field_map) {
1185                 if (egCore.idl.classes.aua.field_map[key] &&
1186                     !egCore.idl.classes[cls].field_map[key].virtual) {
1187                     if (stage_addr[key]() !== null)
1188                         addr[key] = stage_addr[key]();
1189                 }
1190             }
1191         }
1192
1193         addr_from_stage(cuser.mailing_addresses[0]);
1194         addr_from_stage(cuser.billing_addresses[0]);
1195
1196         if (user.addresses.length == 1) {
1197             // If there is only one address, 
1198             // use it as both mailing and billing.
1199             var addr = user.addresses[0];
1200             addr._is_mailing = addr._is_billing = true;
1201             user.mailing_address = user.billing_address = addr;
1202         }
1203
1204         if (cuser.cards.length) {
1205             user.card = {
1206                 id : service.virt_id--,
1207                 barcode : cuser.cards[0].barcode(),
1208                 isnew : true,
1209                 active : true,
1210                 _primary : 'on'
1211             };
1212
1213             user.cards.push(user.card);
1214             if (user.usrname == '') 
1215                 user.usrname = card.barcode;
1216         }
1217
1218         angular.forEach(cuser.settings, function(setting) {
1219             service.user_settings[setting.setting()] = Boolean(setting.value());
1220         });
1221     }
1222
1223     // copy select values from the cloned user to the new user.
1224     // user is a hash
1225     service.copy_clone_data = function(user) {
1226         var clone_user = service.clone_user;
1227
1228         // flesh the home org locally
1229         user.home_ou = egCore.org.get(clone_user.home_ou());
1230         if (user.profile) user.profile = egCore.env.pgt.map[user.profile];
1231
1232         if (!clone_user.billing_address() &&
1233             !clone_user.mailing_address())
1234             return; // no addresses to copy or link
1235
1236         // if the cloned user has any addresses, we don't need 
1237         // the stub address created in init_new_patron.
1238         user.addresses = [];
1239
1240         var copy_addresses = 
1241             service.org_settings['circ.patron_edit.clone.copy_address'];
1242
1243         var clone_fields = [
1244             'day_phone',
1245             'evening_phone',
1246             'other_phone',
1247             'usrgroup'
1248         ]; 
1249
1250         angular.forEach(clone_fields, function(field) {
1251             user[field] = clone_user[field]();
1252         });
1253
1254         if (copy_addresses) {
1255             var bill_addr, mail_addr;
1256
1257             // copy the billing and mailing addresses into new addresses
1258             function clone_addr(addr) {
1259                 var new_addr = egCore.idl.toHash(addr);
1260                 new_addr.id = service.virt_id--;
1261                 new_addr.usr = user.id;
1262                 new_addr.isnew = true;
1263                 new_addr.valid = true;
1264                 user.addresses.push(new_addr);
1265                 return new_addr;
1266             }
1267
1268             if (bill_addr = clone_user.billing_address()) {
1269                 var addr = clone_addr(bill_addr);
1270                 addr._is_billing = true;
1271                 user.billing_address = addr;
1272             }
1273
1274             if (mail_addr = clone_user.mailing_address()) {
1275
1276                 if (bill_addr && bill_addr.id() == mail_addr.id()) {
1277                     user.mailing_address = user.billing_address;
1278                     user.mailing_address._is_mailing = true;
1279                 } else {
1280                     var addr = clone_addr(mail_addr);
1281                     addr._is_mailing = true;
1282                     user.mailing_address = addr;
1283                 }
1284
1285                 if (!bill_addr) {
1286                     // if there is no billing addr, use the mailing addr
1287                     user.billing_address = user.mailing_address;
1288                     user.billing_address._is_billing = true;
1289                 }
1290             }
1291
1292
1293         } else {
1294
1295             // link the billing and mailing addresses
1296             var addr;
1297             if (addr = clone_user.billing_address()) {
1298                 user.billing_address = egCore.idl.toHash(addr);
1299                 user.billing_address._is_billing = true;
1300                 user.addresses.push(user.billing_address);
1301                 user.billing_address._linked_owner_id = clone_user.id();
1302                 user.billing_address._linked_owner = service.format_name(
1303                     clone_user.family_name(),
1304                     clone_user.first_given_name(),
1305                     clone_user.second_given_name()
1306                 );
1307             }
1308
1309             if (addr = clone_user.mailing_address()) {
1310                 if (user.billing_address && 
1311                     addr.id() == user.billing_address.id) {
1312                     // mailing matches billing
1313                     user.mailing_address = user.billing_address;
1314                     user.mailing_address._is_mailing = true;
1315                 } else {
1316                     user.mailing_address = egCore.idl.toHash(addr);
1317                     user.mailing_address._is_mailing = true;
1318                     user.addresses.push(user.mailing_address);
1319                     user.mailing_address._linked_owner_id = clone_user.id();
1320                     user.mailing_address._linked_owner = service.format_name(
1321                         clone_user.family_name(),
1322                         clone_user.first_given_name(),
1323                         clone_user.second_given_name()
1324                     );
1325                 }
1326             }
1327         }
1328     }
1329
1330     // translate the patron back into IDL form
1331     service.save_user = function(phash) {
1332
1333         var patron = egCore.idl.fromHash('au', phash);
1334
1335         patron.home_ou(patron.home_ou().id());
1336         patron.expire_date(patron.expire_date().toISOString());
1337         patron.profile(patron.profile().id());
1338         if (patron.dob()) 
1339             patron.dob(patron.dob().toISOString().replace(/T.*/,''));
1340         if (patron.ident_type()) 
1341             patron.ident_type(patron.ident_type().id());
1342         if (patron.net_access_level())
1343             patron.net_access_level(patron.net_access_level().id());
1344
1345         angular.forEach(
1346             ['juvenile', 'barred', 'active', 'master_account'],
1347             function(field) { patron[field](phash[field] ? 't' : 'f'); }
1348         );
1349
1350         var card_hashes = patron.cards();
1351         patron.cards([]);
1352         angular.forEach(card_hashes, function(chash) {
1353             var card = egCore.idl.fromHash('ac', chash)
1354             card.usr(patron.id());
1355             card.active(chash.active ? 't' : 'f');
1356             patron.cards().push(card);
1357             if (chash._primary) {
1358                 patron.card(card);
1359             }
1360         });
1361
1362         var addr_hashes = patron.addresses();
1363         patron.addresses([]);
1364         angular.forEach(addr_hashes, function(addr_hash) {
1365             if (!addr_hash.isnew && !addr_hash.isdeleted) 
1366                 addr_hash.ischanged = true;
1367             var addr = egCore.idl.fromHash('aua', addr_hash);
1368             patron.addresses().push(addr);
1369             addr.valid(addr.valid() ? 't' : 'f');
1370             addr.within_city_limits(addr.within_city_limits() ? 't' : 'f');
1371             if (addr_hash._is_mailing) patron.mailing_address(addr);
1372             if (addr_hash._is_billing) patron.billing_address(addr);
1373         });
1374
1375         patron.survey_responses([]);
1376         angular.forEach(service.survey_responses, function(answer) {
1377             var question = service.survey_questions[answer.question()];
1378             var resp = new egCore.idl.asvr();
1379             resp.isnew(true);
1380             resp.survey(question.survey());
1381             resp.question(question.id());
1382             resp.answer(answer.id());
1383             resp.usr(patron.id());
1384             resp.answer_date('now');
1385             patron.survey_responses().push(resp);
1386         });
1387         
1388         // re-object-ify the patron stat cat entry maps
1389         var maps = [];
1390         angular.forEach(patron.stat_cat_entries(), function(entry) {
1391             var e = egCore.idl.fromHash('actscecm', entry);
1392             e.stat_cat(e.stat_cat().id);
1393             maps.push(e);
1394         });
1395         patron.stat_cat_entries(maps);
1396
1397         // service.stat_cat_entry_maps maps stats to values
1398         // patron.stat_cat_entries is an array of stat_cat_entry_usr_map's
1399         angular.forEach(
1400             service.stat_cat_entry_maps, function(value, cat_id) {
1401
1402             // see if we already have a mapping for this entry
1403             var existing = patron.stat_cat_entries().filter(
1404                 function(e) { return e.stat_cat() == cat_id })[0];
1405
1406             if (existing) { // we have a mapping
1407                 // if the existing mapping matches the new one,
1408                 // there' nothing left to do
1409                 if (existing.stat_cat_entry() == value) return;
1410
1411                 // mappings differ.  delete the old one and create
1412                 // a new one below.
1413                 existing.isdeleted(true);
1414             }
1415
1416             var newmap = new egCore.idl.actscecm();
1417             newmap.target_usr(patron.id());
1418             newmap.isnew(true);
1419             newmap.stat_cat(cat_id);
1420             newmap.stat_cat_entry(value);
1421             patron.stat_cat_entries().push(newmap);
1422         });
1423
1424         if (!patron.isnew()) patron.ischanged(true);
1425
1426         return egLovefield.addOfflineXact({
1427             user        : egCore.idl.toHash(patron),
1428             timestamp   : parseInt(new Date().getTime() / 1000),
1429             type        : 'register',
1430             delta       : 0
1431         }).then(function (success) {
1432             if (success) return patron;
1433         });
1434     }
1435
1436     service.remove_staged_user = function() {
1437         if (!service.stage_user) return $q.when();
1438         return egCore.net.request(
1439             'open-ils.actor',
1440             'open-ils.actor.user.stage.delete',
1441             egCore.auth.token(),
1442             service.stage_user.user.row_id()
1443         );
1444     }
1445
1446     service.save_user_settings = function(new_user, user_settings) {
1447         return;
1448     }
1449
1450     // Applies field-specific validation regex's from org settings 
1451     // to form fields.  Be careful not remove any pattern data we
1452     // are not explicitly over-writing in the provided patterns obj.
1453     service.set_field_patterns = function(patterns) {
1454         if (service.org_settings['opac.username_regex']) {
1455             patterns.au.usrname = 
1456                 new RegExp(service.org_settings['opac.username_regex']);
1457         }
1458
1459         if (service.org_settings['opac.barcode_regex']) {
1460             patterns.ac.barcode = 
1461                 new RegExp(service.org_settings['opac.barcode_regex']);
1462         }
1463
1464         if (service.org_settings['global.password_regex']) {
1465             patterns.au.passwd = 
1466                 new RegExp(service.org_settings['global.password_regex']);
1467         }
1468
1469         var phone_reg = service.org_settings['ui.patron.edit.phone.regex'];
1470         if (phone_reg) {
1471             // apply generic phone regex first, replace below as needed.
1472             patterns.au.day_phone = new RegExp(phone_reg);
1473             patterns.au.evening_phone = new RegExp(phone_reg);
1474             patterns.au.other_phone = new RegExp(phone_reg);
1475         }
1476
1477         // the remaining patterns fit a well-known key name pattern
1478
1479         angular.forEach(service.org_settings, function(val, key) {
1480             if (!val) return;
1481             var parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
1482             if (!parts) return;
1483             var cls = parts[1];
1484             var name = parts[2];
1485             patterns[cls][name] = new RegExp(val);
1486         });
1487     }
1488
1489     return service;
1490 }])
1491
1492 .controller('PatronRegCtrl',
1493        ['$scope','$routeParams','$q','$uibModal','$window','egCore',
1494         'patronSvc','patronRegSvc','egUnloadPrompt','egAlertDialog',
1495         'egWorkLog','$timeout','egLovefield','$rootScope',
1496 function($scope , $routeParams , $q , $uibModal , $window , egCore ,
1497          patronSvc , patronRegSvc , egUnloadPrompt, egAlertDialog ,
1498          egWorkLog , $timeout , egLovefield , $rootScope) {
1499
1500     $scope.rs = $rootScope;
1501     if ($scope.workstation_obj) patronRegSvc.org = $scope.workstation_obj.owning_lib;
1502     $scope.offline = true;
1503
1504     $scope.page_data_loaded = false;
1505     $scope.clone_id = patronRegSvc.clone_id = $routeParams.clone_id;
1506     $scope.stage_username = 
1507         patronRegSvc.stage_username = $routeParams.stage_username;
1508     $scope.patron_id = 
1509         patronRegSvc.patron_id = $routeParams.edit_id || $routeParams.id;
1510
1511     // for existing patrons, disable barcode input by default
1512     $scope.disable_bc = $scope.focus_usrname = Boolean($scope.patron_id);
1513     $scope.focus_bc = !Boolean($scope.patron_id);
1514     $scope.address_alerts = [];
1515     $scope.dupe_counts = {};
1516
1517     // map of perm name to true/false for perms the logged in user
1518     // has at the currently selected patron home org unit.
1519     $scope.perms = {};
1520
1521     $scope.edit_passthru = {};
1522
1523     // 0=all, 1=suggested, 2=all
1524     $scope.edit_passthru.vis_level = 2; 
1525
1526     // Apply default values for new patrons during initial registration
1527     // prs is shorthand for patronSvc
1528     function set_new_patron_defaults(prs) {
1529         if (!$scope.patron.passwd) {
1530             // passsword may originate from staged user.
1531             $scope.generate_password();
1532         }
1533         $scope.hold_notify_phone = true;
1534         $scope.hold_notify_email = true;
1535
1536         // staged users may be loaded w/ a profile.
1537         $scope.set_expire_date();
1538
1539         if (prs.org_settings['ui.patron.default_ident_type']) {
1540             // $scope.patron needs this field to be an object
1541             var id = prs.org_settings['ui.patron.default_ident_type'];
1542             var ident_type = $scope.ident_types.filter(
1543                 function(type) { return type.id() == id })[0];
1544             $scope.patron.ident_type = ident_type;
1545         }
1546         if (prs.org_settings['ui.patron.default_inet_access_level']) {
1547             // $scope.patron needs this field to be an object
1548             var id = prs.org_settings['ui.patron.default_inet_access_level'];
1549             var level = $scope.net_access_levels.filter(
1550                 function(lvl) { return lvl.id() == id })[0];
1551             $scope.patron.net_access_level = level;
1552         }
1553         if (prs.org_settings['ui.patron.default_country']) {
1554             $scope.patron.addresses[0].country = 
1555                 prs.org_settings['ui.patron.default_country'];
1556         }
1557     }
1558
1559     // A null or undefined pattern leads to exceptions.  Before the
1560     // patterns are loaded from the server, default all patterns
1561     // to an innocuous regex.  To avoid re-creating numerous
1562     // RegExp objects, cache the stub RegExp after initial creation.
1563     // note: angular docs say ng-pattern accepts a regexp or string,
1564     // but as of writing, it only works with a regexp object.
1565     // (Likely an angular 1.2 vs. 1.4 issue).
1566     var field_patterns = {au : {}, ac : {}, aua : {}};
1567     $scope.field_pattern = function(cls, field) { 
1568         if (!field_patterns[cls][field])
1569             field_patterns[cls][field] = new RegExp('.*');
1570         return field_patterns[cls][field];
1571     }
1572
1573     patronRegSvc.offlineMode($scope.offline); // force offline if ng-init'd to do so
1574     patronRegSvc.init().then(function() {
1575         // called after initTab and patronRegSvc.init have completed
1576     
1577         var prs = patronRegSvc; // brevity
1578         // in standalone mode, we have no patronSvc
1579         $scope.patron = prs.init_patron(patronSvc ? patronSvc.current : null);
1580         $scope.field_doc = prs.field_doc;
1581         $scope.edit_profiles = prs.edit_profiles;
1582         $scope.ident_types = prs.ident_types;
1583         $scope.net_access_levels = prs.net_access_levels;
1584         $scope.user_setting_types = prs.user_setting_types;
1585         $scope.opt_in_setting_types = prs.opt_in_setting_types;
1586         $scope.org_settings = prs.org_settings;
1587         $scope.sms_carriers = prs.sms_carriers;
1588         $scope.stat_cats = prs.stat_cats;
1589         $scope.surveys = prs.surveys;
1590         $scope.survey_responses = prs.survey_responses;
1591         $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps;
1592         $scope.stage_user = prs.stage_user;
1593         $scope.stage_user_requestor = prs.stage_user_requestor;
1594     
1595         $scope.user_settings = prs.user_settings;
1596         // clone the user settings back into the patronRegSvc so
1597         // we have a copy of the original state of the settings.
1598         prs.user_settings = {};
1599         angular.forEach($scope.user_settings, function(val, key) {
1600             prs.user_settings[key] = val;
1601         });
1602     
1603         extract_hold_notify();
1604         $scope.handle_home_org_changed();
1605     
1606         if ($scope.org_settings['ui.patron.edit.default_suggested'])
1607             $scope.edit_passthru.vis_level = 1;
1608     
1609         if ($scope.patron.isnew) 
1610             set_new_patron_defaults(prs);
1611     
1612         $scope.page_data_loaded = true;
1613     
1614         prs.set_field_patterns(field_patterns);
1615         apply_username_regex();
1616     });
1617
1618     // update the currently displayed field documentation
1619     $scope.set_selected_field_doc = function(cls, field) {
1620         $scope.selected_field_doc = $scope.field_doc[cls][field];
1621     }
1622
1623     // returns the tree depth of the selected profile group tree node.
1624     $scope.pgt_depth = function(grp) {
1625         var d = 0;
1626         while (grp = egCore.env.pgt.map[grp.parent()]) d++;
1627         return d;
1628     }
1629
1630     // IDL fields used for labels in the UI.
1631     $scope.idl_fields = {
1632         au  : egCore.idl.classes.au.field_map,
1633         ac  : egCore.idl.classes.ac.field_map,
1634         aua : egCore.idl.classes.aua.field_map
1635     };
1636
1637     // field visibility cache.  Some fields are universally required.
1638     // 3 == value universally required
1639     // 2 == field is visible by default
1640     // 1 == field is suggested by default
1641     var field_visibility = {};
1642     var default_field_visibility = {
1643         'ac.barcode' : 3,
1644         'au.usrname' : 3,
1645         'au.passwd' :  3,
1646         'au.first_given_name' : 3,
1647         'au.family_name' : 3,
1648         'au.ident_type' : 3,
1649         'au.home_ou' : 3,
1650         'au.profile' : 3,
1651         'au.expire_date' : 3,
1652         'au.net_access_level' : 3,
1653         'aua.address_type' : 3,
1654         'aua.post_code' : 3,
1655         'aua.street1' : 3,
1656         'aua.street2' : 2,
1657         'aua.city' : 3,
1658         'aua.county' : 2,
1659         'aua.state' : 2,
1660         'aua.country' : 3,
1661         'aua.valid' : 2,
1662         'aua.within_city_limits' : 2,
1663         'stat_cats' : 1,
1664         'surveys' : 1
1665     }; 
1666
1667     // Returns true if the selected field should be visible
1668     // given the current required/suggested/all setting.
1669     // The visibility flag applied to each field as a result of calling
1670     // this function also sets (via the same flag) the requiredness state.
1671     $scope.show_field = function(field_key) {
1672         // org settings have not been received yet.
1673         if (!$scope.org_settings) return false;
1674
1675         if (field_visibility[field_key] == undefined) {
1676             // compile and cache the visibility for the selected field
1677
1678             var req_set = 'ui.patron.edit.' + field_key + '.require';
1679             var sho_set = 'ui.patron.edit.' + field_key + '.show';
1680             var sug_set = 'ui.patron.edit.' + field_key + '.suggest';
1681
1682             if ($scope.org_settings[req_set]) {
1683                 field_visibility[field_key] = 3;
1684
1685             } else if ($scope.org_settings[sho_set]) {
1686                 field_visibility[field_key] = 2;
1687
1688             } else if ($scope.org_settings[sug_set]) {
1689                 field_visibility[field_key] = 1;
1690             }
1691         }
1692
1693         if (field_visibility[field_key] == undefined) {
1694             // No org settings were applied above.  Use the default
1695             // settings if present or assume the field has no
1696             // visibility flags applied.
1697             field_visibility[field_key] = 
1698                 default_field_visibility[field_key] || 0;
1699         }
1700
1701         return field_visibility[field_key] >= $scope.edit_passthru.vis_level;
1702     }
1703
1704     // See $scope.show_field().
1705     // A field with visbility level 3 means it's required.
1706     $scope.field_required = function(cls, field) {
1707
1708         // Value in the password field is not required
1709         // for existing patrons.
1710         if (field == 'passwd' && $scope.patron && !$scope.patron.isnew) 
1711           return false;
1712
1713         return (field_visibility[cls + '.' + field] == 3 || default_field_visibility[cls + '.' + field] == 3);
1714     }
1715
1716     // generates a random 4-digit password
1717     $scope.generate_password = function() {
1718         $scope.patron.passwd = Math.floor(Math.random()*9000) + 1000;
1719     }
1720
1721     $scope.set_expire_date = function() {
1722         if (!$scope.patron.profile) return;
1723         var seconds = egCore.date.intervalToSeconds(
1724             $scope.patron.profile.perm_interval());
1725         var now_epoch = new Date().getTime();
1726         $scope.patron.expire_date = new Date(
1727             now_epoch + (seconds * 1000 /* milliseconds */))
1728     }
1729
1730     // grp is the pgt object
1731     $scope.set_profile = function(grp) {
1732         $scope.patron.profile = grp;
1733         $scope.set_expire_date();
1734         $scope.field_modified();
1735     }
1736
1737     $scope.invalid_profile = function() {
1738         return !(
1739             $scope.patron && 
1740             $scope.patron.profile && 
1741             $scope.patron.profile.usergroup() == 't'
1742         );
1743     }
1744
1745     $scope.new_address = function() {
1746         var addr = egCore.idl.toHash(new egCore.idl.aua());
1747         patronRegSvc.ingest_address($scope.patron, addr);
1748         addr.id = patronRegSvc.virt_id--;
1749         addr.isnew = true;
1750         addr.valid = true;
1751         addr.within_city_limits = true;
1752         addr.country = $scope.org_settings['ui.patron.default_country'];
1753         $scope.patron.addresses.push(addr);
1754     }
1755
1756     // keep deleted addresses out of the patron object so
1757     // they won't appear in the UI.  They'll be re-inserted
1758     // when the patron is updated.
1759     deleted_addresses = [];
1760     $scope.delete_address = function(id) {
1761
1762         if ($scope.patron.isnew &&
1763             $scope.patron.addresses.length == 1 &&
1764             $scope.org_settings['ui.patron.registration.require_address']) {
1765             egAlertDialog.open(egCore.strings.REG_ADDR_REQUIRED);
1766             return;
1767         }
1768
1769         var addresses = [];
1770         angular.forEach($scope.patron.addresses, function(addr) {
1771             if (addr.id == id) {
1772                 if (id > 0) {
1773                     addr.isdeleted = true;
1774                     deleted_addresses.push(addr);
1775                 }
1776             } else {
1777                 addresses.push(addr);
1778             }
1779         });
1780         $scope.patron.addresses = addresses;
1781     } 
1782
1783     $scope.post_code_changed = function(addr) { 
1784         if ($scope.offline) return;
1785         egCore.net.request(
1786             'open-ils.search', 'open-ils.search.zip', addr.post_code)
1787         .then(function(resp) {
1788             if (!resp) return;
1789             if (resp.city) addr.city = resp.city;
1790             if (resp.state) addr.state = resp.state;
1791             if (resp.county) addr.county = resp.county;
1792             if (resp.alert) alert(resp.alert);
1793         });
1794     }
1795
1796     $scope.replace_card = function() {
1797         $scope.patron.card.active = false;
1798         $scope.patron.card.ischanged = true;
1799         $scope.disable_bc = false;
1800
1801         var new_card = egCore.idl.toHash(new egCore.idl.ac());
1802         new_card.id = patronRegSvc.virt_id--;
1803         new_card.isnew = true;
1804         new_card.active = true;
1805         new_card._primary = 'on';
1806         $scope.patron.card = new_card;
1807         $scope.patron.cards.push(new_card);
1808     }
1809
1810     $scope.day_phone_changed = function(phone) {
1811         if (phone && $scope.patron.isnew && 
1812             $scope.org_settings['patron.password.use_phone']) {
1813             $scope.patron.passwd = phone.substr(-4);
1814         }
1815     }
1816
1817     $scope.barcode_changed = function(bc) {
1818         if (!bc) return;
1819         if (!$scope.patron.usrname)
1820             $scope.patron.usrname = bc;
1821     }
1822
1823     $scope.cards_dialog = function() {
1824         $uibModal.open({
1825             templateUrl: './circ/patron/t_patron_cards_dialog',
1826             backdrop: 'static',
1827             controller: 
1828                    ['$scope','$uibModalInstance','cards','perms',
1829             function($scope , $uibModalInstance , cards , perms) {
1830                 // scope here is the modal-level scope
1831                 $scope.args = {cards : cards};
1832                 $scope.perms = perms;
1833                 $scope.ok = function() { $uibModalInstance.close($scope.args) }
1834                 $scope.cancel = function () { $uibModalInstance.dismiss() }
1835             }],
1836             resolve : {
1837                 cards : function() {
1838                     // scope here is the controller-level scope
1839                     return $scope.patron.cards;
1840                 },
1841                 perms : function() {
1842                     return $scope.perms;
1843                 }
1844             }
1845         }).result.then(
1846             function(args) {
1847                 angular.forEach(args.cards, function(card) {
1848                     card.ischanged = true; // assume cards need updating, OK?
1849                     if (card._primary == 'on' && 
1850                         card.id != $scope.patron.card.id) {
1851                         $scope.patron.card = card;
1852                     }
1853                 });
1854             }
1855         );
1856     }
1857
1858     $scope.set_addr_type = function(addr, type) {
1859         var addrs = $scope.patron.addresses;
1860         if (addr['_is_'+type]) {
1861             angular.forEach(addrs, function(a) {
1862                 if (a.id != addr.id) a['_is_'+type] = false;
1863             });
1864         } else {
1865             // unchecking mailing/billing means we have to randomly
1866             // select another address to fill that role.  Select the
1867             // first address in the list (that does not match the
1868             // modifed address)
1869             for (var i = 0; i < addrs.length; i++) {
1870                 if (addrs[i].id != addr.id) {
1871                     addrs[i]['_is_' + type] = true;
1872                     break;
1873                 }
1874             }
1875         }
1876     }
1877
1878
1879     // Translate hold notify preferences from the form/scope back into a 
1880     // single user setting value for opac.hold_notify.
1881     function compress_hold_notify() {
1882         var hold_notify = '';
1883         var splitter = '';
1884         if ($scope.hold_notify_phone) {
1885             hold_notify = 'phone';
1886             splitter = ':';
1887         }
1888         if ($scope.hold_notify_email) {
1889             hold_notify = splitter + 'email';
1890             splitter = ':';
1891         }
1892         if ($scope.hold_notify_sms) {
1893             hold_notify = splitter + 'sms';
1894             splitter = ':';
1895         }
1896         $scope.user_settings['opac.hold_notify'] = hold_notify;
1897     }
1898
1899     // dialog for selecting additional permission groups
1900     $scope.secondary_groups_dialog = function() {
1901         $uibModal.open({
1902             templateUrl: './circ/patron/t_patron_groups_dialog',
1903             backdrop: 'static',
1904             controller: 
1905                    ['$scope','$uibModalInstance','linked_groups','pgt_depth',
1906             function($scope , $uibModalInstance , linked_groups , pgt_depth) {
1907
1908                 $scope.pgt_depth = pgt_depth;
1909                 $scope.args = {
1910                     linked_groups : linked_groups,
1911                     edit_profiles : patronRegSvc.edit_profiles,
1912                     new_profile   : patronRegSvc.edit_profiles[0]
1913                 };
1914
1915                 // add a new group to the linked groups list
1916                 $scope.link_group = function($event, grp) {
1917                     var found = false; // avoid duplicates
1918                     angular.forEach($scope.args.linked_groups, 
1919                         function(g) {if (g.id() == grp.id()) found = true});
1920                     if (!found) $scope.args.linked_groups.push(grp);
1921                     $event.preventDefault(); // avoid close
1922                 }
1923
1924                 // remove a group from the linked groups list
1925                 $scope.unlink_group = function($event, grp) {
1926                     $scope.args.linked_groups = 
1927                         $scope.args.linked_groups.filter(function(g) {
1928                         return g.id() != grp.id()
1929                     });
1930                     $event.preventDefault(); // avoid close
1931                 }
1932
1933                 $scope.ok = function() { $uibModalInstance.close($scope.args) }
1934                 $scope.cancel = function () { $uibModalInstance.dismiss() }
1935             }],
1936             resolve : {
1937                 linked_groups : function() { return $scope.patron.groups },
1938                 pgt_depth : function() { return $scope.pgt_depth }
1939             }
1940         }).result.then(
1941             function(args) {
1942
1943                 if ($scope.patron.isnew) {
1944                     // groups must be linked for new patrons after the
1945                     // patron is created.
1946                     $scope.patron.groups = args.linked_groups;
1947                     return;
1948                 }
1949
1950                 // update links groups for existing users in real time.
1951                 var ids = args.linked_groups.map(function(g) {return g.id()});
1952                 patronRegSvc.apply_secondary_groups($scope.patron.id, ids)
1953                 .then(function(success) {
1954                     if (success)
1955                         $scope.patron.groups = args.linked_groups;
1956                 });
1957             }
1958         );
1959     }
1960
1961     function extract_hold_notify() {
1962         notify = $scope.user_settings['opac.hold_notify'];
1963         if (!notify) return;
1964         $scope.hold_notify_phone = Boolean(notify.match(/phone/));
1965         $scope.hold_notify_email = Boolean(notify.match(/email/));
1966         $scope.hold_notify_sms = Boolean(notify.match(/sms/));
1967     }
1968
1969     $scope.invalidate_field = function(field) {
1970         patronRegSvc.invalidate_field($scope.patron, field);
1971     }
1972
1973     address_alert = function(addr) {
1974         if ($scope.offline) return;
1975         var args = {
1976             street1: addr.street1,
1977             street2: addr.street2,
1978             city: addr.city,
1979             state: addr.state,
1980             county: addr.county,
1981             country: addr.country,
1982             post_code: addr.post_code,
1983             mailing_address: addr._is_mailing,
1984             billing_address: addr._is_billing
1985         }
1986
1987         egCore.net.request(
1988             'open-ils.actor',
1989             'open-ils.actor.address_alert.test',
1990             egCore.auth.token(), egCore.auth.user().ws_ou(), args
1991             ).then(function(res) {
1992                 $scope.address_alerts = res;
1993         });
1994     }
1995
1996     $scope.dupe_value_changed = function(type, value) {
1997         $scope.dupe_counts[type] = 0;
1998         patronRegSvc.dupe_patron_search($scope.patron, type, value)
1999         .then(function(res) {
2000             $scope.dupe_counts[type] = res.count;
2001             if (res.count) {
2002                 $scope.dupe_search_encoded = 
2003                     encodeURIComponent(js2JSON(res.search));
2004             } else {
2005                 $scope.dupe_search_encoded = '';
2006             }
2007         });
2008     }
2009
2010     // Dummy function in offline mode
2011     $scope.handle_home_org_changed = function() {}
2012
2013     // This is called with every character typed in a form field,
2014     // since that's the only way to gaurantee something has changed.
2015     // See handle_field_changed for ng-change vs. ng-blur.
2016     $scope.field_modified = function() {
2017         // Call attach with every field change, regardless of whether
2018         // it's been called before.  This will allow for re-attach after
2019         // the user clicks through the unload warning. egUnloadPrompt
2020         // will ensure we only attach once.
2021         egUnloadPrompt.attach($rootScope);
2022     }
2023
2024     // also monitor when form is changed *by the user*, as using
2025     // an ng-change handler doesn't work with eg-date-input
2026     $scope.$watch('reg_form.$pristine', function(newVal, oldVal) {
2027         if (!newVal) egUnloadPrompt.attach($rootScope);
2028     });
2029
2030     // username regex (if present) must be removed any time
2031     // the username matches the barcode to avoid firing the
2032     // invalid field handlers.
2033     function apply_username_regex() {
2034         var regex = $scope.org_settings['opac.username_regex'];
2035         if (regex) {
2036             if ($scope.patron.card.barcode) {
2037                 // username must match the regex or the barcode
2038                 field_patterns.au.usrname = 
2039                     new RegExp(
2040                         regex + '|^' + $scope.patron.card.barcode + '$');
2041             } else {
2042                 // username must match the regex
2043                 field_patterns.au.usrname = new RegExp(regex);
2044             }
2045         } else {
2046             // username can be any format.
2047             field_patterns.au.usrname = new RegExp('.*');
2048         }
2049     }
2050
2051     // obj could be the patron, an address, etc.
2052     // This is called any time a form field achieves then loses focus.
2053     // It does not necessarily mean the field has changed.
2054     // The alternative is ng-change, but it's called with each character
2055     // typed, which would be overkill for many of the actions called here.
2056     $scope.handle_field_changed = function(obj, field_name) {
2057         if (!obj) return;
2058
2059         var cls = obj.classname; // set by egIdl
2060         var value = obj[field_name];
2061
2062         // Hush!
2063         //console.log('changing field ' + field_name + ' to ' + value);
2064
2065         switch (field_name) {
2066             case 'day_phone' : 
2067                 if ($scope.patron.day_phone && 
2068                     $scope.patron.isnew && 
2069                     $scope.org_settings['patron.password.use_phone']) {
2070                     $scope.patron.passwd = phone.substr(-4);
2071                 }
2072                 break;
2073
2074             case 'barcode':
2075                 apply_username_regex();
2076                 $scope.barcode_changed(value);
2077                 break;
2078
2079             case 'dob':
2080                 maintain_juvenile_flag();
2081                 break;
2082
2083             default:
2084                 break;
2085         }
2086     }
2087
2088     // patron.juvenile is set to true if the user was born after
2089     function maintain_juvenile_flag() {
2090         if ( !($scope.patron && $scope.patron.dob) ) return;
2091
2092         var juv_interval = 
2093             $scope.org_settings['global.juvenile_age_threshold'] 
2094             || '18 years';
2095
2096         var base = new Date();
2097
2098         base.setTime(base.getTime() - 
2099             Number(egCore.date.intervalToSeconds(juv_interval) + '000'));
2100
2101         $scope.patron.juvenile = ($scope.patron.dob > base);
2102     }
2103
2104     // returns true (disable) for orgs that cannot have users.
2105     $scope.disable_home_org = function(org_id) {
2106         if (!org_id) return;
2107         var org = egCore.org.get(org_id);
2108         return (
2109             org &&
2110             org.ou_type() &&
2111             org.ou_type().can_have_users() == 'f'
2112         );
2113     }
2114
2115     $scope.edit_passthru.self_edit_disallowed = function() {
2116         return false;
2117     }
2118
2119     $scope.edit_passthru.group_edit_disallowed = function() {
2120         return false;
2121     }
2122
2123     // Returns true if the Save and Save & Clone buttons should be disabled.
2124     $scope.edit_passthru.hide_save_actions = function() {
2125         return false;
2126     }
2127
2128     // Returns true if any input elements are tagged as invalid
2129     // via Angular patterns or required attributes.
2130     function form_has_invalid_fields() {
2131         return $('#patron-reg-container .ng-invalid').length > 0;
2132     }
2133
2134     function form_is_incomplete() {
2135         return (
2136             $scope.dupe_username ||
2137             $scope.dupe_barcode ||
2138             form_has_invalid_fields()
2139         );
2140
2141     }
2142
2143     $scope.edit_passthru.save = function(save_args) {
2144         if (!save_args) save_args = {};
2145
2146         if (form_is_incomplete()) {
2147             // User has not provided valid values for all required fields.
2148             return egAlertDialog.open(egCore.strings.REG_INVALID_FIELDS);
2149         }
2150
2151         // remove page unload warning prompt
2152         egUnloadPrompt.clear();
2153
2154         // toss the deleted addresses back into the patron's list of
2155         // addresses so it's included in the update
2156         $scope.patron.addresses = 
2157             $scope.patron.addresses.concat(deleted_addresses);
2158         
2159         compress_hold_notify();
2160
2161         var updated_user;
2162
2163         patronRegSvc.save_user($scope.patron)
2164         .then($scope.rs.save_offline_xacts)
2165         .then(function(new_user) { 
2166             // reload the current page
2167             $window.location.href = location.href;
2168         });
2169     }
2170 }])