LP1904036 Patron edit stat cat create + delete handled OK
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / staff / circ / patron / edit.component.ts
1 import {Component, OnInit, AfterViewInit, Input, ViewChild} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {empty, from} from 'rxjs';
4 import {concatMap, tap} from 'rxjs/operators';
5 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
6 import {OrgService} from '@eg/core/org.service';
7 import {IdlService, IdlObject} from '@eg/core/idl.service';
8 import {NetService} from '@eg/core/net.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {PcrudService} from '@eg/core/pcrud.service';
11 import {PatronService} from '@eg/staff/share/patron/patron.service';
12 import {PatronContextService} from './patron.service';
13 import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component';
14 import {DateUtil} from '@eg/share/util/date';
15 import {ProfileSelectComponent} from '@eg/staff/share/patron/profile-select.component';
16 import {ToastService} from '@eg/share/toast/toast.service';
17 import {StringService} from '@eg/share/string/string.service';
18 import {EventService} from '@eg/core/event.service';
19 import {PermService} from '@eg/core/perm.service';
20 import {SecondaryGroupsDialogComponent} from './secondary-groups.component';
21 import {ServerStoreService} from '@eg/core/server-store.service';
22 import {EditToolbarComponent, VisibilityLevel} from './edit-toolbar.component';
23 import {PatronSearchFieldSet} from '@eg/staff/share/patron/search.component';
24 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
25 import {HoldNotifyUpdateDialogComponent} from './hold-notify-update.component';
26 import {BroadcastService} from '@eg/share/util/broadcast.service';
27 import {PrintService} from '@eg/share/print/print.service';
28 import {WorkLogService} from '@eg/staff/share/worklog/worklog.service';
29
30 const PATRON_FLESH_FIELDS = [
31     'cards',
32     'card',
33     'groups',
34     'standing_penalties',
35     'settings',
36     'addresses',
37     'billing_address',
38     'mailing_address',
39     'stat_cat_entries',
40     'waiver_entries',
41     'usr_activity',
42     'notes'
43 ];
44
45 const COMMON_USER_SETTING_TYPES = [
46   'circ.holds_behind_desk',
47   'circ.collections.exempt',
48   'opac.hold_notify',
49   'opac.default_phone',
50   'opac.default_pickup_location',
51   'opac.default_sms_carrier',
52   'opac.default_sms_notify'
53 ];
54
55 const PERMS_NEEDED = [
56     'EDIT_SELF_IN_CLIENT',
57     'UPDATE_USER',
58     'CREATE_USER',
59     'CREATE_USER_GROUP_LINK',
60     'UPDATE_PATRON_COLLECTIONS_EXEMPT',
61     'UPDATE_PATRON_CLAIM_RETURN_COUNT',
62     'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
63     'UPDATE_PATRON_ACTIVE_CARD',
64     'UPDATE_PATRON_PRIMARY_CARD'
65 ];
66
67 enum FieldVisibility {
68     REQUIRED = 3,
69     VISIBLE = 2,
70     SUGGESTED = 1
71 }
72
73 // 3 == value universally required
74 // 2 == field is visible by default
75 // 1 == field is suggested by default
76 const DEFAULT_FIELD_VISIBILITY = {
77     'ac.barcode': FieldVisibility.REQUIRED,
78     'au.usrname': FieldVisibility.REQUIRED,
79     'au.passwd': FieldVisibility.REQUIRED,
80     'au.first_given_name': FieldVisibility.REQUIRED,
81     'au.family_name': FieldVisibility.REQUIRED,
82     'au.pref_first_given_name': FieldVisibility.VISIBLE,
83     'au.pref_family_name': FieldVisibility.VISIBLE,
84     'au.ident_type': FieldVisibility.REQUIRED,
85     'au.ident_type2': FieldVisibility.VISIBLE,
86     'au.home_ou': FieldVisibility.REQUIRED,
87     'au.profile': FieldVisibility.REQUIRED,
88     'au.expire_date': FieldVisibility.REQUIRED,
89     'au.net_access_level': FieldVisibility.REQUIRED,
90     'aua.address_type': FieldVisibility.REQUIRED,
91     'aua.post_code': FieldVisibility.REQUIRED,
92     'aua.street1': FieldVisibility.REQUIRED,
93     'aua.street2': FieldVisibility.VISIBLE,
94     'aua.city': FieldVisibility.REQUIRED,
95     'aua.county': FieldVisibility.VISIBLE,
96     'aua.state': FieldVisibility.VISIBLE,
97     'aua.country': FieldVisibility.REQUIRED,
98     'aua.valid': FieldVisibility.VISIBLE,
99     'aua.within_city_limits': FieldVisibility.VISIBLE,
100     'stat_cats': FieldVisibility.SUGGESTED,
101     'surveys': FieldVisibility.SUGGESTED,
102     'au.name_keywords': FieldVisibility.SUGGESTED
103 };
104
105 interface StatCat {
106     cat: IdlObject;
107     entries: ComboboxEntry[];
108 }
109
110 @Component({
111   templateUrl: 'edit.component.html',
112   selector: 'eg-patron-edit',
113   styleUrls: ['edit.component.css']
114 })
115 export class EditComponent implements OnInit, AfterViewInit {
116
117     @Input() patronId: number = null;
118     @Input() cloneId: number = null;
119     @Input() stageUsername: string = null;
120
121     _toolbar: EditToolbarComponent;
122     @Input() set toolbar(tb: EditToolbarComponent) {
123         if (tb !== this._toolbar) {
124             this._toolbar = tb;
125
126             // Our toolbar component may not be available during init,
127             // since it pops in and out of existence depending on which
128             // patron tab is open.  Wait until we know it's defined.
129             if (tb) {
130                 tb.saveClicked.subscribe(_ => this.save());
131                 tb.saveCloneClicked.subscribe(_ => this.save(true));
132                 tb.printClicked.subscribe(_ => this.printPatron());
133             }
134         }
135     }
136
137     get toolbar(): EditToolbarComponent {
138         return this._toolbar;
139     }
140
141     @ViewChild('profileSelect')
142         private profileSelect: ProfileSelectComponent;
143     @ViewChild('secondaryGroupsDialog')
144         private secondaryGroupsDialog: SecondaryGroupsDialogComponent;
145     @ViewChild('holdNotifyUpdateDialog')
146         private holdNotifyUpdateDialog: HoldNotifyUpdateDialogComponent;
147     @ViewChild('addrAlert') private addrAlert: AlertDialogComponent;
148     @ViewChild('addrRequiredAlert')
149         private addrRequiredAlert: AlertDialogComponent;
150     @ViewChild('xactCollisionAlert')
151         private xactCollisionAlert: AlertDialogComponent;
152
153
154     autoId = -1;
155     patron: IdlObject;
156     modifiedPatron: IdlObject;
157     changeHandlerNeeded = false;
158     nameTab = 'primary';
159     replaceBarcodeUsed = false;
160
161     // Are we still fetching data and applying values?
162     loading = false;
163     // Should the user be able to see the form?
164     // On page load, we want to show the form just before we are
165     // done loading, so values can be applied to inputs after they
166     // are rendered but before those changes would result in setting
167     // changesPending = true
168     showForm = false;
169
170     surveys: IdlObject[];
171     smsCarriers: ComboboxEntry[];
172     identTypes: ComboboxEntry[];
173     inetLevels: ComboboxEntry[];
174     statCats: StatCat[] = [];
175     grpList: IdlObject;
176     editProfiles: IdlObject[] = [];
177     userStatCats: {[statId: number]: ComboboxEntry} = {};
178     userSettings: {[name: string]: any} = {};
179     userSettingTypes: {[name: string]: IdlObject} = {};
180     optInSettingTypes: {[name: string]: IdlObject} = {};
181     secondaryGroups: IdlObject[] = [];
182     expireDate: Date;
183     changesPending = false;
184     dupeBarcode = false;
185     dupeUsername = false;
186     origUsername: string;
187     stageUser: IdlObject;
188     stageUserRequestor: IdlObject;
189     waiverName: string;
190
191     fieldPatterns: {[cls: string]: {[field: string]: RegExp}} = {
192         au: {},
193         ac: {},
194         aua: {}
195     };
196
197     fieldVisibility: {[key: string]: FieldVisibility} = {};
198
199     holdNotifyValues = {
200         day_phone: null,
201         other_phone: null,
202         evening_phone: null,
203         default_phone: null,
204         default_sms_notify: null,
205         default_sms_carrier: null,
206         phone_notify: false,
207         email_notify: false,
208         sms_notify: false
209     };
210
211     // All locations we have the specified permissions
212     permOrgs: {[name: string]: number[]};
213
214     // True if a given perm is granted at the current home_ou of the
215     // patron we are editing.
216     hasPerm: {[name: string]: boolean} = {};
217
218     holdNotifyTypes: {email?: boolean, phone?: boolean, sms?: boolean} = {};
219
220     fieldDoc: {[cls: string]: {[field: string]: string}} = {};
221
222     constructor(
223         private router: Router,
224         private org: OrgService,
225         private net: NetService,
226         private auth: AuthService,
227         private pcrud: PcrudService,
228         private idl: IdlService,
229         private strings: StringService,
230         private toast: ToastService,
231         private perms: PermService,
232         private evt: EventService,
233         private serverStore: ServerStoreService,
234         private broadcaster: BroadcastService,
235         private patronService: PatronService,
236         private printer: PrintService,
237         private worklog: WorkLogService,
238         public context: PatronContextService
239     ) {}
240
241     ngOnInit() {
242         this.load();
243     }
244
245     ngAfterViewInit() {
246     }
247
248     load(): Promise<any> {
249         this.loading = true;
250         this.showForm = false;
251         return this.setStatCats()
252         .then(_ => this.getFieldDocs())
253         .then(_ => this.setSurveys())
254         .then(_ => this.loadPatron())
255         .then(_ => this.getCloneUser())
256         .then(_ => this.getStageUser())
257         .then(_ => this.getSecondaryGroups())
258         .then(_ => this.applyPerms())
259         .then(_ => this.setEditProfiles())
260         .then(_ => this.setIdentTypes())
261         .then(_ => this.setInetLevels())
262         .then(_ => this.setOptInSettings())
263         .then(_ => this.setSmsCarriers())
264         .then(_ => this.setFieldPatterns())
265         .then(_ => this.showForm = true)
266         // Not my preferred way to handle this, but some values are
267         // applied to widgets slightly after the load() is done and the
268         // widgets are rendered.  If a widget is required and has no
269         // value yet, then a premature save state check will see the
270         // form as invalid and nonsaveable. In order the check for a
271         // non-saveable state on page load without forcing the page into
272         // an nonsaveable state on every page load, check the save state
273         // after a 1 second delay.
274         .then(_ => setTimeout(() => {
275             this.emitSaveState();
276             this.loading = false;
277         }, 1000));
278     }
279
280     setEditProfiles(): Promise<any> {
281         return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise()
282         .then(list => this.grpList = list)
283         .then(_ => this.applyEditProfiles());
284     }
285
286     // TODO
287     // Share the set of forbidden groups with the 2ndary groups selector.
288     applyEditProfiles(): Promise<any> {
289         const appPerms = [];
290         const failedPerms = [];
291         const profiles = this.grpList;
292
293         // extract the application permissions
294         profiles.forEach(grp => {
295             if (grp.application_perm()) {
296                 appPerms.push(grp.application_perm());
297             }
298         });
299
300         const traverseTree = (grp: IdlObject, failed: boolean) => {
301             if (!grp) { return; }
302
303             failed = failed || failedPerms.includes(grp.application_perm());
304
305             if (!failed) { this.editProfiles.push(grp.id()); }
306
307             const children = profiles.filter(p => p.parent() === grp.id());
308             children.forEach(child => traverseTree(child, failed));
309         };
310
311         return this.perms.hasWorkPermAt(appPerms, true).then(orgs => {
312             appPerms.forEach(p => {
313                 if (orgs[p].length === 0) { failedPerms.push(p); }
314                 traverseTree(this.grpList[0], false);
315             });
316         });
317     }
318
319     getCloneUser(): Promise<any> {
320         if (!this.cloneId) { return Promise.resolve(); }
321
322         return this.patronService.getById(this.cloneId,
323             {flesh: 1, flesh_fields: {au: ['addresses']}})
324         .then(cloneUser => {
325             const evt = this.evt.parse(cloneUser);
326             if (evt) { return alert(evt); }
327             this.copyCloneData(cloneUser);
328         });
329     }
330
331     getStageUser(): Promise<any> {
332         if (!this.stageUsername) { return Promise.resolve(); }
333
334         return this.net.request(
335             'open-ils.actor',
336             'open-ils.actor.user.stage.retrieve.by_username',
337             this.auth.token(), this.stageUsername).toPromise()
338
339         .then(suser => {
340             const evt = this.evt.parse(suser);
341             if (evt) {
342                 alert(evt);
343                 return Promise.reject(evt);
344             } else {
345                 this.stageUser = suser;
346             }
347         })
348         .then(_ => {
349
350             const requestor = this.stageUser.user.requesting_usr();
351             if (requestor) {
352                 return this.pcrud.retrieve('au', requestor).toPromise();
353             }
354
355         })
356         .then(reqr => this.stageUserRequestor = reqr)
357         .then(_ => this.copyStageData())
358         .then(_ => this.maintainJuvFlag());
359     }
360
361     copyStageData() {
362         const stageData = this.stageUser;
363         const patron = this.patron;
364
365         Object.keys(this.idl.classes.stgu.field_map).forEach(key => {
366             const field = this.idl.classes.au.field_map[key];
367             if (field && !field.virtual) {
368                 const value = stageData.user[key]();
369                 if (value !== null) {
370                     patron[key](value);
371                 }
372             }
373         });
374
375         // Clear the usrname if it looks like a UUID
376         if (patron.usrname().replace(/-/g, '').match(/[0-9a-f]{32}/)) {
377             patron.usrname('');
378         }
379
380         // Don't use stub address if we have one from the staged user.
381         if (stageData.mailing_addresses.length > 0
382             || stageData.billing_addresses.length > 0) {
383             patron.addresses([]);
384         }
385
386         const addrFromStage = (stageAddr: IdlObject) => {
387             if (!stageAddr) { return; }
388
389             const cls = stageAddr.classname;
390             const addr = this.idl.create('aua');
391
392             addr.isnew(true);
393             addr.id(this.autoId--);
394             addr.valid('t');
395
396             this.strings.interpolate('circ.patron.edit.default_addr_type')
397             .then(msg => addr.address_type(msg));
398
399             Object.keys(this.idl.classes[cls].field_map).forEach(key => {
400                 const field = this.idl.classes.aua.field_map[key];
401                 if (field && !field.virtual) {
402                     const value = stageAddr[key]();
403                     if (value !== null) {
404                         addr[key](value);
405                     }
406                 }
407             });
408
409             patron.addresses().push(addr);
410
411             if (cls === 'stgma') {
412                 patron.mailing_address(addr);
413             } else {
414                 patron.billing_address(addr);
415             }
416         };
417
418         addrFromStage(stageData.mailing_addresses[0]);
419         addrFromStage(stageData.billing_addresses[0]);
420
421         if (patron.addresses().length === 1) {
422             // Only one address, use it for both purposes.
423             const addr = patron.addresses()[0];
424             patron.mailing_address(addr);
425             patron.billing_address(addr);
426         }
427
428         if (stageData.cards[0]) {
429             const card = this.idl.create('ac');
430             card.isnew(true);
431             card.id(this.autoId--);
432             card.barcode(stageData.cards[0].barcode());
433             patron.card(card);
434             patron.cards([card]);
435
436             if (!patron.usrname()) {
437                 patron.usrname(card.barcode());
438             }
439         }
440
441         stageData.settings.forEach(setting => {
442             this.userSettings[setting.setting()] = Boolean(setting.value());
443         });
444
445         stageData.statcats.forEach(entry => {
446
447             entry.statcat(Number(entry.statcat()));
448
449             const stat: StatCat =
450                 this.statCats.filter(s => s.cat.id() === entry.statcat())[0];
451
452             let cboxEntry: ComboboxEntry =
453                 stat.entries.filter(e => e.label === entry.value())[0];
454
455             if (!cboxEntry) {
456                 // If the applied value is not in the list of entries,
457                 // create a freetext combobox entry for it.
458                 cboxEntry = {
459                     id: null,
460                     freetext: true,
461                     label: entry.value()
462                 };
463
464                 stat.entries.unshift(cboxEntry);
465             }
466
467             this.userStatCats[entry.statcat()] = cboxEntry;
468
469             // This forces the creation of the stat cat entry IDL objects.
470             this.userStatCatChange(stat.cat, cboxEntry);
471         });
472
473         if (patron.billing_address()) {
474             this.handlePostCodeChange(
475                 patron.billing_address(), patron.billing_address().post_code());
476         }
477     }
478
479     checkStageUserDupes(): Promise<any> {
480         // Fire duplicate patron checks,once for each category
481
482         const patron = this.patron;
483
484         // Fire-and-forget the email search because it can take several seconds
485         if (patron.email()) {
486             this.dupeValueChange('email', patron.email());
487         }
488
489         return this.dupeValueChange('name', patron.family_name())
490
491         .then(_ => {
492             if (patron.ident_value()) {
493                 return this.dupeValueChange('ident', patron.ident_value());
494             }
495         })
496         .then(_ => {
497             if (patron.day_phone()) {
498                 return this.dupeValueChange('phone', patron.day_phone());
499             }
500         })
501         .then(_ => {
502             let promise = Promise.resolve();
503             this.patron.addresses().forEach(addr => {
504                 promise =
505                     promise.then(__ => this.dupeValueChange('address', addr));
506                 promise =
507                     promise.then(__ => this.toolbar.checkAddressAlerts(patron, addr));
508             });
509         });
510     }
511
512     copyCloneData(clone: IdlObject) {
513         const patron = this.patron;
514
515         // flesh the home org locally
516         patron.home_ou(clone.home_ou());
517
518         ['day_phone', 'evening_phone', 'other_phone', 'usrgroup']
519             .forEach(field => patron[field](clone[field]()));
520
521         // Create a new address from an existing address
522         const cloneAddr = (addr: IdlObject) => {
523             const newAddr = this.idl.clone(addr);
524             newAddr.id(this.autoId--);
525             newAddr.usr(patron.id());
526             newAddr.isnew(true);
527             newAddr.valid('t');
528             return newAddr;
529         };
530
531         const copyAddrs =
532             this.context.settingsCache['circ.patron_edit.clone.copy_address'];
533
534         // No addresses to copy/link.  Stick with the defaults.
535         if (clone.addresses().length === 0) { return; }
536
537         patron.addresses([]);
538
539         clone.addresses().forEach(sourceAddr => {
540
541             const myAddr = copyAddrs ? cloneAddr(sourceAddr) : sourceAddr;
542             if (copyAddrs) { myAddr._linked_owner = clone; }
543
544             if (clone.billing_address() === sourceAddr.id()) {
545                 this.patron.billing_address(myAddr);
546             }
547
548             if (clone.mailing_address() === sourceAddr.id()) {
549                 this.patron.mailing_address(myAddr);
550             }
551
552             this.patron.addresses().push(myAddr);
553         });
554
555         // If we have one type of address but not the other, use the one
556         // we have for both address purposes.
557
558         if (!this.patron.billing_address() && this.patron.mailing_address()) {
559             this.patron.billing_address(this.patron.mailing_address());
560         }
561
562         if (this.patron.billing_address() && !this.patron.mailing_address()) {
563             this.patron.mailing_address(this.patron.billing_address());
564         }
565     }
566
567     getFieldDocs(): Promise<any> {
568         return this.pcrud.search('fdoc', {
569             fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
570         .pipe(tap(doc => {
571             if (!this.fieldDoc[doc.fm_class()]) {
572                 this.fieldDoc[doc.fm_class()] = {};
573             }
574             this.fieldDoc[doc.fm_class()][doc.field()] = doc.string();
575         })).toPromise();
576     }
577
578     getFieldDoc(cls: string, field: string): string {
579         cls = this.getClass(cls);
580         if (this.fieldDoc[cls]) {
581             return this.fieldDoc[cls][field];
582         }
583     }
584
585     exampleText(cls: string, field: string): string {
586         cls = this.getClass(cls);
587         return this.context.settingsCache[`ui.patron.edit.${cls}.${field}.example`];
588     }
589
590     setSurveys(): Promise<any> {
591         return this.patronService.getSurveys()
592         .then(surveys => this.surveys = surveys);
593     }
594
595     surveyQuestionAnswers(question: IdlObject): ComboboxEntry[] {
596         return question.answers().map(
597             a => ({id: a.id(), label: a.answer(), fm: a}));
598     }
599
600     setStatCats(): Promise<any> {
601         this.statCats = [];
602         return this.patronService.getStatCats().then(cats => {
603             cats.forEach(cat => {
604                 cat.id(Number(cat.id()));
605                 cat.entries().forEach(entry => entry.id(Number(entry.id())));
606
607                 const entries = cat.entries().map(entry =>
608                     ({id: entry.id(), label: entry.value()}));
609
610                 this.statCats.push({
611                     cat: cat,
612                     entries: entries
613                 });
614             });
615         });
616     }
617
618     setSmsCarriers(): Promise<any> {
619         if (!this.context.settingsCache['sms.enable']) {
620             return Promise.resolve();
621         }
622
623         return this.patronService.getSmsCarriers().then(carriers => {
624             this.smsCarriers = carriers.map(carrier => {
625                 return {
626                     id: carrier.id(),
627                     label: carrier.name()
628                 };
629             });
630         });
631     }
632
633     getSecondaryGroups(): Promise<any> {
634         return this.net.request(
635             'open-ils.actor',
636             'open-ils.actor.user.get_groups',
637             this.auth.token(), this.patronId
638
639         ).pipe(concatMap(maps => {
640             if (maps.length === 0) { return []; }
641
642             return this.pcrud.search('pgt',
643                 {id: maps.map(m => m.grp())}, {}, {atomic: true});
644
645         })).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
646     }
647
648     setIdentTypes(): Promise<any> {
649         return this.patronService.getIdentTypes()
650         .then(types => {
651             this.identTypes = types.map(t => ({id: t.id(), label: t.name()}));
652         });
653     }
654
655     setInetLevels(): Promise<any> {
656         return this.patronService.getInetLevels()
657         .then(levels => {
658             this.inetLevels = levels.map(t => ({id: t.id(), label: t.name()}));
659         });
660     }
661
662     applyPerms(): Promise<any> {
663
664         const promise = this.permOrgs ?
665             Promise.resolve(this.permOrgs) :
666             this.perms.hasWorkPermAt(PERMS_NEEDED, true);
667
668         return promise.then(permOrgs => {
669             this.permOrgs = permOrgs;
670             Object.keys(permOrgs).forEach(perm =>
671                 this.hasPerm[perm] =
672                   permOrgs[perm].includes(this.patron.home_ou())
673             );
674         });
675     }
676
677     setOptInSettings(): Promise<any> {
678         const orgIds = this.org.ancestors(this.auth.user().ws_ou(), true);
679
680         const query = {
681             '-or' : [
682                 {name : COMMON_USER_SETTING_TYPES},
683                 {name : { // opt-in notification user settings
684                     'in': {
685                         select : {atevdef : ['opt_in_setting']},
686                         from : 'atevdef',
687                         // we only care about opt-in settings for
688                         // event_defs our users encounter
689                         where : {'+atevdef' : {owner : orgIds}}
690                     }
691                 }}
692             ]
693         };
694
695         return this.pcrud.search('cust', query, {}, {atomic : true})
696         .toPromise().then(types => {
697
698             types.forEach(stype => {
699                 this.userSettingTypes[stype.name()] = stype;
700                 if (!COMMON_USER_SETTING_TYPES.includes(stype.name())) {
701                     this.optInSettingTypes[stype.name()] = stype;
702                 }
703             });
704         });
705     }
706
707     loadPatron(): Promise<any> {
708         if (this.patronId) {
709             return this.patronService.getFleshedById(this.patronId, PATRON_FLESH_FIELDS)
710             .then(patron => {
711                 this.patron = patron;
712                 this.origUsername = patron.usrname();
713                 this.absorbPatronData();
714             });
715         } else {
716             return Promise.resolve(this.createNewPatron());
717         }
718     }
719
720     absorbPatronData() {
721
722         const usets = this.userSettings;
723         let setting;
724
725         this.holdNotifyValues.day_phone = this.patron.day_phone();
726         this.holdNotifyValues.other_phone = this.patron.other_phone();
727         this.holdNotifyValues.evening_phone = this.patron.evening_phone();
728
729         this.patron.settings().forEach(stg => {
730             const value = stg.value();
731             if (value !== '' && value !== null) {
732                 usets[stg.name()] = JSON.parse(value);
733             }
734         });
735
736         const holdNotify = usets['opac.hold_notify'];
737
738         if (holdNotify) {
739             this.holdNotifyTypes.email = this.holdNotifyValues.email_notify
740                 = holdNotify.match(/email/) !== null;
741
742             this.holdNotifyTypes.phone = this.holdNotifyValues.phone_notify
743                 = holdNotify.match(/phone/) !== null;
744
745             this.holdNotifyTypes.sms = this.holdNotifyValues.sms_notify
746                 = holdNotify.match(/sms/) !== null;
747         }
748
749         if (setting = usets['opac.default_sms_carrier']) {
750             setting = usets['opac.default_sms_carrier'] = Number(setting);
751             this.holdNotifyValues.default_sms_carrier = setting;
752         }
753
754         if (setting = usets['opac.default_phone']) {
755             this.holdNotifyValues.default_phone = setting;
756         }
757
758         if (setting = usets['opac.default_sms_notify']) {
759             this.holdNotifyValues.default_sms_notify = setting;
760         }
761
762         if (setting = usets['opac.default_pickup_location']) {
763             usets['opac.default_pickup_location'] = Number(setting);
764         }
765
766         this.expireDate = new Date(this.patron.expire_date());
767
768         // stat_cat_entries() are entry maps under the covers.
769         this.patron.stat_cat_entries().forEach(map => {
770
771             const stat: StatCat =
772                 this.statCats.filter(s => s.cat.id() === map.stat_cat())[0];
773
774             let cboxEntry: ComboboxEntry =
775                 stat.entries.filter(e => e.label === map.stat_cat_entry())[0];
776
777             if (!cboxEntry) {
778                 // If the applied value is not in the list of entries,
779                 // create a freetext combobox entry for it.
780                 cboxEntry = {
781                     id: null,
782                     freetext: true,
783                     label: map.stat_cat_entry(),
784                     fm: map
785                 };
786
787                 stat.entries.unshift(cboxEntry);
788             }
789
790             this.userStatCats[map.stat_cat()] = cboxEntry;
791         });
792
793         if (this.patron.waiver_entries().length === 0) {
794             this.addWaiver();
795         }
796
797         if (!this.patron.card()) {
798             this.replaceBarcode();
799         }
800     }
801
802     createNewPatron() {
803         const patron = this.idl.create('au');
804         patron.isnew(true);
805         patron.id(-1);
806         patron.home_ou(this.auth.user().ws_ou());
807         patron.active('t');
808         patron.settings([]);
809         patron.waiver_entries([]);
810         patron.stat_cat_entries([]);
811
812         const card = this.idl.create('ac');
813         card.isnew(true);
814         card.usr(-1);
815         card.id(this.autoId--);
816         patron.card(card);
817         patron.cards([card]);
818
819         const addr = this.idl.create('aua');
820         addr.isnew(true);
821         addr.id(-1);
822         addr.usr(-1);
823         addr.valid('t');
824         addr.within_city_limits('f');
825         addr.country(this.context.settingsCache['ui.patron.default_country']);
826         patron.billing_address(addr);
827         patron.mailing_address(addr);
828         patron.addresses([addr]);
829
830         this.strings.interpolate('circ.patron.edit.default_addr_type')
831         .then(msg => addr.address_type(msg));
832
833         this.serverStore.getItem('ui.patron.default_ident_type')
834         .then(identType => {
835             if (identType) { patron.ident_type(Number(identType)); }
836         });
837
838         this.patron = patron;
839         this.addWaiver();
840     }
841
842     objectFromPath(path: string, index: number): IdlObject {
843         const base = path ? this.patron[path]() : this.patron;
844         if (index === null || index === undefined) {
845             return base;
846         } else {
847             // Some paths lead to an array of objects.
848             return base[index];
849         }
850     }
851
852     getFieldLabel(idlClass: string, field: string, override?: string): string {
853         return override ? override :
854             this.idl.classes[idlClass].field_map[field].label;
855     }
856
857     // With this, the 'cls' specifier is only needed in the template
858     // when it's not 'au', which is the base/common class.
859     getClass(cls: string): string {
860         return cls || 'au';
861     }
862
863     getFieldValue(path: string, index: number, field: string): any {
864         return this.objectFromPath(path, index)[field]();
865     }
866
867     emitSaveState() {
868         // Timeout gives the form a chance to mark fields as (in)valid
869         setTimeout(() => {
870
871             const invalidInput = document.querySelector('.ng-invalid');
872
873             const canSave = (
874                 invalidInput === null
875                 && !this.dupeBarcode
876                 && !this.dupeUsername
877                 && !this.selfEditForbidden()
878                 && !this.groupEditForbidden()
879             );
880
881             if (this.toolbar) {
882                 this.toolbar.disableSaveStateChanged.emit(!canSave);
883             }
884         });
885     }
886
887     adjustSaveState() {
888         // Avoid responding to any value changes while we are loading
889         if (this.loading) { return; }
890         this.changesPending = true;
891         this.emitSaveState();
892     }
893
894     userStatCatChange(cat: IdlObject, entry: ComboboxEntry) {
895         let map = this.patron.stat_cat_entries()
896             .filter(m => m.stat_cat() === cat.id())[0];
897
898         if (map) {
899             if (entry) {
900                 map.stat_cat_entry(entry.label);
901                 map.ischanged(true);
902                 map.isdeleted(false);
903             } else {
904                 if (map.isnew()) {
905                     // Deleting a stat cat that was created during this
906                     // edit session just means removing it from the list
907                     // of maps to consider.
908                     this.patron.stat_cat_entries(
909                         this.patron.stat_cat_entries()
910                             .filter(m => m.stat_cat() !== cat.id())
911                     );
912                 } else {
913                     map.isdeleted(true);
914                 }
915             }
916         } else {
917             map = this.idl.create('actscecm');
918             map.isnew(true);
919             map.stat_cat(cat.id());
920             map.stat_cat_entry(entry.label);
921             map.target_usr(this.patronId);
922             this.patron.stat_cat_entries().push(map);
923         }
924
925         this.adjustSaveState();
926     }
927
928     userSettingChange(name: string, value: any) {
929         this.userSettings[name] = value;
930         this.adjustSaveState();
931     }
932
933     applySurveyResponse(question: IdlObject, answer: ComboboxEntry) {
934         if (!this.patron.survey_responses()) {
935             this.patron.survey_responses([]);
936         }
937
938         const responses = this.patron.survey_responses()
939             .filter(r => r.question() !== question.id());
940
941         const resp = this.idl.create('asvr');
942         resp.isnew(true);
943         resp.survey(question.survey());
944         resp.question(question.id());
945         resp.answer(answer.id);
946         resp.usr(this.patron.id());
947         resp.answer_date('now');
948         responses.push(resp);
949         this.patron.survey_responses(responses);
950     }
951
952     // Called as the model changes.
953     // This may be called many times before the final value is applied,
954     // so avoid any heavy lifting here.  See afterFieldChange();
955     fieldValueChange(path: string, index: number, field: string, value: any) {
956         if (typeof value === 'boolean') { value = value ? 't' : 'f'; }
957
958         // This can be called in cases where components fire up, even
959         // though the actual value on the patron has not changed.
960         // Exit early in that case so we don't mark the form as dirty.
961         const oldValue = this.getFieldValue(path, index, field);
962         if (oldValue === value) { return; }
963
964         this.changeHandlerNeeded = true;
965         this.objectFromPath(path, index)[field](value);
966     }
967
968     // Called after a change operation has completed (e.g. on blur)
969     afterFieldChange(path: string, index: number, field: string) {
970         if (!this.changeHandlerNeeded) { return; } // no changes applied
971         this.changeHandlerNeeded = false;
972
973         const obj = this.objectFromPath(path, index);
974         const value = this.getFieldValue(path, index, field);
975         obj.ischanged(true); // isnew() supersedes
976
977         console.debug(
978             `Modifying field path=${path || ''} field=${field} value=${value}`);
979
980         switch (field) {
981
982             case 'dob':
983                 this.maintainJuvFlag();
984                 break;
985
986             case 'profile':
987                 this.setExpireDate();
988                 break;
989
990             case 'day_phone':
991             case 'evening_phone':
992             case 'other_phone':
993                 this.handlePhoneChange(field, value);
994                 break;
995
996             case 'ident_value':
997             case 'ident_value2':
998             case 'first_given_name':
999             case 'family_name':
1000             case 'email':
1001                 this.dupeValueChange(field, value);
1002                 break;
1003
1004             case 'street1':
1005             case 'street2':
1006             case 'city':
1007                 // dupe search on address wants the address object as the value.
1008                 this.dupeValueChange('address', obj);
1009                 this.toolbar.checkAddressAlerts(this.patron, obj);
1010                 break;
1011
1012             case 'post_code':
1013                 this.handlePostCodeChange(obj, value);
1014                 break;
1015
1016             case 'barcode':
1017                 this.handleBarcodeChange(value);
1018                 break;
1019
1020             case 'usrname':
1021                 this.handleUsernameChange(value);
1022                 break;
1023         }
1024
1025         this.adjustSaveState();
1026     }
1027
1028     maintainJuvFlag() {
1029
1030         if (!this.patron.dob()) { return; }
1031
1032         const interval =
1033             this.context.settingsCache['global.juvenile_age_threshold']
1034             || '18 years';
1035
1036         const cutoff = new Date();
1037
1038         cutoff.setTime(cutoff.getTime() -
1039             Number(DateUtil.intervalToSeconds(interval) + '000'));
1040
1041         const isJuve = new Date(this.patron.dob()) > cutoff;
1042
1043         this.fieldValueChange(null, null, 'juvenile', isJuve);
1044         this.afterFieldChange(null, null, 'juvenile');
1045     }
1046
1047     handlePhoneChange(field: string, value: string) {
1048         this.dupeValueChange(field, value);
1049
1050         const pwUsePhone =
1051             this.context.settingsCache['patron.password.use_phone'];
1052
1053         if (field === 'day_phone' && value &&
1054             this.patron.isnew() && !this.patron.passwd() && pwUsePhone) {
1055             this.fieldValueChange(null, null, 'passwd', value.substr(-4));
1056             this.afterFieldChange(null, null, 'passwd');
1057         }
1058     }
1059
1060     handlePostCodeChange(addr: IdlObject, postCode: any) {
1061         this.net.request(
1062             'open-ils.search', 'open-ils.search.zip', postCode
1063         ).subscribe(resp => {
1064             if (!resp) { return; }
1065
1066             ['city', 'state', 'county'].forEach(field => {
1067                 if (resp[field]) {
1068                     addr[field](resp[field]);
1069                 }
1070             });
1071
1072             if (resp.alert) {
1073                 this.addrAlert.dialogBody = resp.alert;
1074                 this.addrAlert.open();
1075             }
1076         });
1077     }
1078
1079     handleUsernameChange(value: any) {
1080         this.dupeUsername = false;
1081
1082         if (!value || value === this.origUsername) {
1083             // In case the usrname changes then changes back.
1084             return;
1085         }
1086
1087         this.net.request(
1088             'open-ils.actor',
1089             'open-ils.actor.username.exists',
1090             this.auth.token(), value
1091         ).subscribe(resp => this.dupeUsername = Boolean(resp));
1092     }
1093
1094     handleBarcodeChange(value: any) {
1095         this.dupeBarcode = false;
1096
1097         if (!value) { return; }
1098
1099         this.net.request(
1100             'open-ils.actor',
1101             'open-ils.actor.barcode.exists',
1102             this.auth.token(), value
1103         ).subscribe(resp => {
1104             if (Number(resp) === 1) {
1105                 this.dupeBarcode = true;
1106             } else {
1107
1108                 if (this.patron.usrname()) { return; }
1109
1110                 // Propagate username with barcode value by default.
1111                 // This will apply the value and fire the dupe checker
1112                 this.updateUsernameRegex();
1113                 this.fieldValueChange(null, null, 'usrname', value);
1114                 this.afterFieldChange(null, null, 'usrname');
1115             }
1116         });
1117     }
1118
1119     dupeValueChange(name: string, value: any) {
1120
1121         if (name.match(/phone/)) { name = 'phone'; }
1122         if (name.match(/name/)) { name = 'name'; }
1123         if (name.match(/ident/)) { name = 'ident'; }
1124
1125         let search: PatronSearchFieldSet;
1126         switch (name) {
1127
1128             case 'name':
1129                 const fname = this.patron.first_given_name();
1130                 const lname = this.patron.family_name();
1131                 if (!fname || !lname) { return; }
1132                 search = {
1133                     first_given_name : {value : fname, group : 0},
1134                     family_name : {value : lname, group : 0}
1135                 };
1136                 break;
1137
1138             case 'email':
1139                 search = {email : {value : value, group : 0}};
1140                 break;
1141
1142             case 'ident':
1143                 search = {ident : {value : value, group : 2}};
1144                 break;
1145
1146             case 'phone':
1147                 search = {phone : {value : value, group : 2}};
1148                 break;
1149
1150             case 'address':
1151                 search = {};
1152                 ['street1', 'street2', 'city', 'post_code'].forEach(field => {
1153                     if (value[field]()) {
1154                         search[field] = {value : value[field](), group: 1};
1155                     }
1156                 });
1157                 break;
1158         }
1159
1160         this.toolbar.checkDupes(name, search);
1161     }
1162
1163     showField(field: string): boolean {
1164
1165         if (this.fieldVisibility[field] === undefined) {
1166             // Settings have not yet been applied for this field.
1167             // Calculate them now.
1168
1169             // The preferred name fields use the primary name field settings
1170             let settingKey = field;
1171             let altName = false;
1172             if (field.match(/^au.alt_/)) {
1173                 altName = true;
1174                 settingKey = field.replace(/alt_/, '');
1175             }
1176
1177             const required = `ui.patron.edit.${settingKey}.require`;
1178             const show = `ui.patron.edit.${settingKey}.show`;
1179             const suggest = `ui.patron.edit.${settingKey}.suggest`;
1180
1181             if (this.context.settingsCache[required]) {
1182                 if (altName) {
1183                     // Preferred name fields are never required.
1184                     this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1185                 } else {
1186                     this.fieldVisibility[field] = FieldVisibility.REQUIRED;
1187                 }
1188
1189             } else if (this.context.settingsCache[show]) {
1190                 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1191
1192             } else if (this.context.settingsCache[suggest]) {
1193                 this.fieldVisibility[field] = FieldVisibility.SUGGESTED;
1194             }
1195         }
1196
1197         if (this.fieldVisibility[field] === undefined) {
1198             // No org settings were applied above.  Use the default
1199             // settings if present or assume the field has no
1200             // visibility flags applied.
1201             this.fieldVisibility[field] = DEFAULT_FIELD_VISIBILITY[field] || 0;
1202         }
1203
1204         return this.fieldVisibility[field] >= this.toolbar.visibilityLevel;
1205     }
1206
1207     fieldRequired(field: string): boolean {
1208
1209         switch (field) {
1210             case 'au.passwd':
1211                 // Only required for new patrons
1212                 return this.patronId === null;
1213
1214             case 'au.email':
1215                 // If the user ops in for email notices, require
1216                 // an email address
1217                 return this.holdNotifyTypes.email;
1218         }
1219
1220         return this.fieldVisibility[field] === 3;
1221     }
1222
1223     settingFieldRequired(name: string): boolean {
1224
1225         switch (name) {
1226             case 'opac.default_sms_notify':
1227             case 'opac.default_sms_carrier':
1228                 return this.holdNotifyTypes.sms;
1229         }
1230
1231         return false;
1232     }
1233
1234     fieldPattern(idlClass: string, field: string): RegExp {
1235         if (!this.fieldPatterns[idlClass][field]) {
1236             this.fieldPatterns[idlClass][field] = new RegExp('.*');
1237         }
1238         return this.fieldPatterns[idlClass][field];
1239     }
1240
1241     generatePassword() {
1242         this.fieldValueChange(null, null,
1243           'passwd', Math.floor(Math.random() * 9000) + 1000);
1244
1245         // Normally this is called on (blur), but the input is not
1246         // focused when using the generate button.
1247         this.afterFieldChange(null, null, 'passwd');
1248     }
1249
1250
1251     cannotHaveUsersOrgs(): number[] {
1252         return this.org.list()
1253           .filter(org => org.ou_type().can_have_users() === 'f')
1254           .map(org => org.id());
1255     }
1256
1257     cannotHaveVolsOrgs(): number[] {
1258         return this.org.list()
1259           .filter(org => org.ou_type().can_have_vols() === 'f')
1260           .map(org => org.id());
1261     }
1262
1263     setExpireDate() {
1264         const profile = this.profileSelect.profiles[this.patron.profile()];
1265         if (!profile) { return; }
1266
1267         const seconds = DateUtil.intervalToSeconds(profile.perm_interval());
1268         const nowEpoch = new Date().getTime();
1269         const newDate = new Date(nowEpoch + (seconds * 1000 /* millis */));
1270         this.expireDate = newDate;
1271         this.fieldValueChange(null, null, 'expire_date', newDate.toISOString());
1272         this.afterFieldChange(null, null, 'expire_date');
1273     }
1274
1275     handleBoolResponse(success: boolean,
1276         msg: string, errMsg?: string): Promise<boolean> {
1277
1278         if (success) {
1279             return this.strings.interpolate(msg)
1280             .then(str => this.toast.success(str))
1281             .then(_ => true);
1282         }
1283
1284       console.error(errMsg);
1285
1286       return this.strings.interpolate(msg)
1287       .then(str => this.toast.danger(str))
1288       .then(_ => false);
1289     }
1290
1291     sendTestMessage(hook: string): Promise<boolean> {
1292
1293         return this.net.request(
1294             'open-ils.actor',
1295             'open-ils.actor.event.test_notification',
1296             this.auth.token(), {hook: hook, target: this.patronId}
1297         ).toPromise().then(resp => {
1298
1299             if (resp && resp.template_output && resp.template_output() &&
1300                 resp.template_output().is_error() === 'f') {
1301                 return this.handleBoolResponse(
1302                     true, 'circ.patron.edit.test_notify.success');
1303
1304             } else {
1305                 return this.handleBoolResponse(
1306                     false, 'circ.patron.edit.test_notify.fail',
1307                     'Test Notification Failed ' + resp);
1308             }
1309         });
1310     }
1311
1312     invalidateField(field: string): Promise<boolean> {
1313
1314         return this.net.request(
1315             'open-ils.actor',
1316             'open-ils.actor.invalidate.' + field,
1317             this.auth.token(), this.patronId, null, this.patron.home_ou()
1318
1319         ).toPromise().then(resp => {
1320             const evt = this.evt.parse(resp);
1321
1322             if (evt && evt.textcode !== 'SUCCESS') {
1323                 return this.handleBoolResponse(false,
1324                     'circ.patron.edit.invalidate.fail',
1325                     'Field Invalidation Failed: ' + resp);
1326             }
1327
1328             this.patron[field](null);
1329
1330             // Keep this in sync for future updates.
1331             this.patron.last_xact_id(resp.payload.last_xact_id[this.patronId]);
1332
1333             return this.handleBoolResponse(
1334               true, 'circ.patron.edit.invalidate.success');
1335         });
1336     }
1337
1338     openGroupsDialog() {
1339         this.secondaryGroupsDialog.open({size: 'lg'}).subscribe(groups => {
1340             if (!groups) { return; }
1341
1342             this.secondaryGroups = groups;
1343
1344             if (this.patron.isnew()) {
1345                 // Links will be applied after the patron is created.
1346                 return;
1347             }
1348
1349             // Apply the new links to an existing user in real time
1350             this.applySecondaryGroups();
1351         });
1352     }
1353
1354     applySecondaryGroups(): Promise<boolean> {
1355
1356         const groupIds = this.secondaryGroups.map(grp => grp.id());
1357
1358         return this.net.request(
1359             'open-ils.actor',
1360             'open-ils.actor.user.set_groups',
1361             this.auth.token(), this.patronId, groupIds
1362         ).toPromise().then(resp => {
1363
1364             if (Number(resp) === 1) {
1365                 return this.handleBoolResponse(
1366                     true, 'circ.patron.edit.grplink.success');
1367
1368             } else {
1369                 return this.handleBoolResponse(
1370                     false, 'circ.patron.edit.grplink.fail',
1371                     'Failed to change group links: ' + resp);
1372             }
1373         });
1374     }
1375
1376     // Set the mailing or billing address
1377     setAddrType(addrType: string, addr: IdlObject, selected: boolean) {
1378         if (selected) {
1379             this.patron[addrType + '_address'](addr);
1380         } else {
1381             // Unchecking mailing/billing means we have to randomly
1382             // select another address to fill that role.  Select the
1383             // first address in the list (that does not match the
1384             // modifed address)
1385             let found = false;
1386             this.patron.addresses().some(a => {
1387                 if (a.id() !== addr.id()) {
1388                     this.patron[addrType + '_address'](a);
1389                     return found = true;
1390                 }
1391             });
1392
1393             if (!found) {
1394                 // No alternate address was found.  Clear the value.
1395                 this.patron[addrType + '_address'](null);
1396             }
1397
1398             this.patron.ischanged(true);
1399         }
1400     }
1401
1402     deleteAddr(addr: IdlObject) {
1403         const addresses = this.patron.addresses();
1404         let promise = Promise.resolve(false);
1405
1406         if (this.patron.isnew() && addresses.length === 1) {
1407             promise = this.serverStore.getItem(
1408                 'ui.patron.registration.require_address');
1409         }
1410
1411         promise.then(required => {
1412
1413             if (required) {
1414                 this.addrRequiredAlert.open();
1415                 return;
1416             }
1417
1418             // Roll the mailing/billing designation to another
1419             // address when needed.
1420             if (this.patron.mailing_address() &&
1421                 this.patron.mailing_address().id() === addr.id()) {
1422                 this.setAddrType('mailing', addr, false);
1423             }
1424
1425             if (this.patron.billing_address() &&
1426                 this.patron.billing_address().id() === addr.id()) {
1427                 this.setAddrType('billing', addr, false);
1428             }
1429
1430             if (addr.isnew()) {
1431                 let idx = 0;
1432
1433                 addresses.some((a, i) => {
1434                     if (a.id() === addr.id()) { idx = i; return true; }
1435                 });
1436
1437                 // New addresses can be discarded
1438                 addresses.splice(idx, 1);
1439
1440             } else {
1441                 addr.isdeleted(true);
1442             }
1443         });
1444     }
1445
1446     newAddr() {
1447         const addr = this.idl.create('aua');
1448         addr.id(this.autoId--);
1449         addr.isnew(true);
1450         addr.valid('t');
1451         this.patron.addresses().push(addr);
1452     }
1453
1454     nonDeletedAddresses(): IdlObject[] {
1455         return this.patron.addresses().filter(a => !a.isdeleted());
1456     }
1457
1458     save(clone?: boolean): Promise<any> {
1459
1460         this.changesPending = false;
1461         this.loading = true;
1462         this.showForm = false;
1463
1464         return this.saveUser()
1465         .then(_ => this.saveUserSettings())
1466         .then(_ => this.updateHoldPrefs())
1467         .then(_ => this.removeStagedUser())
1468         .then(_ => this.postSaveRedirect(clone));
1469     }
1470
1471     postSaveRedirect(clone: boolean) {
1472
1473         this.worklog.record({
1474             user: this.modifiedPatron.family_name(),
1475             patron_id: this.modifiedPatron.id(),
1476             action: this.patron.isnew() ? 'registered_patron' : 'edited_patron'
1477         });
1478
1479         if (this.stageUser) {
1480             this.broadcaster.broadcast('eg.pending_usr.update',
1481                 {usr: this.idl.toHash(this.modifiedPatron)});
1482
1483             // Typically, this window is opened as a new tab from the
1484             // pending users interface. Once we're done, just close the
1485             // window.
1486             window.close();
1487             return;
1488         }
1489
1490         if (clone) {
1491             this.context.summary = null;
1492             this.router.navigate(
1493                 ['/staff/circ/patron/register/clone', this.modifiedPatron.id()]);
1494
1495         } else {
1496             // Full refresh to force reload of modified patron data.
1497             window.location.href = window.location.href;
1498         }
1499     }
1500
1501     // Resolves on success, rejects on error
1502     saveUser(): Promise<IdlObject> {
1503         this.modifiedPatron = null;
1504
1505         // A dummy waiver is added on load.  Remove it if no values were added.
1506         this.patron.waiver_entries(
1507             this.patron.waiver_entries().filter(e => !e.isnew() || e.name()));
1508
1509         return this.net.request(
1510             'open-ils.actor',
1511             'open-ils.actor.patron.update',
1512             this.auth.token(), this.patron
1513         ).toPromise().then(result => {
1514
1515             if (result && result.classname) {
1516                 this.context.addRecentPatron(result.id());
1517
1518                 // Successful result returns the patron IdlObject.
1519                 return this.modifiedPatron = result;
1520             }
1521
1522             const evt = this.evt.parse(result);
1523
1524             if (evt) {
1525                 console.error('Patron update failed with', evt);
1526                 if (evt.textcode === 'XACT_COLLISION') {
1527                     this.xactCollisionAlert.open().toPromise().then(_ =>
1528                         window.location.href = window.location.href
1529                     );
1530                 }
1531             } else {
1532
1533                 alert('Patron update failed:' + result);
1534             }
1535
1536             return Promise.reject('Save Failed');
1537         });
1538     }
1539
1540     // Resolves on success, rejects on error
1541     saveUserSettings(): Promise<any> {
1542
1543         let settings: any = {};
1544
1545         const holdMethods = [];
1546
1547         ['email', 'phone', 'sms'].forEach(method => {
1548             if (this.holdNotifyTypes[method]) {
1549                 holdMethods.push(method);
1550             }
1551         });
1552
1553         this.userSettings['opac.hold_notify'] =
1554             holdMethods.length > 0 ?  holdMethods.join(':') : null;
1555
1556         if (this.patronId) {
1557             // Update all user editor setting values for existing
1558             // users regardless of whether a value changed.
1559             settings = this.userSettings;
1560
1561         } else {
1562
1563             // Create settings for all non-null setting values for new patrons.
1564             Object.keys(this.userSettings).forEach(key => {
1565                 const val = this.userSettings[key];
1566                 if (val !== null) { settings[key] = val; }
1567             });
1568         }
1569
1570         if (Object.keys(settings).length === 0) { return Promise.resolve(); }
1571
1572         return this.net.request(
1573             'open-ils.actor',
1574             'open-ils.actor.patron.settings.update',
1575             this.auth.token(), this.modifiedPatron.id(), settings
1576         ).toPromise();
1577     }
1578
1579
1580     updateHoldPrefs(): Promise<any> {
1581         if (this.patron.isnew()) { return Promise.resolve(); }
1582
1583         return this.collectHoldNotifyChange()
1584         .then(mods => {
1585
1586             if (mods.length === 0) { return Promise.resolve(); }
1587
1588             this.holdNotifyUpdateDialog.patronId = this.patronId;
1589             this.holdNotifyUpdateDialog.mods = mods;
1590             this.holdNotifyUpdateDialog.smsCarriers = this.smsCarriers;
1591
1592             this.holdNotifyUpdateDialog.defaultCarrier =
1593                 this.userSettings['opac.default_sms_carrier']
1594                 || this.holdNotifyValues.default_sms_carrier;
1595
1596             return this.holdNotifyUpdateDialog.open().toPromise();
1597         });
1598     }
1599
1600     // Compare current values with those collected at patron load time.
1601     // For any that have changed, ask the server if the original values
1602     // are used on active holds.
1603     collectHoldNotifyChange(): Promise<any[]> {
1604         const mods = [];
1605         const holdNotify = this.userSettings['opac.hold_notify'] || '';
1606
1607         return from(Object.keys(this.holdNotifyValues))
1608         .pipe(concatMap(field => {
1609
1610             let newValue, matches;
1611
1612             if (field.match(/default_/)) {
1613                 newValue = this.userSettings[`opac.${field}`] || null;
1614
1615             } else if (field.match(/_phone/)) {
1616                 newValue = this.patron[field]();
1617
1618             } else if (matches = field.match(/(\w+)_notify/)) {
1619                 const notify = this.userSettings['opac.hold_notify'] || '';
1620                 newValue = notify.match(matches[1]) !== null;
1621             }
1622
1623             const oldValue = this.holdNotifyValues[field];
1624
1625             // No change to apply?
1626             if (newValue === oldValue) { return empty(); }
1627
1628             // API / user setting name mismatch
1629             if (field.match(/carrier/)) { field += '_id'; }
1630
1631             const apiValue = field.match(/notify|carrier/) ? oldValue : newValue;
1632
1633             return this.net.request(
1634                 'open-ils.circ',
1635                 'open-ils.circ.holds.retrieve_by_notify_staff',
1636                 this.auth.token(), this.patronId, apiValue, field
1637             ).pipe(tap(holds => {
1638                 if (holds && holds.length > 0) {
1639                     mods.push({
1640                         field: field,
1641                         newValue: newValue,
1642                         oldValue: oldValue,
1643                         holds: holds
1644                     });
1645                 }
1646             }));
1647         })).toPromise().then(_ => mods);
1648     }
1649
1650     removeStagedUser(): Promise<any> {
1651         if (!this.stageUser) { return Promise.resolve(); }
1652
1653         return this.net.request(
1654             'open-ils.actor',
1655             'open-ils.actor.user.stage.delete',
1656             this.auth.token(),
1657             this.stageUser.user.row_id()
1658         ).toPromise();
1659     }
1660
1661     printPatron() {
1662         this.printer.print({
1663             templateName: 'patron_data',
1664             contextData: {patron: this.patron},
1665             printContext: 'default'
1666         });
1667     }
1668
1669     replaceBarcode() {
1670         // Disable current card
1671
1672         this.replaceBarcodeUsed = true;
1673
1674         if (this.patron.card()) {
1675             // patron.card() is not the same in-memory object as its
1676             // analog in patron.cards().  Since we're about to replace
1677             // patron.card() anyway, just update the patron.cards() version.
1678             const crd = this.patron.cards()
1679                 .filter(c => c.id() === this.patron.card().id())[0];
1680
1681             crd.active('f');
1682             crd.ischanged(true);
1683         }
1684
1685         const card = this.idl.create('ac');
1686         card.isnew(true);
1687         card.id(this.autoId--);
1688         card.usr(this.patron.id());
1689         card.active('t');
1690
1691         this.patron.card(card);
1692         this.patron.cards().push(card);
1693
1694         // Focus the barcode input
1695         setTimeout(() => {
1696             this.emitSaveState();
1697             const node = document.getElementById('ac-barcode-input');
1698             node.focus();
1699         });
1700     }
1701
1702     showBarcodes() {
1703     }
1704
1705     canSave(): boolean {
1706         return document.querySelector('.ng-invalid') === null;
1707     }
1708
1709     setFieldPatterns() {
1710         let regex;
1711
1712         if (regex =
1713             this.context.settingsCache['ui.patron.edit.ac.barcode.regex']) {
1714             this.fieldPatterns.ac.barcode = new RegExp(regex);
1715         }
1716
1717         if (regex = this.context.settingsCache['global.password_regex']) {
1718             this.fieldPatterns.au.passwd = new RegExp(regex);
1719         }
1720
1721         if (regex = this.context.settingsCache['ui.patron.edit.phone.regex']) {
1722             // apply generic phone regex first, replace below as needed.
1723             this.fieldPatterns.au.day_phone = new RegExp(regex);
1724             this.fieldPatterns.au.evening_phone = new RegExp(regex);
1725             this.fieldPatterns.au.other_phone = new RegExp(regex);
1726         }
1727
1728         // the remaining this.fieldPatterns fit a well-known key name pattern
1729
1730         Object.keys(this.context.settingsCache).forEach(key => {
1731             const val = this.context.settingsCache[key];
1732             if (!val) { return; }
1733             const parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
1734             if (!parts) { return; }
1735             const cls = parts[1];
1736             const name = parts[2];
1737             this.fieldPatterns[cls][name] = new RegExp(val);
1738         });
1739
1740         this.updateUsernameRegex();
1741     }
1742
1743     // The username must match either the configured regex or the
1744     // patron's barcode
1745     updateUsernameRegex() {
1746         const regex = this.context.settingsCache['opac.username_regex'];
1747         if (regex) {
1748             const barcode = this.patron.card().barcode();
1749             if (barcode) {
1750                 this.fieldPatterns.au.usrname =
1751                     new RegExp(`${regex}|^${barcode}$`);
1752             } else {
1753                 // username must match the regex
1754                 this.fieldPatterns.au.usrname = new RegExp(regex);
1755             }
1756         } else {
1757             // username can be any format.
1758             this.fieldPatterns.au.usrname = new RegExp('.*');
1759         }
1760     }
1761
1762     selfEditForbidden(): boolean {
1763         return (
1764             this.patron.id() === this.auth.user().id()
1765             && !this.hasPerm.EDIT_SELF_IN_CLIENT
1766         );
1767     }
1768
1769     groupEditForbidden(): boolean {
1770         return (
1771             this.patron.profile()
1772             && !this.editProfiles.includes(this.patron.profile())
1773         );
1774     }
1775
1776     addWaiver() {
1777         const waiver = this.idl.create('aupw');
1778         waiver.isnew(true);
1779         waiver.id(this.autoId--);
1780         waiver.usr(this.patronId);
1781         this.patron.waiver_entries().push(waiver);
1782     }
1783
1784     removeWaiver(waiver: IdlObject) {
1785         if (waiver.isnew()) {
1786             this.patron.waiver_entries(
1787                 this.patron.waiver_entries().filter(w => w.id() !== waiver.id()));
1788
1789             if (this.patron.waiver_entries().length === 0) {
1790                 // We need at least one waiver to access action buttons
1791                 this.addWaiver();
1792             }
1793         } else {
1794             waiver.isdeleted(true);
1795         }
1796     }
1797 }
1798
1799