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