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.autorenew.opt_in',
48 'circ.collections.exempt',
51 'opac.default_pickup_location',
52 'opac.default_sms_carrier',
53 'opac.default_sms_notify'
56 const PERMS_NEEDED = [
57 'EDIT_SELF_IN_CLIENT',
60 'CREATE_USER_GROUP_LINK',
61 'UPDATE_PATRON_COLLECTIONS_EXEMPT',
62 'UPDATE_PATRON_CLAIM_RETURN_COUNT',
63 'UPDATE_PATRON_CLAIM_NEVER_CHECKED_OUT_COUNT',
64 'UPDATE_PATRON_ACTIVE_CARD',
65 'UPDATE_PATRON_PRIMARY_CARD'
68 enum FieldVisibility {
74 // 3 == value universally required
75 // 2 == field is visible by default
76 // 1 == field is suggested by default
77 const DEFAULT_FIELD_VISIBILITY = {
78 'ac.barcode': FieldVisibility.REQUIRED,
79 'au.usrname': FieldVisibility.REQUIRED,
80 'au.passwd': FieldVisibility.REQUIRED,
81 'au.first_given_name': FieldVisibility.REQUIRED,
82 'au.family_name': FieldVisibility.REQUIRED,
83 'au.pref_first_given_name': FieldVisibility.VISIBLE,
84 'au.pref_family_name': FieldVisibility.VISIBLE,
85 'au.ident_type': FieldVisibility.REQUIRED,
86 'au.ident_type2': FieldVisibility.VISIBLE,
87 'au.home_ou': FieldVisibility.REQUIRED,
88 'au.profile': FieldVisibility.REQUIRED,
89 'au.expire_date': FieldVisibility.REQUIRED,
90 'au.net_access_level': FieldVisibility.REQUIRED,
91 'aua.address_type': FieldVisibility.REQUIRED,
92 'aua.post_code': FieldVisibility.REQUIRED,
93 'aua.street1': FieldVisibility.REQUIRED,
94 'aua.street2': FieldVisibility.VISIBLE,
95 'aua.city': FieldVisibility.REQUIRED,
96 'aua.county': FieldVisibility.VISIBLE,
97 'aua.state': FieldVisibility.VISIBLE,
98 'aua.country': FieldVisibility.REQUIRED,
99 'aua.valid': FieldVisibility.VISIBLE,
100 'aua.within_city_limits': FieldVisibility.VISIBLE,
101 'stat_cats': FieldVisibility.SUGGESTED,
102 'surveys': FieldVisibility.SUGGESTED,
103 'au.name_keywords': FieldVisibility.SUGGESTED
108 entries: ComboboxEntry[];
112 templateUrl: 'edit.component.html',
113 selector: 'eg-patron-edit',
114 styleUrls: ['edit.component.css']
116 export class EditComponent implements OnInit, AfterViewInit {
118 @Input() patronId: number = null;
119 @Input() cloneId: number = null;
120 @Input() stageUsername: string = null;
122 _toolbar: EditToolbarComponent;
123 @Input() set toolbar(tb: EditToolbarComponent) {
124 if (tb !== this._toolbar) {
127 // Our toolbar component may not be available during init,
128 // since it pops in and out of existence depending on which
129 // patron tab is open. Wait until we know it's defined.
131 tb.saveClicked.subscribe(_ => this.save());
132 tb.saveCloneClicked.subscribe(_ => this.save(true));
133 tb.printClicked.subscribe(_ => this.printPatron());
138 get toolbar(): EditToolbarComponent {
139 return this._toolbar;
142 @ViewChild('profileSelect')
143 private profileSelect: ProfileSelectComponent;
144 @ViewChild('secondaryGroupsDialog')
145 private secondaryGroupsDialog: SecondaryGroupsDialogComponent;
146 @ViewChild('holdNotifyUpdateDialog')
147 private holdNotifyUpdateDialog: HoldNotifyUpdateDialogComponent;
148 @ViewChild('addrAlert') private addrAlert: AlertDialogComponent;
149 @ViewChild('addrRequiredAlert')
150 private addrRequiredAlert: AlertDialogComponent;
151 @ViewChild('xactCollisionAlert')
152 private xactCollisionAlert: AlertDialogComponent;
157 modifiedPatron: IdlObject;
158 changeHandlerNeeded = false;
160 replaceBarcodeUsed = false;
162 // Are we still fetching data and applying values?
164 // Should the user be able to see the form?
165 // On page load, we want to show the form just before we are
166 // done loading, so values can be applied to inputs after they
167 // are rendered but before those changes would result in setting
168 // changesPending = true
171 surveys: IdlObject[];
172 smsCarriers: ComboboxEntry[];
173 identTypes: ComboboxEntry[];
174 inetLevels: ComboboxEntry[];
175 statCats: StatCat[] = [];
177 editProfiles: IdlObject[] = [];
178 userStatCats: {[statId: number]: ComboboxEntry} = {};
179 userSettings: {[name: string]: any} = {};
180 userSettingTypes: {[name: string]: IdlObject} = {};
181 optInSettingTypes: {[name: string]: IdlObject} = {};
182 secondaryGroups: IdlObject[] = [];
184 changesPending = false;
186 dupeUsername = false;
187 origUsername: string;
188 stageUser: IdlObject;
189 stageUserRequestor: IdlObject;
192 fieldPatterns: {[cls: string]: {[field: string]: RegExp}} = {
198 fieldVisibility: {[key: string]: FieldVisibility} = {};
205 default_sms_notify: null,
206 default_sms_carrier: null,
212 // All locations we have the specified permissions
213 permOrgs: {[name: string]: number[]};
215 // True if a given perm is granted at the current home_ou of the
216 // patron we are editing.
217 hasPerm: {[name: string]: boolean} = {};
219 holdNotifyTypes: {email?: boolean, phone?: boolean, sms?: boolean} = {};
221 fieldDoc: {[cls: string]: {[field: string]: string}} = {};
224 private router: Router,
225 private org: OrgService,
226 private net: NetService,
227 private auth: AuthService,
228 private pcrud: PcrudService,
229 private idl: IdlService,
230 private strings: StringService,
231 private toast: ToastService,
232 private perms: PermService,
233 private evt: EventService,
234 private serverStore: ServerStoreService,
235 private broadcaster: BroadcastService,
236 private patronService: PatronService,
237 private printer: PrintService,
238 private worklog: WorkLogService,
239 public context: PatronContextService
249 load(): Promise<any> {
251 this.showForm = false;
252 return this.setStatCats()
253 .then(_ => this.getFieldDocs())
254 .then(_ => this.setSurveys())
255 .then(_ => this.loadPatron())
256 .then(_ => this.getCloneUser())
257 .then(_ => this.getStageUser())
258 .then(_ => this.getSecondaryGroups())
259 .then(_ => this.applyPerms())
260 .then(_ => this.setEditProfiles())
261 .then(_ => this.setIdentTypes())
262 .then(_ => this.setInetLevels())
263 .then(_ => this.setOptInSettings())
264 .then(_ => this.setSmsCarriers())
265 .then(_ => this.setFieldPatterns())
266 .then(_ => this.showForm = true)
267 // Not my preferred way to handle this, but some values are
268 // applied to widgets slightly after the load() is done and the
269 // widgets are rendered. If a widget is required and has no
270 // value yet, then a premature save state check will see the
271 // form as invalid and nonsaveable. In order the check for a
272 // non-saveable state on page load without forcing the page into
273 // an nonsaveable state on every page load, check the save state
274 // after a 1 second delay.
275 .then(_ => setTimeout(() => {
276 this.emitSaveState();
277 this.loading = false;
281 setEditProfiles(): Promise<any> {
282 return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise()
283 .then(list => this.grpList = list)
284 .then(_ => this.applyEditProfiles());
288 // Share the set of forbidden groups with the 2ndary groups selector.
289 applyEditProfiles(): Promise<any> {
291 const failedPerms = [];
292 const profiles = this.grpList;
294 // extract the application permissions
295 profiles.forEach(grp => {
296 if (grp.application_perm()) {
297 appPerms.push(grp.application_perm());
301 const traverseTree = (grp: IdlObject, failed: boolean) => {
302 if (!grp) { return; }
304 failed = failed || failedPerms.includes(grp.application_perm());
306 if (!failed) { this.editProfiles.push(grp.id()); }
308 const children = profiles.filter(p => p.parent() === grp.id());
309 children.forEach(child => traverseTree(child, failed));
312 return this.perms.hasWorkPermAt(appPerms, true).then(orgs => {
313 appPerms.forEach(p => {
314 if (orgs[p].length === 0) { failedPerms.push(p); }
315 traverseTree(this.grpList[0], false);
320 getCloneUser(): Promise<any> {
321 if (!this.cloneId) { return Promise.resolve(); }
323 return this.patronService.getById(this.cloneId,
324 {flesh: 1, flesh_fields: {au: ['addresses']}})
326 const evt = this.evt.parse(cloneUser);
327 if (evt) { return alert(evt); }
328 this.copyCloneData(cloneUser);
332 getStageUser(): Promise<any> {
333 if (!this.stageUsername) { return Promise.resolve(); }
335 return this.net.request(
337 'open-ils.actor.user.stage.retrieve.by_username',
338 this.auth.token(), this.stageUsername).toPromise()
341 const evt = this.evt.parse(suser);
344 return Promise.reject(evt);
346 this.stageUser = suser;
351 const requestor = this.stageUser.user.requesting_usr();
353 return this.pcrud.retrieve('au', requestor).toPromise();
357 .then(reqr => this.stageUserRequestor = reqr)
358 .then(_ => this.copyStageData())
359 .then(_ => this.maintainJuvFlag());
363 const stageData = this.stageUser;
364 const patron = this.patron;
366 Object.keys(this.idl.classes.stgu.field_map).forEach(key => {
367 const field = this.idl.classes.au.field_map[key];
368 if (field && !field.virtual) {
369 const value = stageData.user[key]();
370 if (value !== null) {
376 // Clear the usrname if it looks like a UUID
377 if (patron.usrname().replace(/-/g, '').match(/[0-9a-f]{32}/)) {
381 // Don't use stub address if we have one from the staged user.
382 if (stageData.mailing_addresses.length > 0
383 || stageData.billing_addresses.length > 0) {
384 patron.addresses([]);
387 const addrFromStage = (stageAddr: IdlObject) => {
388 if (!stageAddr) { return; }
390 const cls = stageAddr.classname;
391 const addr = this.idl.create('aua');
394 addr.id(this.autoId--);
397 this.strings.interpolate('circ.patron.edit.default_addr_type')
398 .then(msg => addr.address_type(msg));
400 Object.keys(this.idl.classes[cls].field_map).forEach(key => {
401 const field = this.idl.classes.aua.field_map[key];
402 if (field && !field.virtual) {
403 const value = stageAddr[key]();
404 if (value !== null) {
410 patron.addresses().push(addr);
412 if (cls === 'stgma') {
413 patron.mailing_address(addr);
415 patron.billing_address(addr);
419 addrFromStage(stageData.mailing_addresses[0]);
420 addrFromStage(stageData.billing_addresses[0]);
422 if (patron.addresses().length === 1) {
423 // Only one address, use it for both purposes.
424 const addr = patron.addresses()[0];
425 patron.mailing_address(addr);
426 patron.billing_address(addr);
429 if (stageData.cards[0]) {
430 const card = this.idl.create('ac');
432 card.id(this.autoId--);
433 card.barcode(stageData.cards[0].barcode());
435 patron.cards([card]);
437 if (!patron.usrname()) {
438 patron.usrname(card.barcode());
442 stageData.settings.forEach(setting => {
443 this.userSettings[setting.setting()] = Boolean(setting.value());
446 stageData.statcats.forEach(entry => {
448 entry.statcat(Number(entry.statcat()));
450 const stat: StatCat =
451 this.statCats.filter(s => s.cat.id() === entry.statcat())[0];
453 let cboxEntry: ComboboxEntry =
454 stat.entries.filter(e => e.label === entry.value())[0];
457 // If the applied value is not in the list of entries,
458 // create a freetext combobox entry for it.
465 stat.entries.unshift(cboxEntry);
468 this.userStatCats[entry.statcat()] = cboxEntry;
470 // This forces the creation of the stat cat entry IDL objects.
471 this.userStatCatChange(stat.cat, cboxEntry);
474 if (patron.billing_address()) {
475 this.handlePostCodeChange(
476 patron.billing_address(), patron.billing_address().post_code());
480 checkStageUserDupes(): Promise<any> {
481 // Fire duplicate patron checks,once for each category
483 const patron = this.patron;
485 // Fire-and-forget the email search because it can take several seconds
486 if (patron.email()) {
487 this.dupeValueChange('email', patron.email());
490 return this.dupeValueChange('name', patron.family_name())
493 if (patron.ident_value()) {
494 return this.dupeValueChange('ident', patron.ident_value());
498 if (patron.day_phone()) {
499 return this.dupeValueChange('phone', patron.day_phone());
503 let promise = Promise.resolve();
504 this.patron.addresses().forEach(addr => {
506 promise.then(__ => this.dupeValueChange('address', addr));
508 promise.then(__ => this.toolbar.checkAddressAlerts(patron, addr));
513 copyCloneData(clone: IdlObject) {
514 const patron = this.patron;
516 // flesh the home org locally
517 patron.home_ou(clone.home_ou());
519 ['day_phone', 'evening_phone', 'other_phone', 'usrgroup']
520 .forEach(field => patron[field](clone[field]()));
522 // Create a new address from an existing address
523 const cloneAddr = (addr: IdlObject) => {
524 const newAddr = this.idl.clone(addr);
525 newAddr.id(this.autoId--);
526 newAddr.usr(patron.id());
533 this.context.settingsCache['circ.patron_edit.clone.copy_address'];
535 // No addresses to copy/link. Stick with the defaults.
536 if (clone.addresses().length === 0) { return; }
538 patron.addresses([]);
540 clone.addresses().forEach(sourceAddr => {
542 const myAddr = copyAddrs ? cloneAddr(sourceAddr) : sourceAddr;
543 if (copyAddrs) { myAddr._linked_owner = clone; }
545 if (clone.billing_address() === sourceAddr.id()) {
546 this.patron.billing_address(myAddr);
549 if (clone.mailing_address() === sourceAddr.id()) {
550 this.patron.mailing_address(myAddr);
553 this.patron.addresses().push(myAddr);
556 // If we have one type of address but not the other, use the one
557 // we have for both address purposes.
559 if (!this.patron.billing_address() && this.patron.mailing_address()) {
560 this.patron.billing_address(this.patron.mailing_address());
563 if (this.patron.billing_address() && !this.patron.mailing_address()) {
564 this.patron.mailing_address(this.patron.billing_address());
568 getFieldDocs(): Promise<any> {
569 return this.pcrud.search('fdoc', {
570 fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
572 if (!this.fieldDoc[doc.fm_class()]) {
573 this.fieldDoc[doc.fm_class()] = {};
575 this.fieldDoc[doc.fm_class()][doc.field()] = doc.string();
579 getFieldDoc(cls: string, field: string): string {
580 cls = this.getClass(cls);
581 if (this.fieldDoc[cls]) {
582 return this.fieldDoc[cls][field];
586 exampleText(cls: string, field: string): string {
587 cls = this.getClass(cls);
588 return this.context.settingsCache[`ui.patron.edit.${cls}.${field}.example`];
591 setSurveys(): Promise<any> {
592 return this.patronService.getSurveys()
593 .then(surveys => this.surveys = surveys);
596 surveyQuestionAnswers(question: IdlObject): ComboboxEntry[] {
597 return question.answers().map(
598 a => ({id: a.id(), label: a.answer(), fm: a}));
601 setStatCats(): Promise<any> {
603 return this.patronService.getStatCats().then(cats => {
604 cats.forEach(cat => {
605 cat.id(Number(cat.id()));
606 cat.entries().forEach(entry => entry.id(Number(entry.id())));
608 const entries = cat.entries().map(entry =>
609 ({id: entry.id(), label: entry.value()}));
619 setSmsCarriers(): Promise<any> {
620 if (!this.context.settingsCache['sms.enable']) {
621 return Promise.resolve();
624 return this.patronService.getSmsCarriers().then(carriers => {
625 this.smsCarriers = carriers.map(carrier => {
628 label: carrier.name()
634 getSecondaryGroups(): Promise<any> {
635 return this.net.request(
637 'open-ils.actor.user.get_groups',
638 this.auth.token(), this.patronId
640 ).pipe(concatMap(maps => {
641 if (maps.length === 0) { return []; }
643 return this.pcrud.search('pgt',
644 {id: maps.map(m => m.grp())}, {}, {atomic: true});
646 })).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
649 setIdentTypes(): Promise<any> {
650 return this.patronService.getIdentTypes()
652 this.identTypes = types.map(t => ({id: t.id(), label: t.name()}));
656 setInetLevels(): Promise<any> {
657 return this.patronService.getInetLevels()
659 this.inetLevels = levels.map(t => ({id: t.id(), label: t.name()}));
663 applyPerms(): Promise<any> {
665 const promise = this.permOrgs ?
666 Promise.resolve(this.permOrgs) :
667 this.perms.hasWorkPermAt(PERMS_NEEDED, true);
669 return promise.then(permOrgs => {
670 this.permOrgs = permOrgs;
671 Object.keys(permOrgs).forEach(perm =>
673 permOrgs[perm].includes(this.patron.home_ou())
678 setOptInSettings(): Promise<any> {
679 const orgIds = this.org.ancestors(this.auth.user().ws_ou(), true);
683 {name : COMMON_USER_SETTING_TYPES},
684 {name : { // opt-in notification user settings
686 select : {atevdef : ['opt_in_setting']},
688 // we only care about opt-in settings for
689 // event_defs our users encounter
690 where : {'+atevdef' : {owner : orgIds}}
696 return this.pcrud.search('cust', query, {}, {atomic : true})
697 .toPromise().then(types => {
699 types.forEach(stype => {
700 this.userSettingTypes[stype.name()] = stype;
701 if (!COMMON_USER_SETTING_TYPES.includes(stype.name())) {
702 this.optInSettingTypes[stype.name()] = stype;
705 if (this.patron.isnew()) {
706 let val = stype.reg_default();
707 if (val !== null && val !== undefined) {
708 if (stype.datatype() === 'bool') {
709 // A boolean user setting type whose default
710 // value starts with t/T is considered 'true',
712 val = Boolean((val + '').match(/^t/i));
714 this.userSettings[stype.name()] = val;
721 loadPatron(): Promise<any> {
723 return this.patronService.getFleshedById(this.patronId, PATRON_FLESH_FIELDS)
725 this.patron = patron;
726 this.origUsername = patron.usrname();
727 this.absorbPatronData();
730 return Promise.resolve(this.createNewPatron());
736 const usets = this.userSettings;
739 this.holdNotifyValues.day_phone = this.patron.day_phone();
740 this.holdNotifyValues.other_phone = this.patron.other_phone();
741 this.holdNotifyValues.evening_phone = this.patron.evening_phone();
743 this.patron.settings().forEach(stg => {
744 const value = stg.value();
745 if (value !== '' && value !== null) {
746 usets[stg.name()] = JSON.parse(value);
750 const holdNotify = usets['opac.hold_notify'];
753 this.holdNotifyTypes.email = this.holdNotifyValues.email_notify
754 = holdNotify.match(/email/) !== null;
756 this.holdNotifyTypes.phone = this.holdNotifyValues.phone_notify
757 = holdNotify.match(/phone/) !== null;
759 this.holdNotifyTypes.sms = this.holdNotifyValues.sms_notify
760 = holdNotify.match(/sms/) !== null;
763 if (setting = usets['opac.default_sms_carrier']) {
764 setting = usets['opac.default_sms_carrier'] = Number(setting);
765 this.holdNotifyValues.default_sms_carrier = setting;
768 if (setting = usets['opac.default_phone']) {
769 this.holdNotifyValues.default_phone = setting;
772 if (setting = usets['opac.default_sms_notify']) {
773 this.holdNotifyValues.default_sms_notify = setting;
776 if (setting = usets['opac.default_pickup_location']) {
777 usets['opac.default_pickup_location'] = Number(setting);
780 this.expireDate = new Date(this.patron.expire_date());
782 // stat_cat_entries() are entry maps under the covers.
783 this.patron.stat_cat_entries().forEach(map => {
785 const stat: StatCat =
786 this.statCats.filter(s => s.cat.id() === map.stat_cat())[0];
788 let cboxEntry: ComboboxEntry =
789 stat.entries.filter(e => e.label === map.stat_cat_entry())[0];
792 // If the applied value is not in the list of entries,
793 // create a freetext combobox entry for it.
797 label: map.stat_cat_entry(),
801 stat.entries.unshift(cboxEntry);
804 this.userStatCats[map.stat_cat()] = cboxEntry;
807 if (this.patron.waiver_entries().length === 0) {
811 if (!this.patron.card()) {
812 this.replaceBarcode();
817 const patron = this.idl.create('au');
820 patron.home_ou(this.auth.user().ws_ou());
823 patron.waiver_entries([]);
824 patron.stat_cat_entries([]);
826 const card = this.idl.create('ac');
829 card.id(this.autoId--);
831 patron.cards([card]);
833 const addr = this.idl.create('aua');
838 addr.within_city_limits('f');
839 addr.country(this.context.settingsCache['ui.patron.default_country']);
840 patron.billing_address(addr);
841 patron.mailing_address(addr);
842 patron.addresses([addr]);
844 this.strings.interpolate('circ.patron.edit.default_addr_type')
845 .then(msg => addr.address_type(msg));
847 this.serverStore.getItem('ui.patron.default_ident_type')
849 if (identType) { patron.ident_type(Number(identType)); }
852 this.patron = patron;
856 objectFromPath(path: string, index: number): IdlObject {
857 const base = path ? this.patron[path]() : this.patron;
858 if (index === null || index === undefined) {
861 // Some paths lead to an array of objects.
866 getFieldLabel(idlClass: string, field: string, override?: string): string {
867 return override ? override :
868 this.idl.classes[idlClass].field_map[field].label;
871 // With this, the 'cls' specifier is only needed in the template
872 // when it's not 'au', which is the base/common class.
873 getClass(cls: string): string {
877 getFieldValue(path: string, index: number, field: string): any {
878 return this.objectFromPath(path, index)[field]();
882 // Timeout gives the form a chance to mark fields as (in)valid
885 const invalidInput = document.querySelector('.ng-invalid');
888 invalidInput === null
890 && !this.dupeUsername
891 && !this.selfEditForbidden()
892 && !this.groupEditForbidden()
896 this.toolbar.disableSaveStateChanged.emit(!canSave);
902 // Avoid responding to any value changes while we are loading
903 if (this.loading) { return; }
904 this.changesPending = true;
905 this.emitSaveState();
908 userStatCatChange(cat: IdlObject, entry: ComboboxEntry) {
909 let map = this.patron.stat_cat_entries()
910 .filter(m => m.stat_cat() === cat.id())[0];
914 map.stat_cat_entry(entry.label);
916 map.isdeleted(false);
919 // Deleting a stat cat that was created during this
920 // edit session just means removing it from the list
921 // of maps to consider.
922 this.patron.stat_cat_entries(
923 this.patron.stat_cat_entries()
924 .filter(m => m.stat_cat() !== cat.id())
931 map = this.idl.create('actscecm');
933 map.stat_cat(cat.id());
934 map.stat_cat_entry(entry.label);
935 map.target_usr(this.patronId);
936 this.patron.stat_cat_entries().push(map);
939 this.adjustSaveState();
942 userSettingChange(name: string, value: any) {
943 this.userSettings[name] = value;
944 this.adjustSaveState();
947 applySurveyResponse(question: IdlObject, answer: ComboboxEntry) {
948 if (!this.patron.survey_responses()) {
949 this.patron.survey_responses([]);
952 const responses = this.patron.survey_responses()
953 .filter(r => r.question() !== question.id());
955 const resp = this.idl.create('asvr');
957 resp.survey(question.survey());
958 resp.question(question.id());
959 resp.answer(answer.id);
960 resp.usr(this.patron.id());
961 resp.answer_date('now');
962 responses.push(resp);
963 this.patron.survey_responses(responses);
966 // Called as the model changes.
967 // This may be called many times before the final value is applied,
968 // so avoid any heavy lifting here. See afterFieldChange();
969 fieldValueChange(path: string, index: number, field: string, value: any) {
970 if (typeof value === 'boolean') { value = value ? 't' : 'f'; }
972 // This can be called in cases where components fire up, even
973 // though the actual value on the patron has not changed.
974 // Exit early in that case so we don't mark the form as dirty.
975 const oldValue = this.getFieldValue(path, index, field);
976 if (oldValue === value) { return; }
978 this.changeHandlerNeeded = true;
979 this.objectFromPath(path, index)[field](value);
982 // Called after a change operation has completed (e.g. on blur)
983 afterFieldChange(path: string, index: number, field: string) {
984 if (!this.changeHandlerNeeded) { return; } // no changes applied
985 this.changeHandlerNeeded = false;
987 const obj = this.objectFromPath(path, index);
988 const value = this.getFieldValue(path, index, field);
989 obj.ischanged(true); // isnew() supersedes
992 `Modifying field path=${path || ''} field=${field} value=${value}`);
997 this.maintainJuvFlag();
1001 this.setExpireDate();
1005 case 'evening_phone':
1007 this.handlePhoneChange(field, value);
1011 case 'ident_value2':
1012 case 'first_given_name':
1015 this.dupeValueChange(field, value);
1021 // dupe search on address wants the address object as the value.
1022 this.dupeValueChange('address', obj);
1023 this.toolbar.checkAddressAlerts(this.patron, obj);
1027 this.handlePostCodeChange(obj, value);
1031 this.handleBarcodeChange(value);
1035 this.handleUsernameChange(value);
1039 this.adjustSaveState();
1044 if (!this.patron.dob()) { return; }
1047 this.context.settingsCache['global.juvenile_age_threshold']
1050 const cutoff = new Date();
1052 cutoff.setTime(cutoff.getTime() -
1053 Number(DateUtil.intervalToSeconds(interval) + '000'));
1055 const isJuve = new Date(this.patron.dob()) > cutoff;
1057 this.fieldValueChange(null, null, 'juvenile', isJuve);
1058 this.afterFieldChange(null, null, 'juvenile');
1061 handlePhoneChange(field: string, value: string) {
1062 this.dupeValueChange(field, value);
1065 this.context.settingsCache['patron.password.use_phone'];
1067 if (field === 'day_phone' && value &&
1068 this.patron.isnew() && !this.patron.passwd() && pwUsePhone) {
1069 this.fieldValueChange(null, null, 'passwd', value.substr(-4));
1070 this.afterFieldChange(null, null, 'passwd');
1074 handlePostCodeChange(addr: IdlObject, postCode: any) {
1076 'open-ils.search', 'open-ils.search.zip', postCode
1077 ).subscribe(resp => {
1078 if (!resp) { return; }
1080 ['city', 'state', 'county'].forEach(field => {
1082 addr[field](resp[field]);
1087 this.addrAlert.dialogBody = resp.alert;
1088 this.addrAlert.open();
1093 handleUsernameChange(value: any) {
1094 this.dupeUsername = false;
1096 if (!value || value === this.origUsername) {
1097 // In case the usrname changes then changes back.
1103 'open-ils.actor.username.exists',
1104 this.auth.token(), value
1105 ).subscribe(resp => this.dupeUsername = Boolean(resp));
1108 handleBarcodeChange(value: any) {
1109 this.dupeBarcode = false;
1111 if (!value) { return; }
1115 'open-ils.actor.barcode.exists',
1116 this.auth.token(), value
1117 ).subscribe(resp => {
1118 if (Number(resp) === 1) {
1119 this.dupeBarcode = true;
1122 if (this.patron.usrname()) { return; }
1124 // Propagate username with barcode value by default.
1125 // This will apply the value and fire the dupe checker
1126 this.updateUsernameRegex();
1127 this.fieldValueChange(null, null, 'usrname', value);
1128 this.afterFieldChange(null, null, 'usrname');
1133 dupeValueChange(name: string, value: any): Promise<any> {
1135 if (name.match(/phone/)) { name = 'phone'; }
1136 if (name.match(/name/)) { name = 'name'; }
1137 if (name.match(/ident/)) { name = 'ident'; }
1139 let search: PatronSearchFieldSet;
1143 const fname = this.patron.first_given_name();
1144 const lname = this.patron.family_name();
1145 if (!fname || !lname) { return; }
1147 first_given_name : {value : fname, group : 0},
1148 family_name : {value : lname, group : 0}
1153 search = {email : {value : value, group : 0}};
1157 search = {ident : {value : value, group : 2}};
1161 search = {phone : {value : value, group : 2}};
1166 ['street1', 'street2', 'city', 'post_code'].forEach(field => {
1167 if (value[field]()) {
1168 search[field] = {value : value[field](), group: 1};
1174 return this.toolbar.checkDupes(name, search);
1177 showField(field: string): boolean {
1179 if (this.fieldVisibility[field] === undefined) {
1180 // Settings have not yet been applied for this field.
1181 // Calculate them now.
1183 // The preferred name fields use the primary name field settings
1184 let settingKey = field;
1185 let altName = false;
1186 if (field.match(/^au.alt_/)) {
1188 settingKey = field.replace(/alt_/, '');
1191 const required = `ui.patron.edit.${settingKey}.require`;
1192 const show = `ui.patron.edit.${settingKey}.show`;
1193 const suggest = `ui.patron.edit.${settingKey}.suggest`;
1195 if (this.context.settingsCache[required]) {
1197 // Preferred name fields are never required.
1198 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1200 this.fieldVisibility[field] = FieldVisibility.REQUIRED;
1203 } else if (this.context.settingsCache[show]) {
1204 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1206 } else if (this.context.settingsCache[suggest]) {
1207 this.fieldVisibility[field] = FieldVisibility.SUGGESTED;
1211 if (this.fieldVisibility[field] === undefined) {
1212 // No org settings were applied above. Use the default
1213 // settings if present or assume the field has no
1214 // visibility flags applied.
1215 this.fieldVisibility[field] = DEFAULT_FIELD_VISIBILITY[field] || 0;
1218 return this.fieldVisibility[field] >= this.toolbar.visibilityLevel;
1221 fieldRequired(field: string): boolean {
1225 // Only required for new patrons
1226 return this.patronId === null;
1229 // If the user ops in for email notices, require
1231 return this.holdNotifyTypes.email;
1234 return this.fieldVisibility[field] === 3;
1237 settingFieldRequired(name: string): boolean {
1240 case 'opac.default_sms_notify':
1241 case 'opac.default_sms_carrier':
1242 return this.holdNotifyTypes.sms;
1248 fieldPattern(idlClass: string, field: string): RegExp {
1249 if (!this.fieldPatterns[idlClass][field]) {
1250 this.fieldPatterns[idlClass][field] = new RegExp('.*');
1252 return this.fieldPatterns[idlClass][field];
1255 generatePassword() {
1256 this.fieldValueChange(null, null,
1257 'passwd', Math.floor(Math.random() * 9000) + 1000);
1259 // Normally this is called on (blur), but the input is not
1260 // focused when using the generate button.
1261 this.afterFieldChange(null, null, 'passwd');
1265 cannotHaveUsersOrgs(): number[] {
1266 return this.org.list()
1267 .filter(org => org.ou_type().can_have_users() === 'f')
1268 .map(org => org.id());
1271 cannotHaveVolsOrgs(): number[] {
1272 return this.org.list()
1273 .filter(org => org.ou_type().can_have_vols() === 'f')
1274 .map(org => org.id());
1278 const profile = this.profileSelect.profiles[this.patron.profile()];
1279 if (!profile) { return; }
1281 const seconds = DateUtil.intervalToSeconds(profile.perm_interval());
1282 const nowEpoch = new Date().getTime();
1283 const newDate = new Date(nowEpoch + (seconds * 1000 /* millis */));
1284 this.expireDate = newDate;
1285 this.fieldValueChange(null, null, 'expire_date', newDate.toISOString());
1286 this.afterFieldChange(null, null, 'expire_date');
1289 handleBoolResponse(success: boolean,
1290 msg: string, errMsg?: string): Promise<boolean> {
1293 return this.strings.interpolate(msg)
1294 .then(str => this.toast.success(str))
1298 console.error(errMsg);
1300 return this.strings.interpolate(msg)
1301 .then(str => this.toast.danger(str))
1305 sendTestMessage(hook: string): Promise<boolean> {
1307 return this.net.request(
1309 'open-ils.actor.event.test_notification',
1310 this.auth.token(), {hook: hook, target: this.patronId}
1311 ).toPromise().then(resp => {
1313 if (resp && resp.template_output && resp.template_output() &&
1314 resp.template_output().is_error() === 'f') {
1315 return this.handleBoolResponse(
1316 true, 'circ.patron.edit.test_notify.success');
1319 return this.handleBoolResponse(
1320 false, 'circ.patron.edit.test_notify.fail',
1321 'Test Notification Failed ' + resp);
1326 invalidateField(field: string): Promise<boolean> {
1328 return this.net.request(
1330 'open-ils.actor.invalidate.' + field,
1331 this.auth.token(), this.patronId, null, this.patron.home_ou()
1333 ).toPromise().then(resp => {
1334 const evt = this.evt.parse(resp);
1336 if (evt && evt.textcode !== 'SUCCESS') {
1337 return this.handleBoolResponse(false,
1338 'circ.patron.edit.invalidate.fail',
1339 'Field Invalidation Failed: ' + resp);
1342 this.patron[field](null);
1344 // Keep this in sync for future updates.
1345 this.patron.last_xact_id(resp.payload.last_xact_id[this.patronId]);
1347 return this.handleBoolResponse(
1348 true, 'circ.patron.edit.invalidate.success');
1352 openGroupsDialog() {
1353 this.secondaryGroupsDialog.open({size: 'lg'}).subscribe(groups => {
1354 if (!groups) { return; }
1356 this.secondaryGroups = groups;
1358 if (this.patron.isnew()) {
1359 // Links will be applied after the patron is created.
1363 // Apply the new links to an existing user in real time
1364 this.applySecondaryGroups();
1368 applySecondaryGroups(): Promise<boolean> {
1370 const groupIds = this.secondaryGroups.map(grp => grp.id());
1372 return this.net.request(
1374 'open-ils.actor.user.set_groups',
1375 this.auth.token(), this.patronId, groupIds
1376 ).toPromise().then(resp => {
1378 if (Number(resp) === 1) {
1379 return this.handleBoolResponse(
1380 true, 'circ.patron.edit.grplink.success');
1383 return this.handleBoolResponse(
1384 false, 'circ.patron.edit.grplink.fail',
1385 'Failed to change group links: ' + resp);
1390 // Set the mailing or billing address
1391 setAddrType(addrType: string, addr: IdlObject, selected: boolean) {
1393 this.patron[addrType + '_address'](addr);
1395 // Unchecking mailing/billing means we have to randomly
1396 // select another address to fill that role. Select the
1397 // first address in the list (that does not match the
1400 this.patron.addresses().some(a => {
1401 if (a.id() !== addr.id()) {
1402 this.patron[addrType + '_address'](a);
1403 return found = true;
1408 // No alternate address was found. Clear the value.
1409 this.patron[addrType + '_address'](null);
1412 this.patron.ischanged(true);
1416 deleteAddr(addr: IdlObject) {
1417 const addresses = this.patron.addresses();
1418 let promise = Promise.resolve(false);
1420 if (this.patron.isnew() && addresses.length === 1) {
1421 promise = this.serverStore.getItem(
1422 'ui.patron.registration.require_address');
1425 promise.then(required => {
1428 this.addrRequiredAlert.open();
1432 // Roll the mailing/billing designation to another
1433 // address when needed.
1434 if (this.patron.mailing_address() &&
1435 this.patron.mailing_address().id() === addr.id()) {
1436 this.setAddrType('mailing', addr, false);
1439 if (this.patron.billing_address() &&
1440 this.patron.billing_address().id() === addr.id()) {
1441 this.setAddrType('billing', addr, false);
1447 addresses.some((a, i) => {
1448 if (a.id() === addr.id()) { idx = i; return true; }
1451 // New addresses can be discarded
1452 addresses.splice(idx, 1);
1455 addr.isdeleted(true);
1461 const addr = this.idl.create('aua');
1462 addr.id(this.autoId--);
1465 this.patron.addresses().push(addr);
1468 nonDeletedAddresses(): IdlObject[] {
1469 return this.patron.addresses().filter(a => !a.isdeleted());
1472 save(clone?: boolean): Promise<any> {
1474 this.changesPending = false;
1475 this.loading = true;
1476 this.showForm = false;
1478 return this.saveUser()
1479 .then(_ => this.saveUserSettings())
1480 .then(_ => this.updateHoldPrefs())
1481 .then(_ => this.removeStagedUser())
1482 .then(_ => this.postSaveRedirect(clone));
1485 postSaveRedirect(clone: boolean) {
1487 this.worklog.record({
1488 user: this.modifiedPatron.family_name(),
1489 patron_id: this.modifiedPatron.id(),
1490 action: this.patron.isnew() ? 'registered_patron' : 'edited_patron'
1493 if (this.stageUser) {
1494 this.broadcaster.broadcast('eg.pending_usr.update',
1495 {usr: this.idl.toHash(this.modifiedPatron)});
1497 // Typically, this window is opened as a new tab from the
1498 // pending users interface. Once we're done, just close the
1505 this.context.summary = null;
1506 this.router.navigate(
1507 ['/staff/circ/patron/register/clone', this.modifiedPatron.id()]);
1510 // Full refresh to force reload of modified patron data.
1511 window.location.href = window.location.href;
1515 // Resolves on success, rejects on error
1516 saveUser(): Promise<IdlObject> {
1517 this.modifiedPatron = null;
1519 // A dummy waiver is added on load. Remove it if no values were added.
1520 this.patron.waiver_entries(
1521 this.patron.waiver_entries().filter(e => !e.isnew() || e.name()));
1523 return this.net.request(
1525 'open-ils.actor.patron.update',
1526 this.auth.token(), this.patron
1527 ).toPromise().then(result => {
1529 if (result && result.classname) {
1530 this.context.addRecentPatron(result.id());
1532 // Successful result returns the patron IdlObject.
1533 return this.modifiedPatron = result;
1536 const evt = this.evt.parse(result);
1539 console.error('Patron update failed with', evt);
1540 if (evt.textcode === 'XACT_COLLISION') {
1541 this.xactCollisionAlert.open().toPromise().then(_ =>
1542 window.location.href = window.location.href
1547 alert('Patron update failed:' + result);
1550 return Promise.reject('Save Failed');
1554 // Resolves on success, rejects on error
1555 saveUserSettings(): Promise<any> {
1557 let settings: any = {};
1559 const holdMethods = [];
1561 ['email', 'phone', 'sms'].forEach(method => {
1562 if (this.holdNotifyTypes[method]) {
1563 holdMethods.push(method);
1567 this.userSettings['opac.hold_notify'] =
1568 holdMethods.length > 0 ? holdMethods.join(':') : null;
1570 if (this.patronId) {
1571 // Update all user editor setting values for existing
1572 // users regardless of whether a value changed.
1573 settings = this.userSettings;
1577 // Create settings for all non-null setting values for new patrons.
1578 Object.keys(this.userSettings).forEach(key => {
1579 const val = this.userSettings[key];
1580 if (val !== null) { settings[key] = val; }
1584 if (Object.keys(settings).length === 0) { return Promise.resolve(); }
1586 return this.net.request(
1588 'open-ils.actor.patron.settings.update',
1589 this.auth.token(), this.modifiedPatron.id(), settings
1594 updateHoldPrefs(): Promise<any> {
1595 if (this.patron.isnew()) { return Promise.resolve(); }
1597 return this.collectHoldNotifyChange()
1600 if (mods.length === 0) { return Promise.resolve(); }
1602 this.holdNotifyUpdateDialog.patronId = this.patronId;
1603 this.holdNotifyUpdateDialog.mods = mods;
1604 this.holdNotifyUpdateDialog.smsCarriers = this.smsCarriers;
1606 this.holdNotifyUpdateDialog.defaultCarrier =
1607 this.userSettings['opac.default_sms_carrier']
1608 || this.holdNotifyValues.default_sms_carrier;
1610 return this.holdNotifyUpdateDialog.open().toPromise();
1614 // Compare current values with those collected at patron load time.
1615 // For any that have changed, ask the server if the original values
1616 // are used on active holds.
1617 collectHoldNotifyChange(): Promise<any[]> {
1619 const holdNotify = this.userSettings['opac.hold_notify'] || '';
1621 return from(Object.keys(this.holdNotifyValues))
1622 .pipe(concatMap(field => {
1624 let newValue, matches;
1626 if (field.match(/default_/)) {
1627 newValue = this.userSettings[`opac.${field}`] || null;
1629 } else if (field.match(/_phone/)) {
1630 newValue = this.patron[field]();
1632 } else if (matches = field.match(/(\w+)_notify/)) {
1633 const notify = this.userSettings['opac.hold_notify'] || '';
1634 newValue = notify.match(matches[1]) !== null;
1637 const oldValue = this.holdNotifyValues[field];
1639 // No change to apply?
1640 if (newValue === oldValue) { return empty(); }
1642 // API / user setting name mismatch
1643 if (field.match(/carrier/)) { field += '_id'; }
1645 const apiValue = field.match(/notify|carrier/) ? oldValue : newValue;
1647 return this.net.request(
1649 'open-ils.circ.holds.retrieve_by_notify_staff',
1650 this.auth.token(), this.patronId, apiValue, field
1651 ).pipe(tap(holds => {
1652 if (holds && holds.length > 0) {
1661 })).toPromise().then(_ => mods);
1664 removeStagedUser(): Promise<any> {
1665 if (!this.stageUser) { return Promise.resolve(); }
1667 return this.net.request(
1669 'open-ils.actor.user.stage.delete',
1671 this.stageUser.user.row_id()
1676 this.printer.print({
1677 templateName: 'patron_data',
1678 contextData: {patron: this.patron},
1679 printContext: 'default'
1684 // Disable current card
1686 this.replaceBarcodeUsed = true;
1688 if (this.patron.card()) {
1689 // patron.card() is not the same in-memory object as its
1690 // analog in patron.cards(). Since we're about to replace
1691 // patron.card() anyway, just update the patron.cards() version.
1692 const crd = this.patron.cards()
1693 .filter(c => c.id() === this.patron.card().id())[0];
1696 crd.ischanged(true);
1699 const card = this.idl.create('ac');
1701 card.id(this.autoId--);
1702 card.usr(this.patron.id());
1705 this.patron.card(card);
1706 this.patron.cards().push(card);
1708 // Focus the barcode input
1710 this.emitSaveState();
1711 const node = document.getElementById('ac-barcode-input');
1719 canSave(): boolean {
1720 return document.querySelector('.ng-invalid') === null;
1723 setFieldPatterns() {
1727 this.context.settingsCache['ui.patron.edit.ac.barcode.regex']) {
1728 this.fieldPatterns.ac.barcode = new RegExp(regex);
1731 if (regex = this.context.settingsCache['global.password_regex']) {
1732 this.fieldPatterns.au.passwd = new RegExp(regex);
1735 if (regex = this.context.settingsCache['ui.patron.edit.phone.regex']) {
1736 // apply generic phone regex first, replace below as needed.
1737 this.fieldPatterns.au.day_phone = new RegExp(regex);
1738 this.fieldPatterns.au.evening_phone = new RegExp(regex);
1739 this.fieldPatterns.au.other_phone = new RegExp(regex);
1742 // the remaining this.fieldPatterns fit a well-known key name pattern
1744 Object.keys(this.context.settingsCache).forEach(key => {
1745 const val = this.context.settingsCache[key];
1746 if (!val) { return; }
1747 const parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
1748 if (!parts) { return; }
1749 const cls = parts[1];
1750 const name = parts[2];
1751 this.fieldPatterns[cls][name] = new RegExp(val);
1754 this.updateUsernameRegex();
1757 // The username must match either the configured regex or the
1759 updateUsernameRegex() {
1760 const regex = this.context.settingsCache['opac.username_regex'];
1762 const barcode = this.patron.card().barcode();
1764 this.fieldPatterns.au.usrname =
1765 new RegExp(`${regex}|^${barcode}$`);
1767 // username must match the regex
1768 this.fieldPatterns.au.usrname = new RegExp(regex);
1771 // username can be any format.
1772 this.fieldPatterns.au.usrname = new RegExp('.*');
1776 selfEditForbidden(): boolean {
1778 this.patron.id() === this.auth.user().id()
1779 && !this.hasPerm.EDIT_SELF_IN_CLIENT
1783 groupEditForbidden(): boolean {
1785 this.patron.profile()
1786 && !this.editProfiles.includes(this.patron.profile())
1791 const waiver = this.idl.create('aupw');
1793 waiver.id(this.autoId--);
1794 waiver.usr(this.patronId);
1795 this.patron.waiver_entries().push(waiver);
1798 removeWaiver(waiver: IdlObject) {
1799 if (waiver.isnew()) {
1800 this.patron.waiver_entries(
1801 this.patron.waiver_entries().filter(w => w.id() !== waiver.id()));
1803 if (this.patron.waiver_entries().length === 0) {
1804 // We need at least one waiver to access action buttons
1808 waiver.isdeleted(true);