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