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';
30 const PATRON_FLESH_FIELDS = [
45 const COMMON_USER_SETTING_TYPES = [
46 'circ.holds_behind_desk',
47 'circ.collections.exempt',
50 'opac.default_pickup_location',
51 'opac.default_sms_carrier',
52 'opac.default_sms_notify'
55 const PERMS_NEEDED = [
56 'EDIT_SELF_IN_CLIENT',
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'
67 enum FieldVisibility {
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
107 entries: ComboboxEntry[];
111 templateUrl: 'edit.component.html',
112 selector: 'eg-patron-edit',
113 styleUrls: ['edit.component.css']
115 export class EditComponent implements OnInit, AfterViewInit {
117 @Input() patronId: number = null;
118 @Input() cloneId: number = null;
119 @Input() stageUsername: string = null;
121 _toolbar: EditToolbarComponent;
122 @Input() set toolbar(tb: EditToolbarComponent) {
123 if (tb !== this._toolbar) {
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.
130 tb.saveClicked.subscribe(_ => this.save());
131 tb.saveCloneClicked.subscribe(_ => this.save(true));
132 tb.printClicked.subscribe(_ => this.printPatron());
137 get toolbar(): EditToolbarComponent {
138 return this._toolbar;
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;
156 modifiedPatron: IdlObject;
157 changeHandlerNeeded = false;
160 // Are we still fetching data and applying values?
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
169 surveys: IdlObject[];
170 smsCarriers: ComboboxEntry[];
171 identTypes: ComboboxEntry[];
172 inetLevels: ComboboxEntry[];
173 statCats: StatCat[] = [];
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[] = [];
182 changesPending = false;
184 dupeUsername = false;
185 origUsername: string;
186 stageUser: IdlObject;
187 stageUserRequestor: IdlObject;
190 fieldPatterns: {[cls: string]: {[field: string]: RegExp}} = {
196 fieldVisibility: {[key: string]: FieldVisibility} = {};
203 default_sms_notify: null,
204 default_sms_carrier: null,
210 // All locations we have the specified permissions
211 permOrgs: {[name: string]: number[]};
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} = {};
217 holdNotifyTypes: {email?: boolean, phone?: boolean, sms?: boolean} = {};
219 fieldDoc: {[cls: string]: {[field: string]: string}} = {};
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
247 load(): Promise<any> {
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;
279 setEditProfiles(): Promise<any> {
280 return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise()
281 .then(list => this.grpList = list)
282 .then(_ => this.applyEditProfiles());
286 // Share the set of forbidden groups with the 2ndary groups selector.
287 applyEditProfiles(): Promise<any> {
289 const failedPerms = [];
290 const profiles = this.grpList;
292 // extract the application permissions
293 profiles.forEach(grp => {
294 if (grp.application_perm()) {
295 appPerms.push(grp.application_perm());
299 const traverseTree = (grp: IdlObject, failed: boolean) => {
300 if (!grp) { return; }
302 failed = failed || failedPerms.includes(grp.application_perm());
304 if (!failed) { this.editProfiles.push(grp.id()); }
306 const children = profiles.filter(p => p.parent() === grp.id());
307 children.forEach(child => traverseTree(child, failed));
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);
318 getCloneUser(): Promise<any> {
319 if (!this.cloneId) { return Promise.resolve(); }
321 return this.patronService.getById(this.cloneId,
322 {flesh: 1, flesh_fields: {au: ['addresses']}})
324 const evt = this.evt.parse(cloneUser);
325 if (evt) { return alert(evt); }
326 this.copyCloneData(cloneUser);
330 getStageUser(): Promise<any> {
331 if (!this.stageUsername) { return Promise.resolve(); }
333 return this.net.request(
335 'open-ils.actor.user.stage.retrieve.by_username',
336 this.auth.token(), this.stageUsername).toPromise()
339 const evt = this.evt.parse(suser);
342 return Promise.reject(evt);
344 this.stageUser = suser;
349 const requestor = this.stageUser.user.requesting_usr();
351 return this.pcrud.retrieve('au', requestor).toPromise();
355 .then(reqr => this.stageUserRequestor = reqr)
356 .then(_ => this.copyStageData())
357 .then(_ => this.maintainJuvFlag());
361 const stageData = this.stageUser;
362 const patron = this.patron;
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) {
374 // Clear the usrname if it looks like a UUID
375 if (patron.usrname().replace(/-/g, '').match(/[0-9a-f]{32}/)) {
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([]);
385 const addrFromStage = (stageAddr: IdlObject) => {
386 if (!stageAddr) { return; }
388 const cls = stageAddr.classname;
389 const addr = this.idl.create('aua');
392 addr.id(this.autoId--);
395 this.strings.interpolate('circ.patron.edit.default_addr_type')
396 .then(msg => addr.address_type(msg));
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) {
408 patron.addresses().push(addr);
410 if (cls === 'stgma') {
411 patron.mailing_address(addr);
413 patron.billing_address(addr);
417 addrFromStage(stageData.mailing_addresses[0]);
418 addrFromStage(stageData.billing_addresses[0]);
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);
427 if (stageData.cards[0]) {
428 const card = this.idl.create('ac');
430 card.id(this.autoId--);
431 card.barcode(stageData.cards[0].barcode());
433 patron.cards([card]);
435 if (!patron.usrname()) {
436 patron.usrname(card.barcode());
440 stageData.settings.forEach(setting => {
441 this.userSettings[setting.setting()] = Boolean(setting.value());
444 stageData.statcats.forEach(entry => {
446 entry.statcat(Number(entry.statcat()));
448 const stat: StatCat =
449 this.statCats.filter(s => s.cat.id() === entry.statcat())[0];
451 let cboxEntry: ComboboxEntry =
452 stat.entries.filter(e => e.label === entry.value())[0];
455 // If the applied value is not in the list of entries,
456 // create a freetext combobox entry for it.
463 stat.entries.unshift(cboxEntry);
466 this.userStatCats[entry.statcat()] = cboxEntry;
468 // This forces the creation of the stat cat entry IDL objects.
469 this.userStatCatChange(stat.cat, cboxEntry);
472 if (patron.billing_address()) {
473 this.handlePostCodeChange(
474 patron.billing_address(), patron.billing_address().post_code());
478 checkStageUserDupes(): Promise<any> {
479 // Fire duplicate patron checks,once for each category
481 const patron = this.patron;
483 // Fire-and-forget the email search because it can take several seconds
484 if (patron.email()) {
485 this.dupeValueChange('email', patron.email());
488 return this.dupeValueChange('name', patron.family_name())
491 if (patron.ident_value()) {
492 return this.dupeValueChange('ident', patron.ident_value());
496 if (patron.day_phone()) {
497 return this.dupeValueChange('phone', patron.day_phone());
501 let promise = Promise.resolve();
502 this.patron.addresses().forEach(addr => {
504 promise.then(__ => this.dupeValueChange('address', addr));
509 copyCloneData(clone: IdlObject) {
510 const patron = this.patron;
512 // flesh the home org locally
513 patron.home_ou(clone.home_ou());
515 ['day_phone', 'evening_phone', 'other_phone', 'usrgroup']
516 .forEach(field => patron[field](clone[field]()));
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());
529 this.context.settingsCache['circ.patron_edit.clone.copy_address'];
531 // No addresses to copy/link. Stick with the defaults.
532 if (clone.addresses().length === 0) { return; }
534 patron.addresses([]);
536 clone.addresses().forEach(sourceAddr => {
538 const myAddr = copyAddrs ? cloneAddr(sourceAddr) : sourceAddr;
539 if (copyAddrs) { myAddr._linked_owner = clone; }
541 if (clone.billing_address() === sourceAddr.id()) {
542 this.patron.billing_address(myAddr);
545 if (clone.mailing_address() === sourceAddr.id()) {
546 this.patron.mailing_address(myAddr);
549 this.patron.addresses().push(myAddr);
552 // If we have one type of address but not the other, use the one
553 // we have for both address purposes.
555 if (!this.patron.billing_address() && this.patron.mailing_address()) {
556 this.patron.billing_address(this.patron.mailing_address());
559 if (this.patron.billing_address() && !this.patron.mailing_address()) {
560 this.patron.mailing_address(this.patron.billing_address());
564 getFieldDocs(): Promise<any> {
565 return this.pcrud.search('fdoc', {
566 fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
568 if (!this.fieldDoc[doc.fm_class()]) {
569 this.fieldDoc[doc.fm_class()] = {};
571 this.fieldDoc[doc.fm_class()][doc.field()] = doc.string();
575 getFieldDoc(cls: string, field: string): string {
576 cls = this.getClass(cls);
577 if (this.fieldDoc[cls]) {
578 return this.fieldDoc[cls][field];
582 exampleText(cls: string, field: string): string {
583 cls = this.getClass(cls);
584 return this.context.settingsCache[`ui.patron.edit.${cls}.${field}.example`];
587 setSurveys(): Promise<any> {
588 return this.patronService.getSurveys()
589 .then(surveys => this.surveys = surveys);
592 surveyQuestionAnswers(question: IdlObject): ComboboxEntry[] {
593 return question.answers().map(
594 a => ({id: a.id(), label: a.answer(), fm: a}));
597 setStatCats(): Promise<any> {
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())));
604 const entries = cat.entries().map(entry =>
605 ({id: entry.id(), label: entry.value()}));
615 setSmsCarriers(): Promise<any> {
616 if (!this.context.settingsCache['sms.enable']) {
617 return Promise.resolve();
620 return this.patronService.getSmsCarriers().then(carriers => {
621 this.smsCarriers = carriers.map(carrier => {
624 label: carrier.name()
630 getSecondaryGroups(): Promise<any> {
631 return this.net.request(
633 'open-ils.actor.user.get_groups',
634 this.auth.token(), this.patronId
636 ).pipe(concatMap(maps => {
637 if (maps.length === 0) { return []; }
639 return this.pcrud.search('pgt',
640 {id: maps.map(m => m.grp())}, {}, {atomic: true});
642 })).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
645 setIdentTypes(): Promise<any> {
646 return this.patronService.getIdentTypes()
648 this.identTypes = types.map(t => ({id: t.id(), label: t.name()}));
652 setInetLevels(): Promise<any> {
653 return this.patronService.getInetLevels()
655 this.inetLevels = levels.map(t => ({id: t.id(), label: t.name()}));
659 applyPerms(): Promise<any> {
661 const promise = this.permOrgs ?
662 Promise.resolve(this.permOrgs) :
663 this.perms.hasWorkPermAt(PERMS_NEEDED, true);
665 return promise.then(permOrgs => {
666 this.permOrgs = permOrgs;
667 Object.keys(permOrgs).forEach(perm =>
669 permOrgs[perm].includes(this.patron.home_ou())
674 setOptInSettings(): Promise<any> {
675 const orgIds = this.org.ancestors(this.auth.user().ws_ou(), true);
679 {name : COMMON_USER_SETTING_TYPES},
680 {name : { // opt-in notification user settings
682 select : {atevdef : ['opt_in_setting']},
684 // we only care about opt-in settings for
685 // event_defs our users encounter
686 where : {'+atevdef' : {owner : orgIds}}
692 return this.pcrud.search('cust', query, {}, {atomic : true})
693 .toPromise().then(types => {
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;
704 loadPatron(): Promise<any> {
706 return this.patronService.getFleshedById(this.patronId, PATRON_FLESH_FIELDS)
708 this.patron = patron;
709 this.origUsername = patron.usrname();
710 this.absorbPatronData();
713 return Promise.resolve(this.createNewPatron());
719 const usets = this.userSettings;
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();
726 this.patron.settings().forEach(stg => {
727 const value = stg.value();
728 if (value !== '' && value !== null) {
729 usets[stg.name()] = JSON.parse(value);
733 const holdNotify = usets['opac.hold_notify'];
736 this.holdNotifyTypes.email = this.holdNotifyValues.email_notify
737 = holdNotify.match(/email/) !== null;
739 this.holdNotifyTypes.phone = this.holdNotifyValues.phone_notify
740 = holdNotify.match(/phone/) !== null;
742 this.holdNotifyTypes.sms = this.holdNotifyValues.sms_notify
743 = holdNotify.match(/sms/) !== null;
746 if (setting = usets['opac.default_sms_carrier']) {
747 setting = usets['opac.default_sms_carrier'] = Number(setting);
748 this.holdNotifyValues.default_sms_carrier = setting;
751 if (setting = usets['opac.default_phone']) {
752 this.holdNotifyValues.default_phone = setting;
755 if (setting = usets['opac.default_sms_notify']) {
756 this.holdNotifyValues.default_sms_notify = setting;
759 if (setting = usets['opac.default_pickup_location']) {
760 usets['opac.default_pickup_location'] = Number(setting);
763 this.expireDate = new Date(this.patron.expire_date());
765 // stat_cat_entries() are entry maps under the covers.
766 this.patron.stat_cat_entries().forEach(map => {
768 const stat: StatCat =
769 this.statCats.filter(s => s.cat.id() === map.stat_cat())[0];
771 let cboxEntry: ComboboxEntry =
772 stat.entries.filter(e => e.label === map.stat_cat_entry())[0];
775 // If the applied value is not in the list of entries,
776 // create a freetext combobox entry for it.
780 label: map.stat_cat_entry(),
784 stat.entries.unshift(cboxEntry);
787 this.userStatCats[map.stat_cat()] = cboxEntry;
790 if (this.patron.waiver_entries().length === 0) {
794 if (!this.patron.card()) {
795 this.replaceBarcode();
800 const patron = this.idl.create('au');
803 patron.home_ou(this.auth.user().ws_ou());
806 patron.waiver_entries([]);
807 patron.stat_cat_entries([]);
809 const card = this.idl.create('ac');
812 card.id(this.autoId--);
814 patron.cards([card]);
816 const addr = this.idl.create('aua');
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]);
827 this.strings.interpolate('circ.patron.edit.default_addr_type')
828 .then(msg => addr.address_type(msg));
830 this.serverStore.getItem('ui.patron.default_ident_type')
832 if (identType) { patron.ident_type(Number(identType)); }
835 this.patron = patron;
839 objectFromPath(path: string, index: number): IdlObject {
840 const base = path ? this.patron[path]() : this.patron;
841 if (index === null || index === undefined) {
844 // Some paths lead to an array of objects.
849 getFieldLabel(idlClass: string, field: string, override?: string): string {
850 return override ? override :
851 this.idl.classes[idlClass].field_map[field].label;
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 {
860 getFieldValue(path: string, index: number, field: string): any {
861 return this.objectFromPath(path, index)[field]();
865 // Timeout gives the form a chance to mark fields as (in)valid
868 const invalidInput = document.querySelector('.ng-invalid');
871 invalidInput === null
873 && !this.dupeUsername
874 && !this.selfEditForbidden()
875 && !this.groupEditForbidden()
879 this.toolbar.disableSaveStateChanged.emit(!canSave);
885 // Avoid responding to any value changes while we are loading
886 if (this.loading) { return; }
887 this.changesPending = true;
888 this.emitSaveState();
891 userStatCatChange(cat: IdlObject, entry: ComboboxEntry) {
892 let map = this.patron.stat_cat_entries()
893 .filter(m => m.stat_cat() === cat.id())[0];
897 map.stat_cat_entry(entry.label);
899 map.isdeleted(false);
904 map = this.idl.create('actscecm');
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);
912 this.adjustSaveState();
915 userSettingChange(name: string, value: any) {
916 this.userSettings[name] = value;
917 this.adjustSaveState();
920 applySurveyResponse(question: IdlObject, answer: ComboboxEntry) {
921 if (!this.patron.survey_responses()) {
922 this.patron.survey_responses([]);
925 const responses = this.patron.survey_responses()
926 .filter(r => r.question() !== question.id());
928 const resp = this.idl.create('asvr');
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);
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'; }
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; }
951 this.changeHandlerNeeded = true;
952 this.objectFromPath(path, index)[field](value);
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;
960 const obj = this.objectFromPath(path, index);
961 const value = this.getFieldValue(path, index, field);
962 obj.ischanged(true); // isnew() supersedes
965 `Modifying field path=${path || ''} field=${field} value=${value}`);
970 this.maintainJuvFlag();
974 this.setExpireDate();
978 case 'evening_phone':
980 this.handlePhoneChange(field, value);
985 case 'first_given_name':
988 this.dupeValueChange(field, value);
994 // dupe search on address wants the address object as the value.
995 this.dupeValueChange('address', obj);
996 this.toolbar.checkAddressAlerts(this.patron, obj);
1000 this.handlePostCodeChange(obj, value);
1004 this.handleBarcodeChange(value);
1008 this.handleUsernameChange(value);
1012 this.adjustSaveState();
1017 if (!this.patron.dob()) { return; }
1020 this.context.settingsCache['global.juvenile_age_threshold']
1023 const cutoff = new Date();
1025 cutoff.setTime(cutoff.getTime() -
1026 Number(DateUtil.intervalToSeconds(interval) + '000'));
1028 const isJuve = new Date(this.patron.dob()) > cutoff;
1030 this.fieldValueChange(null, null, 'juvenile', isJuve);
1031 this.afterFieldChange(null, null, 'juvenile');
1034 handlePhoneChange(field: string, value: string) {
1035 this.dupeValueChange(field, value);
1038 this.context.settingsCache['patron.password.use_phone'];
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');
1047 handlePostCodeChange(addr: IdlObject, postCode: any) {
1049 'open-ils.search', 'open-ils.search.zip', postCode
1050 ).subscribe(resp => {
1051 if (!resp) { return; }
1053 ['city', 'state', 'county'].forEach(field => {
1055 addr[field](resp[field]);
1060 this.addrAlert.dialogBody = resp.alert;
1061 this.addrAlert.open();
1066 handleUsernameChange(value: any) {
1067 this.dupeUsername = false;
1069 if (!value || value === this.origUsername) {
1070 // In case the usrname changes then changes back.
1076 'open-ils.actor.username.exists',
1077 this.auth.token(), value
1078 ).subscribe(resp => this.dupeUsername = Boolean(resp));
1081 handleBarcodeChange(value: any) {
1082 this.dupeBarcode = false;
1084 if (!value) { return; }
1088 'open-ils.actor.barcode.exists',
1089 this.auth.token(), value
1090 ).subscribe(resp => {
1091 if (Number(resp) === 1) {
1092 this.dupeBarcode = true;
1095 if (this.patron.usrname()) { return; }
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');
1106 dupeValueChange(name: string, value: any) {
1108 if (name.match(/phone/)) { name = 'phone'; }
1109 if (name.match(/name/)) { name = 'name'; }
1110 if (name.match(/ident/)) { name = 'ident'; }
1112 let search: PatronSearchFieldSet;
1116 const fname = this.patron.first_given_name();
1117 const lname = this.patron.family_name();
1118 if (!fname || !lname) { return; }
1120 first_given_name : {value : fname, group : 0},
1121 family_name : {value : lname, group : 0}
1126 search = {email : {value : value, group : 0}};
1130 search = {ident : {value : value, group : 2}};
1134 search = {phone : {value : value, group : 2}};
1139 ['street1', 'street2', 'city', 'post_code'].forEach(field => {
1140 if (value[field]()) {
1141 search[field] = {value : value[field](), group: 1};
1147 this.toolbar.checkDupes(name, search);
1150 showField(field: string): boolean {
1152 if (this.fieldVisibility[field] === undefined) {
1153 // Settings have not yet been applied for this field.
1154 // Calculate them now.
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_/)) {
1161 settingKey = field.replace(/alt_/, '');
1164 const required = `ui.patron.edit.${settingKey}.require`;
1165 const show = `ui.patron.edit.${settingKey}.show`;
1166 const suggest = `ui.patron.edit.${settingKey}.suggest`;
1168 if (this.context.settingsCache[required]) {
1170 // Preferred name fields are never required.
1171 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1173 this.fieldVisibility[field] = FieldVisibility.REQUIRED;
1176 } else if (this.context.settingsCache[show]) {
1177 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1179 } else if (this.context.settingsCache[suggest]) {
1180 this.fieldVisibility[field] = FieldVisibility.SUGGESTED;
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;
1191 return this.fieldVisibility[field] >= this.toolbar.visibilityLevel;
1194 fieldRequired(field: string): boolean {
1198 // Only required for new patrons
1199 return this.patronId === null;
1202 // If the user ops in for email notices, require
1204 return this.holdNotifyTypes.email;
1207 return this.fieldVisibility[field] === 3;
1210 settingFieldRequired(name: string): boolean {
1213 case 'opac.default_sms_notify':
1214 case 'opac.default_sms_carrier':
1215 return this.holdNotifyTypes.sms;
1221 fieldPattern(idlClass: string, field: string): RegExp {
1222 if (!this.fieldPatterns[idlClass][field]) {
1223 this.fieldPatterns[idlClass][field] = new RegExp('.*');
1225 return this.fieldPatterns[idlClass][field];
1228 generatePassword() {
1229 this.fieldValueChange(null, null,
1230 'passwd', Math.floor(Math.random() * 9000) + 1000);
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');
1238 cannotHaveUsersOrgs(): number[] {
1239 return this.org.list()
1240 .filter(org => org.ou_type().can_have_users() === 'f')
1241 .map(org => org.id());
1244 cannotHaveVolsOrgs(): number[] {
1245 return this.org.list()
1246 .filter(org => org.ou_type().can_have_vols() === 'f')
1247 .map(org => org.id());
1251 const profile = this.profileSelect.profiles[this.patron.profile()];
1252 if (!profile) { return; }
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');
1262 handleBoolResponse(success: boolean,
1263 msg: string, errMsg?: string): Promise<boolean> {
1266 return this.strings.interpolate(msg)
1267 .then(str => this.toast.success(str))
1271 console.error(errMsg);
1273 return this.strings.interpolate(msg)
1274 .then(str => this.toast.danger(str))
1278 sendTestMessage(hook: string): Promise<boolean> {
1280 return this.net.request(
1282 'open-ils.actor.event.test_notification',
1283 this.auth.token(), {hook: hook, target: this.patronId}
1284 ).toPromise().then(resp => {
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');
1292 return this.handleBoolResponse(
1293 false, 'circ.patron.edit.test_notify.fail',
1294 'Test Notification Failed ' + resp);
1299 invalidateField(field: string): Promise<boolean> {
1301 return this.net.request(
1303 'open-ils.actor.invalidate.' + field,
1304 this.auth.token(), this.patronId, null, this.patron.home_ou()
1306 ).toPromise().then(resp => {
1307 const evt = this.evt.parse(resp);
1309 if (evt && evt.textcode !== 'SUCCESS') {
1310 return this.handleBoolResponse(false,
1311 'circ.patron.edit.invalidate.fail',
1312 'Field Invalidation Failed: ' + resp);
1315 this.patron[field](null);
1317 // Keep this in sync for future updates.
1318 this.patron.last_xact_id(resp.payload.last_xact_id[this.patronId]);
1320 return this.handleBoolResponse(
1321 true, 'circ.patron.edit.invalidate.success');
1325 openGroupsDialog() {
1326 this.secondaryGroupsDialog.open({size: 'lg'}).subscribe(groups => {
1327 if (!groups) { return; }
1329 this.secondaryGroups = groups;
1331 if (this.patron.isnew()) {
1332 // Links will be applied after the patron is created.
1336 // Apply the new links to an existing user in real time
1337 this.applySecondaryGroups();
1341 applySecondaryGroups(): Promise<boolean> {
1343 const groupIds = this.secondaryGroups.map(grp => grp.id());
1345 return this.net.request(
1347 'open-ils.actor.user.set_groups',
1348 this.auth.token(), this.patronId, groupIds
1349 ).toPromise().then(resp => {
1351 if (Number(resp) === 1) {
1352 return this.handleBoolResponse(
1353 true, 'circ.patron.edit.grplink.success');
1356 return this.handleBoolResponse(
1357 false, 'circ.patron.edit.grplink.fail',
1358 'Failed to change group links: ' + resp);
1363 // Set the mailing or billing address
1364 setAddrType(addrType: string, addr: IdlObject, selected: boolean) {
1366 this.patron[addrType + '_address'](addr);
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
1373 this.patron.addresses().some(a => {
1374 if (a.id() !== addr.id()) {
1375 this.patron[addrType + '_address'](a);
1376 return found = true;
1381 // No alternate address was found. Clear the value.
1382 this.patron[addrType + '_address'](null);
1385 this.patron.ischanged(true);
1389 deleteAddr(addr: IdlObject) {
1390 const addresses = this.patron.addresses();
1391 let promise = Promise.resolve(false);
1393 if (this.patron.isnew() && addresses.length === 1) {
1394 promise = this.serverStore.getItem(
1395 'ui.patron.registration.require_address');
1398 promise.then(required => {
1401 this.addrRequiredAlert.open();
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);
1412 if (this.patron.billing_address() &&
1413 this.patron.billing_address().id() === addr.id()) {
1414 this.setAddrType('billing', addr, false);
1420 addresses.some((a, i) => {
1421 if (a.id() === addr.id()) { idx = i; return true; }
1424 // New addresses can be discarded
1425 addresses.splice(idx, 1);
1428 addr.isdeleted(true);
1434 const addr = this.idl.create('aua');
1435 addr.id(this.autoId--);
1438 this.patron.addresses().push(addr);
1441 nonDeletedAddresses(): IdlObject[] {
1442 return this.patron.addresses().filter(a => !a.isdeleted());
1445 save(clone?: boolean): Promise<any> {
1447 this.changesPending = false;
1448 this.loading = true;
1449 this.showForm = false;
1451 return this.saveUser()
1452 .then(_ => this.saveUserSettings())
1453 .then(_ => this.updateHoldPrefs())
1454 .then(_ => this.removeStagedUser())
1455 .then(_ => this.postSaveRedirect(clone));
1458 postSaveRedirect(clone: boolean) {
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'
1466 if (this.stageUser) {
1467 this.broadcaster.broadcast('eg.pending_usr.update',
1468 {usr: this.idl.toHash(this.modifiedPatron)});
1470 // Typically, this window is opened as a new tab from the
1471 // pending users interface. Once we're done, just close the
1478 this.context.summary = null;
1479 this.router.navigate(
1480 ['/staff/circ/patron/register/clone', this.modifiedPatron.id()]);
1483 // Full refresh to force reload of modified patron data.
1484 window.location.href = window.location.href;
1488 // Resolves on success, rejects on error
1489 saveUser(): Promise<IdlObject> {
1490 this.modifiedPatron = null;
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()));
1496 return this.net.request(
1498 'open-ils.actor.patron.update',
1499 this.auth.token(), this.patron
1500 ).toPromise().then(result => {
1502 if (result && result.classname) {
1503 this.context.addRecentPatron(result.id());
1505 // Successful result returns the patron IdlObject.
1506 return this.modifiedPatron = result;
1509 const evt = this.evt.parse(result);
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
1520 alert('Patron update failed:' + result);
1523 return Promise.reject('Save Failed');
1527 // Resolves on success, rejects on error
1528 saveUserSettings(): Promise<any> {
1530 let settings: any = {};
1532 const holdMethods = [];
1534 ['email', 'phone', 'sms'].forEach(method => {
1535 if (this.holdNotifyTypes[method]) {
1536 holdMethods.push(method);
1540 this.userSettings['opac.hold_notify'] =
1541 holdMethods.length > 0 ? holdMethods.join(':') : null;
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;
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; }
1557 if (Object.keys(settings).length === 0) { return Promise.resolve(); }
1559 return this.net.request(
1561 'open-ils.actor.patron.settings.update',
1562 this.auth.token(), this.modifiedPatron.id(), settings
1567 updateHoldPrefs(): Promise<any> {
1568 if (this.patron.isnew()) { return Promise.resolve(); }
1570 return this.collectHoldNotifyChange()
1573 if (mods.length === 0) { return Promise.resolve(); }
1575 this.holdNotifyUpdateDialog.patronId = this.patronId;
1576 this.holdNotifyUpdateDialog.mods = mods;
1577 this.holdNotifyUpdateDialog.smsCarriers = this.smsCarriers;
1579 this.holdNotifyUpdateDialog.defaultCarrier =
1580 this.userSettings['opac.default_sms_carrier']
1581 || this.holdNotifyValues.default_sms_carrier;
1583 return this.holdNotifyUpdateDialog.open().toPromise();
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[]> {
1592 const holdNotify = this.userSettings['opac.hold_notify'] || '';
1594 return from(Object.keys(this.holdNotifyValues))
1595 .pipe(concatMap(field => {
1597 let newValue, matches;
1599 if (field.match(/default_/)) {
1600 newValue = this.userSettings[`opac.${field}`] || null;
1602 } else if (field.match(/_phone/)) {
1603 newValue = this.patron[field]();
1605 } else if (matches = field.match(/(\w+)_notify/)) {
1606 const notify = this.userSettings['opac.hold_notify'] || '';
1607 newValue = notify.match(matches[1]) !== null;
1610 const oldValue = this.holdNotifyValues[field];
1612 // No change to apply?
1613 if (newValue === oldValue) { return empty(); }
1615 // API / user setting name mismatch
1616 if (field.match(/carrier/)) { field += '_id'; }
1618 const apiValue = field.match(/notify|carrier/) ? oldValue : newValue;
1620 return this.net.request(
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) {
1634 })).toPromise().then(_ => mods);
1637 removeStagedUser(): Promise<any> {
1638 if (!this.stageUser) { return Promise.resolve(); }
1640 return this.net.request(
1642 'open-ils.actor.user.stage.delete',
1644 this.stageUser.user.row_id()
1649 this.printer.print({
1650 templateName: 'patron_data',
1651 contextData: {patron: this.patron},
1652 printContext: 'default'
1657 // Disable current card
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];
1667 crd.ischanged(true);
1670 const card = this.idl.create('ac');
1672 card.id(this.autoId--);
1673 card.usr(this.patron.id());
1676 this.patron.card(card);
1677 this.patron.cards().push(card);
1679 // Focus the barcode input
1681 this.emitSaveState();
1682 const node = document.getElementById('ac-barcode-input');
1690 canSave(): boolean {
1691 return document.querySelector('.ng-invalid') === null;
1694 setFieldPatterns() {
1698 this.context.settingsCache['ui.patron.edit.ac.barcode.regex']) {
1699 this.fieldPatterns.ac.barcode = new RegExp(regex);
1702 if (regex = this.context.settingsCache['global.password_regex']) {
1703 this.fieldPatterns.au.passwd = new RegExp(regex);
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);
1713 // the remaining this.fieldPatterns fit a well-known key name pattern
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);
1725 this.updateUsernameRegex();
1728 // The username must match either the configured regex or the
1730 updateUsernameRegex() {
1731 const regex = this.context.settingsCache['opac.username_regex'];
1733 const barcode = this.patron.card().barcode();
1735 this.fieldPatterns.au.usrname =
1736 new RegExp(`${regex}|^${barcode}$`);
1738 // username must match the regex
1739 this.fieldPatterns.au.usrname = new RegExp(regex);
1742 // username can be any format.
1743 this.fieldPatterns.au.usrname = new RegExp('.*');
1747 selfEditForbidden(): boolean {
1749 this.patron.id() === this.auth.user().id()
1750 && !this.hasPerm.EDIT_SELF_IN_CLIENT
1754 groupEditForbidden(): boolean {
1756 this.patron.profile()
1757 && !this.editProfiles.includes(this.patron.profile())
1762 const waiver = this.idl.create('aupw');
1764 waiver.id(this.autoId--);
1765 waiver.usr(this.patronId);
1766 this.patron.waiver_entries().push(waiver);
1769 removeWaiver(waiver: IdlObject) {
1770 if (waiver.isnew()) {
1771 this.patron.waiver_entries(
1772 this.patron.waiver_entries().filter(w => w.id() !== waiver.id()));
1774 if (this.patron.waiver_entries().length === 0) {
1775 // We need at least one waiver to access action buttons
1779 waiver.isdeleted(true);