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