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;
159 replaceBarcodeUsed = false;
161 // Are we still fetching data and applying values?
163 // Should the user be able to see the form?
164 // On page load, we want to show the form just before we are
165 // done loading, so values can be applied to inputs after they
166 // are rendered but before those changes would result in setting
167 // changesPending = true
170 surveys: IdlObject[];
171 smsCarriers: ComboboxEntry[];
172 identTypes: ComboboxEntry[];
173 inetLevels: ComboboxEntry[];
174 statCats: StatCat[] = [];
176 editProfiles: IdlObject[] = [];
177 userStatCats: {[statId: number]: ComboboxEntry} = {};
178 userSettings: {[name: string]: any} = {};
179 userSettingTypes: {[name: string]: IdlObject} = {};
180 optInSettingTypes: {[name: string]: IdlObject} = {};
181 secondaryGroups: IdlObject[] = [];
183 changesPending = false;
185 dupeUsername = false;
186 origUsername: string;
187 stageUser: IdlObject;
188 stageUserRequestor: IdlObject;
191 fieldPatterns: {[cls: string]: {[field: string]: RegExp}} = {
197 fieldVisibility: {[key: string]: FieldVisibility} = {};
204 default_sms_notify: null,
205 default_sms_carrier: null,
211 // All locations we have the specified permissions
212 permOrgs: {[name: string]: number[]};
214 // True if a given perm is granted at the current home_ou of the
215 // patron we are editing.
216 hasPerm: {[name: string]: boolean} = {};
218 holdNotifyTypes: {email?: boolean, phone?: boolean, sms?: boolean} = {};
220 fieldDoc: {[cls: string]: {[field: string]: string}} = {};
223 private router: Router,
224 private org: OrgService,
225 private net: NetService,
226 private auth: AuthService,
227 private pcrud: PcrudService,
228 private idl: IdlService,
229 private strings: StringService,
230 private toast: ToastService,
231 private perms: PermService,
232 private evt: EventService,
233 private serverStore: ServerStoreService,
234 private broadcaster: BroadcastService,
235 private patronService: PatronService,
236 private printer: PrintService,
237 private worklog: WorkLogService,
238 public context: PatronContextService
248 load(): Promise<any> {
250 this.showForm = false;
251 return this.setStatCats()
252 .then(_ => this.getFieldDocs())
253 .then(_ => this.setSurveys())
254 .then(_ => this.loadPatron())
255 .then(_ => this.getCloneUser())
256 .then(_ => this.getStageUser())
257 .then(_ => this.getSecondaryGroups())
258 .then(_ => this.applyPerms())
259 .then(_ => this.setEditProfiles())
260 .then(_ => this.setIdentTypes())
261 .then(_ => this.setInetLevels())
262 .then(_ => this.setOptInSettings())
263 .then(_ => this.setSmsCarriers())
264 .then(_ => this.setFieldPatterns())
265 .then(_ => this.showForm = true)
266 // Not my preferred way to handle this, but some values are
267 // applied to widgets slightly after the load() is done and the
268 // widgets are rendered. If a widget is required and has no
269 // value yet, then a premature save state check will see the
270 // form as invalid and nonsaveable. In order the check for a
271 // non-saveable state on page load without forcing the page into
272 // an nonsaveable state on every page load, check the save state
273 // after a 1 second delay.
274 .then(_ => setTimeout(() => {
275 this.emitSaveState();
276 this.loading = false;
280 setEditProfiles(): Promise<any> {
281 return this.pcrud.retrieveAll('pgt', {}, {atomic: true}).toPromise()
282 .then(list => this.grpList = list)
283 .then(_ => this.applyEditProfiles());
287 // Share the set of forbidden groups with the 2ndary groups selector.
288 applyEditProfiles(): Promise<any> {
290 const failedPerms = [];
291 const profiles = this.grpList;
293 // extract the application permissions
294 profiles.forEach(grp => {
295 if (grp.application_perm()) {
296 appPerms.push(grp.application_perm());
300 const traverseTree = (grp: IdlObject, failed: boolean) => {
301 if (!grp) { return; }
303 failed = failed || failedPerms.includes(grp.application_perm());
305 if (!failed) { this.editProfiles.push(grp.id()); }
307 const children = profiles.filter(p => p.parent() === grp.id());
308 children.forEach(child => traverseTree(child, failed));
311 return this.perms.hasWorkPermAt(appPerms, true).then(orgs => {
312 appPerms.forEach(p => {
313 if (orgs[p].length === 0) { failedPerms.push(p); }
314 traverseTree(this.grpList[0], false);
319 getCloneUser(): Promise<any> {
320 if (!this.cloneId) { return Promise.resolve(); }
322 return this.patronService.getById(this.cloneId,
323 {flesh: 1, flesh_fields: {au: ['addresses']}})
325 const evt = this.evt.parse(cloneUser);
326 if (evt) { return alert(evt); }
327 this.copyCloneData(cloneUser);
331 getStageUser(): Promise<any> {
332 if (!this.stageUsername) { return Promise.resolve(); }
334 return this.net.request(
336 'open-ils.actor.user.stage.retrieve.by_username',
337 this.auth.token(), this.stageUsername).toPromise()
340 const evt = this.evt.parse(suser);
343 return Promise.reject(evt);
345 this.stageUser = suser;
350 const requestor = this.stageUser.user.requesting_usr();
352 return this.pcrud.retrieve('au', requestor).toPromise();
356 .then(reqr => this.stageUserRequestor = reqr)
357 .then(_ => this.copyStageData())
358 .then(_ => this.maintainJuvFlag());
362 const stageData = this.stageUser;
363 const patron = this.patron;
365 Object.keys(this.idl.classes.stgu.field_map).forEach(key => {
366 const field = this.idl.classes.au.field_map[key];
367 if (field && !field.virtual) {
368 const value = stageData.user[key]();
369 if (value !== null) {
375 // Clear the usrname if it looks like a UUID
376 if (patron.usrname().replace(/-/g, '').match(/[0-9a-f]{32}/)) {
380 // Don't use stub address if we have one from the staged user.
381 if (stageData.mailing_addresses.length > 0
382 || stageData.billing_addresses.length > 0) {
383 patron.addresses([]);
386 const addrFromStage = (stageAddr: IdlObject) => {
387 if (!stageAddr) { return; }
389 const cls = stageAddr.classname;
390 const addr = this.idl.create('aua');
393 addr.id(this.autoId--);
396 this.strings.interpolate('circ.patron.edit.default_addr_type')
397 .then(msg => addr.address_type(msg));
399 Object.keys(this.idl.classes[cls].field_map).forEach(key => {
400 const field = this.idl.classes.aua.field_map[key];
401 if (field && !field.virtual) {
402 const value = stageAddr[key]();
403 if (value !== null) {
409 patron.addresses().push(addr);
411 if (cls === 'stgma') {
412 patron.mailing_address(addr);
414 patron.billing_address(addr);
418 addrFromStage(stageData.mailing_addresses[0]);
419 addrFromStage(stageData.billing_addresses[0]);
421 if (patron.addresses().length === 1) {
422 // Only one address, use it for both purposes.
423 const addr = patron.addresses()[0];
424 patron.mailing_address(addr);
425 patron.billing_address(addr);
428 if (stageData.cards[0]) {
429 const card = this.idl.create('ac');
431 card.id(this.autoId--);
432 card.barcode(stageData.cards[0].barcode());
434 patron.cards([card]);
436 if (!patron.usrname()) {
437 patron.usrname(card.barcode());
441 stageData.settings.forEach(setting => {
442 this.userSettings[setting.setting()] = Boolean(setting.value());
445 stageData.statcats.forEach(entry => {
447 entry.statcat(Number(entry.statcat()));
449 const stat: StatCat =
450 this.statCats.filter(s => s.cat.id() === entry.statcat())[0];
452 let cboxEntry: ComboboxEntry =
453 stat.entries.filter(e => e.label === entry.value())[0];
456 // If the applied value is not in the list of entries,
457 // create a freetext combobox entry for it.
464 stat.entries.unshift(cboxEntry);
467 this.userStatCats[entry.statcat()] = cboxEntry;
469 // This forces the creation of the stat cat entry IDL objects.
470 this.userStatCatChange(stat.cat, cboxEntry);
473 if (patron.billing_address()) {
474 this.handlePostCodeChange(
475 patron.billing_address(), patron.billing_address().post_code());
479 checkStageUserDupes(): Promise<any> {
480 // Fire duplicate patron checks,once for each category
482 const patron = this.patron;
484 // Fire-and-forget the email search because it can take several seconds
485 if (patron.email()) {
486 this.dupeValueChange('email', patron.email());
489 return this.dupeValueChange('name', patron.family_name())
492 if (patron.ident_value()) {
493 return this.dupeValueChange('ident', patron.ident_value());
497 if (patron.day_phone()) {
498 return this.dupeValueChange('phone', patron.day_phone());
502 let promise = Promise.resolve();
503 this.patron.addresses().forEach(addr => {
505 promise.then(__ => this.dupeValueChange('address', addr));
507 promise.then(__ => this.toolbar.checkAddressAlerts(patron, addr));
512 copyCloneData(clone: IdlObject) {
513 const patron = this.patron;
515 // flesh the home org locally
516 patron.home_ou(clone.home_ou());
518 ['day_phone', 'evening_phone', 'other_phone', 'usrgroup']
519 .forEach(field => patron[field](clone[field]()));
521 // Create a new address from an existing address
522 const cloneAddr = (addr: IdlObject) => {
523 const newAddr = this.idl.clone(addr);
524 newAddr.id(this.autoId--);
525 newAddr.usr(patron.id());
532 this.context.settingsCache['circ.patron_edit.clone.copy_address'];
534 // No addresses to copy/link. Stick with the defaults.
535 if (clone.addresses().length === 0) { return; }
537 patron.addresses([]);
539 clone.addresses().forEach(sourceAddr => {
541 const myAddr = copyAddrs ? cloneAddr(sourceAddr) : sourceAddr;
542 if (copyAddrs) { myAddr._linked_owner = clone; }
544 if (clone.billing_address() === sourceAddr.id()) {
545 this.patron.billing_address(myAddr);
548 if (clone.mailing_address() === sourceAddr.id()) {
549 this.patron.mailing_address(myAddr);
552 this.patron.addresses().push(myAddr);
555 // If we have one type of address but not the other, use the one
556 // we have for both address purposes.
558 if (!this.patron.billing_address() && this.patron.mailing_address()) {
559 this.patron.billing_address(this.patron.mailing_address());
562 if (this.patron.billing_address() && !this.patron.mailing_address()) {
563 this.patron.mailing_address(this.patron.billing_address());
567 getFieldDocs(): Promise<any> {
568 return this.pcrud.search('fdoc', {
569 fm_class: ['au', 'ac', 'aua', 'actsc', 'asv', 'asvq', 'asva']})
571 if (!this.fieldDoc[doc.fm_class()]) {
572 this.fieldDoc[doc.fm_class()] = {};
574 this.fieldDoc[doc.fm_class()][doc.field()] = doc.string();
578 getFieldDoc(cls: string, field: string): string {
579 cls = this.getClass(cls);
580 if (this.fieldDoc[cls]) {
581 return this.fieldDoc[cls][field];
585 exampleText(cls: string, field: string): string {
586 cls = this.getClass(cls);
587 return this.context.settingsCache[`ui.patron.edit.${cls}.${field}.example`];
590 setSurveys(): Promise<any> {
591 return this.patronService.getSurveys()
592 .then(surveys => this.surveys = surveys);
595 surveyQuestionAnswers(question: IdlObject): ComboboxEntry[] {
596 return question.answers().map(
597 a => ({id: a.id(), label: a.answer(), fm: a}));
600 setStatCats(): Promise<any> {
602 return this.patronService.getStatCats().then(cats => {
603 cats.forEach(cat => {
604 cat.id(Number(cat.id()));
605 cat.entries().forEach(entry => entry.id(Number(entry.id())));
607 const entries = cat.entries().map(entry =>
608 ({id: entry.id(), label: entry.value()}));
618 setSmsCarriers(): Promise<any> {
619 if (!this.context.settingsCache['sms.enable']) {
620 return Promise.resolve();
623 return this.patronService.getSmsCarriers().then(carriers => {
624 this.smsCarriers = carriers.map(carrier => {
627 label: carrier.name()
633 getSecondaryGroups(): Promise<any> {
634 return this.net.request(
636 'open-ils.actor.user.get_groups',
637 this.auth.token(), this.patronId
639 ).pipe(concatMap(maps => {
640 if (maps.length === 0) { return []; }
642 return this.pcrud.search('pgt',
643 {id: maps.map(m => m.grp())}, {}, {atomic: true});
645 })).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
648 setIdentTypes(): Promise<any> {
649 return this.patronService.getIdentTypes()
651 this.identTypes = types.map(t => ({id: t.id(), label: t.name()}));
655 setInetLevels(): Promise<any> {
656 return this.patronService.getInetLevels()
658 this.inetLevels = levels.map(t => ({id: t.id(), label: t.name()}));
662 applyPerms(): Promise<any> {
664 const promise = this.permOrgs ?
665 Promise.resolve(this.permOrgs) :
666 this.perms.hasWorkPermAt(PERMS_NEEDED, true);
668 return promise.then(permOrgs => {
669 this.permOrgs = permOrgs;
670 Object.keys(permOrgs).forEach(perm =>
672 permOrgs[perm].includes(this.patron.home_ou())
677 setOptInSettings(): Promise<any> {
678 const orgIds = this.org.ancestors(this.auth.user().ws_ou(), true);
682 {name : COMMON_USER_SETTING_TYPES},
683 {name : { // opt-in notification user settings
685 select : {atevdef : ['opt_in_setting']},
687 // we only care about opt-in settings for
688 // event_defs our users encounter
689 where : {'+atevdef' : {owner : orgIds}}
695 return this.pcrud.search('cust', query, {}, {atomic : true})
696 .toPromise().then(types => {
698 types.forEach(stype => {
699 this.userSettingTypes[stype.name()] = stype;
700 if (!COMMON_USER_SETTING_TYPES.includes(stype.name())) {
701 this.optInSettingTypes[stype.name()] = stype;
707 loadPatron(): Promise<any> {
709 return this.patronService.getFleshedById(this.patronId, PATRON_FLESH_FIELDS)
711 this.patron = patron;
712 this.origUsername = patron.usrname();
713 this.absorbPatronData();
716 return Promise.resolve(this.createNewPatron());
722 const usets = this.userSettings;
725 this.holdNotifyValues.day_phone = this.patron.day_phone();
726 this.holdNotifyValues.other_phone = this.patron.other_phone();
727 this.holdNotifyValues.evening_phone = this.patron.evening_phone();
729 this.patron.settings().forEach(stg => {
730 const value = stg.value();
731 if (value !== '' && value !== null) {
732 usets[stg.name()] = JSON.parse(value);
736 const holdNotify = usets['opac.hold_notify'];
739 this.holdNotifyTypes.email = this.holdNotifyValues.email_notify
740 = holdNotify.match(/email/) !== null;
742 this.holdNotifyTypes.phone = this.holdNotifyValues.phone_notify
743 = holdNotify.match(/phone/) !== null;
745 this.holdNotifyTypes.sms = this.holdNotifyValues.sms_notify
746 = holdNotify.match(/sms/) !== null;
749 if (setting = usets['opac.default_sms_carrier']) {
750 setting = usets['opac.default_sms_carrier'] = Number(setting);
751 this.holdNotifyValues.default_sms_carrier = setting;
754 if (setting = usets['opac.default_phone']) {
755 this.holdNotifyValues.default_phone = setting;
758 if (setting = usets['opac.default_sms_notify']) {
759 this.holdNotifyValues.default_sms_notify = setting;
762 if (setting = usets['opac.default_pickup_location']) {
763 usets['opac.default_pickup_location'] = Number(setting);
766 this.expireDate = new Date(this.patron.expire_date());
768 // stat_cat_entries() are entry maps under the covers.
769 this.patron.stat_cat_entries().forEach(map => {
771 const stat: StatCat =
772 this.statCats.filter(s => s.cat.id() === map.stat_cat())[0];
774 let cboxEntry: ComboboxEntry =
775 stat.entries.filter(e => e.label === map.stat_cat_entry())[0];
778 // If the applied value is not in the list of entries,
779 // create a freetext combobox entry for it.
783 label: map.stat_cat_entry(),
787 stat.entries.unshift(cboxEntry);
790 this.userStatCats[map.stat_cat()] = cboxEntry;
793 if (this.patron.waiver_entries().length === 0) {
797 if (!this.patron.card()) {
798 this.replaceBarcode();
803 const patron = this.idl.create('au');
806 patron.home_ou(this.auth.user().ws_ou());
809 patron.waiver_entries([]);
810 patron.stat_cat_entries([]);
812 const card = this.idl.create('ac');
815 card.id(this.autoId--);
817 patron.cards([card]);
819 const addr = this.idl.create('aua');
824 addr.within_city_limits('f');
825 addr.country(this.context.settingsCache['ui.patron.default_country']);
826 patron.billing_address(addr);
827 patron.mailing_address(addr);
828 patron.addresses([addr]);
830 this.strings.interpolate('circ.patron.edit.default_addr_type')
831 .then(msg => addr.address_type(msg));
833 this.serverStore.getItem('ui.patron.default_ident_type')
835 if (identType) { patron.ident_type(Number(identType)); }
838 this.patron = patron;
842 objectFromPath(path: string, index: number): IdlObject {
843 const base = path ? this.patron[path]() : this.patron;
844 if (index === null || index === undefined) {
847 // Some paths lead to an array of objects.
852 getFieldLabel(idlClass: string, field: string, override?: string): string {
853 return override ? override :
854 this.idl.classes[idlClass].field_map[field].label;
857 // With this, the 'cls' specifier is only needed in the template
858 // when it's not 'au', which is the base/common class.
859 getClass(cls: string): string {
863 getFieldValue(path: string, index: number, field: string): any {
864 return this.objectFromPath(path, index)[field]();
868 // Timeout gives the form a chance to mark fields as (in)valid
871 const invalidInput = document.querySelector('.ng-invalid');
874 invalidInput === null
876 && !this.dupeUsername
877 && !this.selfEditForbidden()
878 && !this.groupEditForbidden()
882 this.toolbar.disableSaveStateChanged.emit(!canSave);
888 // Avoid responding to any value changes while we are loading
889 if (this.loading) { return; }
890 this.changesPending = true;
891 this.emitSaveState();
894 userStatCatChange(cat: IdlObject, entry: ComboboxEntry) {
895 let map = this.patron.stat_cat_entries()
896 .filter(m => m.stat_cat() === cat.id())[0];
900 map.stat_cat_entry(entry.label);
902 map.isdeleted(false);
905 // Deleting a stat cat that was created during this
906 // edit session just means removing it from the list
907 // of maps to consider.
908 this.patron.stat_cat_entries(
909 this.patron.stat_cat_entries()
910 .filter(m => m.stat_cat() !== cat.id())
917 map = this.idl.create('actscecm');
919 map.stat_cat(cat.id());
920 map.stat_cat_entry(entry.label);
921 map.target_usr(this.patronId);
922 this.patron.stat_cat_entries().push(map);
925 this.adjustSaveState();
928 userSettingChange(name: string, value: any) {
929 this.userSettings[name] = value;
930 this.adjustSaveState();
933 applySurveyResponse(question: IdlObject, answer: ComboboxEntry) {
934 if (!this.patron.survey_responses()) {
935 this.patron.survey_responses([]);
938 const responses = this.patron.survey_responses()
939 .filter(r => r.question() !== question.id());
941 const resp = this.idl.create('asvr');
943 resp.survey(question.survey());
944 resp.question(question.id());
945 resp.answer(answer.id);
946 resp.usr(this.patron.id());
947 resp.answer_date('now');
948 responses.push(resp);
949 this.patron.survey_responses(responses);
952 // Called as the model changes.
953 // This may be called many times before the final value is applied,
954 // so avoid any heavy lifting here. See afterFieldChange();
955 fieldValueChange(path: string, index: number, field: string, value: any) {
956 if (typeof value === 'boolean') { value = value ? 't' : 'f'; }
958 // This can be called in cases where components fire up, even
959 // though the actual value on the patron has not changed.
960 // Exit early in that case so we don't mark the form as dirty.
961 const oldValue = this.getFieldValue(path, index, field);
962 if (oldValue === value) { return; }
964 this.changeHandlerNeeded = true;
965 this.objectFromPath(path, index)[field](value);
968 // Called after a change operation has completed (e.g. on blur)
969 afterFieldChange(path: string, index: number, field: string) {
970 if (!this.changeHandlerNeeded) { return; } // no changes applied
971 this.changeHandlerNeeded = false;
973 const obj = this.objectFromPath(path, index);
974 const value = this.getFieldValue(path, index, field);
975 obj.ischanged(true); // isnew() supersedes
978 `Modifying field path=${path || ''} field=${field} value=${value}`);
983 this.maintainJuvFlag();
987 this.setExpireDate();
991 case 'evening_phone':
993 this.handlePhoneChange(field, value);
998 case 'first_given_name':
1001 this.dupeValueChange(field, value);
1007 // dupe search on address wants the address object as the value.
1008 this.dupeValueChange('address', obj);
1009 this.toolbar.checkAddressAlerts(this.patron, obj);
1013 this.handlePostCodeChange(obj, value);
1017 this.handleBarcodeChange(value);
1021 this.handleUsernameChange(value);
1025 this.adjustSaveState();
1030 if (!this.patron.dob()) { return; }
1033 this.context.settingsCache['global.juvenile_age_threshold']
1036 const cutoff = new Date();
1038 cutoff.setTime(cutoff.getTime() -
1039 Number(DateUtil.intervalToSeconds(interval) + '000'));
1041 const isJuve = new Date(this.patron.dob()) > cutoff;
1043 this.fieldValueChange(null, null, 'juvenile', isJuve);
1044 this.afterFieldChange(null, null, 'juvenile');
1047 handlePhoneChange(field: string, value: string) {
1048 this.dupeValueChange(field, value);
1051 this.context.settingsCache['patron.password.use_phone'];
1053 if (field === 'day_phone' && value &&
1054 this.patron.isnew() && !this.patron.passwd() && pwUsePhone) {
1055 this.fieldValueChange(null, null, 'passwd', value.substr(-4));
1056 this.afterFieldChange(null, null, 'passwd');
1060 handlePostCodeChange(addr: IdlObject, postCode: any) {
1062 'open-ils.search', 'open-ils.search.zip', postCode
1063 ).subscribe(resp => {
1064 if (!resp) { return; }
1066 ['city', 'state', 'county'].forEach(field => {
1068 addr[field](resp[field]);
1073 this.addrAlert.dialogBody = resp.alert;
1074 this.addrAlert.open();
1079 handleUsernameChange(value: any) {
1080 this.dupeUsername = false;
1082 if (!value || value === this.origUsername) {
1083 // In case the usrname changes then changes back.
1089 'open-ils.actor.username.exists',
1090 this.auth.token(), value
1091 ).subscribe(resp => this.dupeUsername = Boolean(resp));
1094 handleBarcodeChange(value: any) {
1095 this.dupeBarcode = false;
1097 if (!value) { return; }
1101 'open-ils.actor.barcode.exists',
1102 this.auth.token(), value
1103 ).subscribe(resp => {
1104 if (Number(resp) === 1) {
1105 this.dupeBarcode = true;
1108 if (this.patron.usrname()) { return; }
1110 // Propagate username with barcode value by default.
1111 // This will apply the value and fire the dupe checker
1112 this.updateUsernameRegex();
1113 this.fieldValueChange(null, null, 'usrname', value);
1114 this.afterFieldChange(null, null, 'usrname');
1119 dupeValueChange(name: string, value: any) {
1121 if (name.match(/phone/)) { name = 'phone'; }
1122 if (name.match(/name/)) { name = 'name'; }
1123 if (name.match(/ident/)) { name = 'ident'; }
1125 let search: PatronSearchFieldSet;
1129 const fname = this.patron.first_given_name();
1130 const lname = this.patron.family_name();
1131 if (!fname || !lname) { return; }
1133 first_given_name : {value : fname, group : 0},
1134 family_name : {value : lname, group : 0}
1139 search = {email : {value : value, group : 0}};
1143 search = {ident : {value : value, group : 2}};
1147 search = {phone : {value : value, group : 2}};
1152 ['street1', 'street2', 'city', 'post_code'].forEach(field => {
1153 if (value[field]()) {
1154 search[field] = {value : value[field](), group: 1};
1160 this.toolbar.checkDupes(name, search);
1163 showField(field: string): boolean {
1165 if (this.fieldVisibility[field] === undefined) {
1166 // Settings have not yet been applied for this field.
1167 // Calculate them now.
1169 // The preferred name fields use the primary name field settings
1170 let settingKey = field;
1171 let altName = false;
1172 if (field.match(/^au.alt_/)) {
1174 settingKey = field.replace(/alt_/, '');
1177 const required = `ui.patron.edit.${settingKey}.require`;
1178 const show = `ui.patron.edit.${settingKey}.show`;
1179 const suggest = `ui.patron.edit.${settingKey}.suggest`;
1181 if (this.context.settingsCache[required]) {
1183 // Preferred name fields are never required.
1184 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1186 this.fieldVisibility[field] = FieldVisibility.REQUIRED;
1189 } else if (this.context.settingsCache[show]) {
1190 this.fieldVisibility[field] = FieldVisibility.VISIBLE;
1192 } else if (this.context.settingsCache[suggest]) {
1193 this.fieldVisibility[field] = FieldVisibility.SUGGESTED;
1197 if (this.fieldVisibility[field] === undefined) {
1198 // No org settings were applied above. Use the default
1199 // settings if present or assume the field has no
1200 // visibility flags applied.
1201 this.fieldVisibility[field] = DEFAULT_FIELD_VISIBILITY[field] || 0;
1204 return this.fieldVisibility[field] >= this.toolbar.visibilityLevel;
1207 fieldRequired(field: string): boolean {
1211 // Only required for new patrons
1212 return this.patronId === null;
1215 // If the user ops in for email notices, require
1217 return this.holdNotifyTypes.email;
1220 return this.fieldVisibility[field] === 3;
1223 settingFieldRequired(name: string): boolean {
1226 case 'opac.default_sms_notify':
1227 case 'opac.default_sms_carrier':
1228 return this.holdNotifyTypes.sms;
1234 fieldPattern(idlClass: string, field: string): RegExp {
1235 if (!this.fieldPatterns[idlClass][field]) {
1236 this.fieldPatterns[idlClass][field] = new RegExp('.*');
1238 return this.fieldPatterns[idlClass][field];
1241 generatePassword() {
1242 this.fieldValueChange(null, null,
1243 'passwd', Math.floor(Math.random() * 9000) + 1000);
1245 // Normally this is called on (blur), but the input is not
1246 // focused when using the generate button.
1247 this.afterFieldChange(null, null, 'passwd');
1251 cannotHaveUsersOrgs(): number[] {
1252 return this.org.list()
1253 .filter(org => org.ou_type().can_have_users() === 'f')
1254 .map(org => org.id());
1257 cannotHaveVolsOrgs(): number[] {
1258 return this.org.list()
1259 .filter(org => org.ou_type().can_have_vols() === 'f')
1260 .map(org => org.id());
1264 const profile = this.profileSelect.profiles[this.patron.profile()];
1265 if (!profile) { return; }
1267 const seconds = DateUtil.intervalToSeconds(profile.perm_interval());
1268 const nowEpoch = new Date().getTime();
1269 const newDate = new Date(nowEpoch + (seconds * 1000 /* millis */));
1270 this.expireDate = newDate;
1271 this.fieldValueChange(null, null, 'expire_date', newDate.toISOString());
1272 this.afterFieldChange(null, null, 'expire_date');
1275 handleBoolResponse(success: boolean,
1276 msg: string, errMsg?: string): Promise<boolean> {
1279 return this.strings.interpolate(msg)
1280 .then(str => this.toast.success(str))
1284 console.error(errMsg);
1286 return this.strings.interpolate(msg)
1287 .then(str => this.toast.danger(str))
1291 sendTestMessage(hook: string): Promise<boolean> {
1293 return this.net.request(
1295 'open-ils.actor.event.test_notification',
1296 this.auth.token(), {hook: hook, target: this.patronId}
1297 ).toPromise().then(resp => {
1299 if (resp && resp.template_output && resp.template_output() &&
1300 resp.template_output().is_error() === 'f') {
1301 return this.handleBoolResponse(
1302 true, 'circ.patron.edit.test_notify.success');
1305 return this.handleBoolResponse(
1306 false, 'circ.patron.edit.test_notify.fail',
1307 'Test Notification Failed ' + resp);
1312 invalidateField(field: string): Promise<boolean> {
1314 return this.net.request(
1316 'open-ils.actor.invalidate.' + field,
1317 this.auth.token(), this.patronId, null, this.patron.home_ou()
1319 ).toPromise().then(resp => {
1320 const evt = this.evt.parse(resp);
1322 if (evt && evt.textcode !== 'SUCCESS') {
1323 return this.handleBoolResponse(false,
1324 'circ.patron.edit.invalidate.fail',
1325 'Field Invalidation Failed: ' + resp);
1328 this.patron[field](null);
1330 // Keep this in sync for future updates.
1331 this.patron.last_xact_id(resp.payload.last_xact_id[this.patronId]);
1333 return this.handleBoolResponse(
1334 true, 'circ.patron.edit.invalidate.success');
1338 openGroupsDialog() {
1339 this.secondaryGroupsDialog.open({size: 'lg'}).subscribe(groups => {
1340 if (!groups) { return; }
1342 this.secondaryGroups = groups;
1344 if (this.patron.isnew()) {
1345 // Links will be applied after the patron is created.
1349 // Apply the new links to an existing user in real time
1350 this.applySecondaryGroups();
1354 applySecondaryGroups(): Promise<boolean> {
1356 const groupIds = this.secondaryGroups.map(grp => grp.id());
1358 return this.net.request(
1360 'open-ils.actor.user.set_groups',
1361 this.auth.token(), this.patronId, groupIds
1362 ).toPromise().then(resp => {
1364 if (Number(resp) === 1) {
1365 return this.handleBoolResponse(
1366 true, 'circ.patron.edit.grplink.success');
1369 return this.handleBoolResponse(
1370 false, 'circ.patron.edit.grplink.fail',
1371 'Failed to change group links: ' + resp);
1376 // Set the mailing or billing address
1377 setAddrType(addrType: string, addr: IdlObject, selected: boolean) {
1379 this.patron[addrType + '_address'](addr);
1381 // Unchecking mailing/billing means we have to randomly
1382 // select another address to fill that role. Select the
1383 // first address in the list (that does not match the
1386 this.patron.addresses().some(a => {
1387 if (a.id() !== addr.id()) {
1388 this.patron[addrType + '_address'](a);
1389 return found = true;
1394 // No alternate address was found. Clear the value.
1395 this.patron[addrType + '_address'](null);
1398 this.patron.ischanged(true);
1402 deleteAddr(addr: IdlObject) {
1403 const addresses = this.patron.addresses();
1404 let promise = Promise.resolve(false);
1406 if (this.patron.isnew() && addresses.length === 1) {
1407 promise = this.serverStore.getItem(
1408 'ui.patron.registration.require_address');
1411 promise.then(required => {
1414 this.addrRequiredAlert.open();
1418 // Roll the mailing/billing designation to another
1419 // address when needed.
1420 if (this.patron.mailing_address() &&
1421 this.patron.mailing_address().id() === addr.id()) {
1422 this.setAddrType('mailing', addr, false);
1425 if (this.patron.billing_address() &&
1426 this.patron.billing_address().id() === addr.id()) {
1427 this.setAddrType('billing', addr, false);
1433 addresses.some((a, i) => {
1434 if (a.id() === addr.id()) { idx = i; return true; }
1437 // New addresses can be discarded
1438 addresses.splice(idx, 1);
1441 addr.isdeleted(true);
1447 const addr = this.idl.create('aua');
1448 addr.id(this.autoId--);
1451 this.patron.addresses().push(addr);
1454 nonDeletedAddresses(): IdlObject[] {
1455 return this.patron.addresses().filter(a => !a.isdeleted());
1458 save(clone?: boolean): Promise<any> {
1460 this.changesPending = false;
1461 this.loading = true;
1462 this.showForm = false;
1464 return this.saveUser()
1465 .then(_ => this.saveUserSettings())
1466 .then(_ => this.updateHoldPrefs())
1467 .then(_ => this.removeStagedUser())
1468 .then(_ => this.postSaveRedirect(clone));
1471 postSaveRedirect(clone: boolean) {
1473 this.worklog.record({
1474 user: this.modifiedPatron.family_name(),
1475 patron_id: this.modifiedPatron.id(),
1476 action: this.patron.isnew() ? 'registered_patron' : 'edited_patron'
1479 if (this.stageUser) {
1480 this.broadcaster.broadcast('eg.pending_usr.update',
1481 {usr: this.idl.toHash(this.modifiedPatron)});
1483 // Typically, this window is opened as a new tab from the
1484 // pending users interface. Once we're done, just close the
1491 this.context.summary = null;
1492 this.router.navigate(
1493 ['/staff/circ/patron/register/clone', this.modifiedPatron.id()]);
1496 // Full refresh to force reload of modified patron data.
1497 window.location.href = window.location.href;
1501 // Resolves on success, rejects on error
1502 saveUser(): Promise<IdlObject> {
1503 this.modifiedPatron = null;
1505 // A dummy waiver is added on load. Remove it if no values were added.
1506 this.patron.waiver_entries(
1507 this.patron.waiver_entries().filter(e => !e.isnew() || e.name()));
1509 return this.net.request(
1511 'open-ils.actor.patron.update',
1512 this.auth.token(), this.patron
1513 ).toPromise().then(result => {
1515 if (result && result.classname) {
1516 this.context.addRecentPatron(result.id());
1518 // Successful result returns the patron IdlObject.
1519 return this.modifiedPatron = result;
1522 const evt = this.evt.parse(result);
1525 console.error('Patron update failed with', evt);
1526 if (evt.textcode === 'XACT_COLLISION') {
1527 this.xactCollisionAlert.open().toPromise().then(_ =>
1528 window.location.href = window.location.href
1533 alert('Patron update failed:' + result);
1536 return Promise.reject('Save Failed');
1540 // Resolves on success, rejects on error
1541 saveUserSettings(): Promise<any> {
1543 let settings: any = {};
1545 const holdMethods = [];
1547 ['email', 'phone', 'sms'].forEach(method => {
1548 if (this.holdNotifyTypes[method]) {
1549 holdMethods.push(method);
1553 this.userSettings['opac.hold_notify'] =
1554 holdMethods.length > 0 ? holdMethods.join(':') : null;
1556 if (this.patronId) {
1557 // Update all user editor setting values for existing
1558 // users regardless of whether a value changed.
1559 settings = this.userSettings;
1563 // Create settings for all non-null setting values for new patrons.
1564 Object.keys(this.userSettings).forEach(key => {
1565 const val = this.userSettings[key];
1566 if (val !== null) { settings[key] = val; }
1570 if (Object.keys(settings).length === 0) { return Promise.resolve(); }
1572 return this.net.request(
1574 'open-ils.actor.patron.settings.update',
1575 this.auth.token(), this.modifiedPatron.id(), settings
1580 updateHoldPrefs(): Promise<any> {
1581 if (this.patron.isnew()) { return Promise.resolve(); }
1583 return this.collectHoldNotifyChange()
1586 if (mods.length === 0) { return Promise.resolve(); }
1588 this.holdNotifyUpdateDialog.patronId = this.patronId;
1589 this.holdNotifyUpdateDialog.mods = mods;
1590 this.holdNotifyUpdateDialog.smsCarriers = this.smsCarriers;
1592 this.holdNotifyUpdateDialog.defaultCarrier =
1593 this.userSettings['opac.default_sms_carrier']
1594 || this.holdNotifyValues.default_sms_carrier;
1596 return this.holdNotifyUpdateDialog.open().toPromise();
1600 // Compare current values with those collected at patron load time.
1601 // For any that have changed, ask the server if the original values
1602 // are used on active holds.
1603 collectHoldNotifyChange(): Promise<any[]> {
1605 const holdNotify = this.userSettings['opac.hold_notify'] || '';
1607 return from(Object.keys(this.holdNotifyValues))
1608 .pipe(concatMap(field => {
1610 let newValue, matches;
1612 if (field.match(/default_/)) {
1613 newValue = this.userSettings[`opac.${field}`] || null;
1615 } else if (field.match(/_phone/)) {
1616 newValue = this.patron[field]();
1618 } else if (matches = field.match(/(\w+)_notify/)) {
1619 const notify = this.userSettings['opac.hold_notify'] || '';
1620 newValue = notify.match(matches[1]) !== null;
1623 const oldValue = this.holdNotifyValues[field];
1625 // No change to apply?
1626 if (newValue === oldValue) { return empty(); }
1628 // API / user setting name mismatch
1629 if (field.match(/carrier/)) { field += '_id'; }
1631 const apiValue = field.match(/notify|carrier/) ? oldValue : newValue;
1633 return this.net.request(
1635 'open-ils.circ.holds.retrieve_by_notify_staff',
1636 this.auth.token(), this.patronId, apiValue, field
1637 ).pipe(tap(holds => {
1638 if (holds && holds.length > 0) {
1647 })).toPromise().then(_ => mods);
1650 removeStagedUser(): Promise<any> {
1651 if (!this.stageUser) { return Promise.resolve(); }
1653 return this.net.request(
1655 'open-ils.actor.user.stage.delete',
1657 this.stageUser.user.row_id()
1662 this.printer.print({
1663 templateName: 'patron_data',
1664 contextData: {patron: this.patron},
1665 printContext: 'default'
1670 // Disable current card
1672 this.replaceBarcodeUsed = true;
1674 if (this.patron.card()) {
1675 // patron.card() is not the same in-memory object as its
1676 // analog in patron.cards(). Since we're about to replace
1677 // patron.card() anyway, just update the patron.cards() version.
1678 const crd = this.patron.cards()
1679 .filter(c => c.id() === this.patron.card().id())[0];
1682 crd.ischanged(true);
1685 const card = this.idl.create('ac');
1687 card.id(this.autoId--);
1688 card.usr(this.patron.id());
1691 this.patron.card(card);
1692 this.patron.cards().push(card);
1694 // Focus the barcode input
1696 this.emitSaveState();
1697 const node = document.getElementById('ac-barcode-input');
1705 canSave(): boolean {
1706 return document.querySelector('.ng-invalid') === null;
1709 setFieldPatterns() {
1713 this.context.settingsCache['ui.patron.edit.ac.barcode.regex']) {
1714 this.fieldPatterns.ac.barcode = new RegExp(regex);
1717 if (regex = this.context.settingsCache['global.password_regex']) {
1718 this.fieldPatterns.au.passwd = new RegExp(regex);
1721 if (regex = this.context.settingsCache['ui.patron.edit.phone.regex']) {
1722 // apply generic phone regex first, replace below as needed.
1723 this.fieldPatterns.au.day_phone = new RegExp(regex);
1724 this.fieldPatterns.au.evening_phone = new RegExp(regex);
1725 this.fieldPatterns.au.other_phone = new RegExp(regex);
1728 // the remaining this.fieldPatterns fit a well-known key name pattern
1730 Object.keys(this.context.settingsCache).forEach(key => {
1731 const val = this.context.settingsCache[key];
1732 if (!val) { return; }
1733 const parts = key.match(/ui.patron.edit\.(\w+)\.(\w+)\.regex/);
1734 if (!parts) { return; }
1735 const cls = parts[1];
1736 const name = parts[2];
1737 this.fieldPatterns[cls][name] = new RegExp(val);
1740 this.updateUsernameRegex();
1743 // The username must match either the configured regex or the
1745 updateUsernameRegex() {
1746 const regex = this.context.settingsCache['opac.username_regex'];
1748 const barcode = this.patron.card().barcode();
1750 this.fieldPatterns.au.usrname =
1751 new RegExp(`${regex}|^${barcode}$`);
1753 // username must match the regex
1754 this.fieldPatterns.au.usrname = new RegExp(regex);
1757 // username can be any format.
1758 this.fieldPatterns.au.usrname = new RegExp('.*');
1762 selfEditForbidden(): boolean {
1764 this.patron.id() === this.auth.user().id()
1765 && !this.hasPerm.EDIT_SELF_IN_CLIENT
1769 groupEditForbidden(): boolean {
1771 this.patron.profile()
1772 && !this.editProfiles.includes(this.patron.profile())
1777 const waiver = this.idl.create('aupw');
1779 waiver.id(this.autoId--);
1780 waiver.usr(this.patronId);
1781 this.patron.waiver_entries().push(waiver);
1784 removeWaiver(waiver: IdlObject) {
1785 if (waiver.isnew()) {
1786 this.patron.waiver_entries(
1787 this.patron.waiver_entries().filter(w => w.id() !== waiver.id()));
1789 if (this.patron.waiver_entries().length === 0) {
1790 // We need at least one waiver to access action buttons
1794 waiver.isdeleted(true);