LP1904036 Patron editor: user settings
authorBill Erickson <berickxx@gmail.com>
Fri, 19 Mar 2021 20:58:17 +0000 (16:58 -0400)
committerGalen Charlton <gmc@equinoxOLI.org>
Fri, 28 Oct 2022 00:13:27 +0000 (20:13 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/edit.component.html
Open-ILS/src/eg2/src/app/staff/circ/patron/edit.component.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/resolver.service.ts
Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts

index f7c138e..ac616d7 100644 (file)
@@ -213,14 +213,14 @@ export class HoldComponent implements OnInit {
 
             if (this.smsEnabled) {
 
-                return this.pcrud.search(
-                    'csc', {active: 't'}, {order_by: {csc: 'name'}})
-                .pipe(tap(carrier => {
-                    this.smsCarriers.push({
-                        id: carrier.id(),
-                        label: carrier.name()
+                return this.patron.getSmsCarriers().then(carriers => {
+                    carriers.forEach(carrier => {
+                        this.smsCarriers.push({
+                            id: carrier.id(),
+                            label: carrier.name()
+                        });
                     });
-                })).toPromise();
+                });
             }
 
         }).then(_ => {
index 8e562b7..53d01f4 100644 (file)
   </div>
 </ng-template>
 
+<ng-template #userSettingLabel let-args="args">
+  <div class="col-lg-3 field-label">
+    <label for="cust-{{args.settingName}}-input">
+      {{userSettingTypes[args.settingName].label()}}
+    </label>
+  </div>
+</ng-template>
+
 <!-- text / number / email inputs -->
 <ng-template #fieldInput let-args="args">
   <div class="col-lg-3">
@@ -72,7 +80,7 @@
     <eg-combobox [entries]="args.entries"
       name="{{getClass(args.cls)}}-{{args.field}}-input"
       domId="{{getClass(args.cls)}}-{{args.field}}-input"
-      [startId]="getFieldValue(args.path, args.field)"
+      [selectedId]="getFieldValue(args.path, args.field)"
       (onChange)="
         fieldValueChange(args.path, args.field, $event ? $event.id : null); 
         afterFieldChange(args.path, args.field)"
 <!-- like fieldRow below, but for user settings checkboxes -->
 <ng-template #userSettingsCheckboxRow let-args="args">
   <div class="row pt-1 pb-1 mt-1">
-    <div class="col-lg-3 field-label">
-      <label for="cust-{{args.settingName}}-input">
-        {{userSettingTypes[args.settingName].label()}}
-      </label>
-    </div>
+    <ng-container *ngTemplateOutlet="userSettingLabel; context: {args: args}">
+    </ng-container>
     <div class="col-lg-3">
       <input 
         type="checkbox"
         class="form-check-input ml-0"
         name="cust-{{args.settingName}}-input"
-        id="cust-{{args.setingName}-input"
+        id="cust-{{args.settingName}}-input"
         [ngModel]="userSettings[args.settingName]"
         (ngModelChange)="userSettingChange(args.settingName, $event)"
         [disabled]="args.disabled"
   </div>
 </ng-template>
 
+<ng-template #userSettingsInput let-args="args">
+  <div class="col-lg-3">
+    <input 
+      type="{{args.type || 'text'}}"
+      class="form-control"
+      name="cust-{{args.settingName}}-input"
+      id="cust-{{args.settingName}}-input"
+      [ngModel]="userSettings[args.settingName]"
+      (ngModelChange)="userSettingChange(args.settingName, $event)"
+      [disabled]="args.disabled"
+    />
+  </div>
+</ng-template>
+
+
+<ng-template #userSettingsInputRow let-args="args">
+  <div class="row pt-1 pb-1 mt-1">
+    <ng-container *ngTemplateOutlet="userSettingLabel; context: {args: args}">
+    </ng-container>
+    <ng-container *ngTemplateOutlet="userSettingInput; context: {args: args}">
+    </ng-container>
+  </div>
+</ng-template>
+
+
 <!-- One row of label + field.  
     Used when a field requires no additional toggles. -->
 <ng-template #fieldRow let-args="args">
 
 <!-- The List O' Fields -->
 
-<div class="mt-3 striped-rows-even patron-edit-container" *ngIf="patron">
+<div class="mt-3 striped-rows-even patron-edit-container" *ngIf="patron && !loading">
   <ng-container *ngTemplateOutlet="fieldRow; context: {args: 
     {template: fieldInput, field: 'barcode', cls: 'ac', 
     path: 'card', disabled: !patron.isnew()}}">
   <div class="border rounded p-2" [ngClass]="{
     'border-primary': nameTab == 'primary', 
     'border-success': nameTab == 'preferred'}">
-    <b>{{nameTab}}</b>
     <div [ngbNavOutlet]="nameNav"></div>
   </div>
 
       *ngTemplateOutlet="fieldInput; context: {args: {field: 'email', type: 'email'}}">
     </ng-container>
     <div class="col-lg-6">
-      <button class="btn btn-outline-dark" 
-        (click)="sendTestMessage('au.email.test')" i18n>
-        Send Test Email
-      </button>
-      <button class="btn btn-outline-dark ml-2" 
-        (click)="invalidateField('email')" i18n>Invalidate</button>
+      <ng-container *ngIf="patron.email()">
+        <button class="btn btn-outline-dark" 
+          (click)="sendTestMessage('au.email.test')" i18n>
+          Send Test Email
+        </button>
+        <ng-container *ngIf="!patron.isnew()">
+         <button class="btn btn-outline-dark ml-2" 
+           (click)="invalidateField('email')" i18n>Invalidate</button>
+        </ng-container>
+      </ng-container>
     </div>
   </div>
 
       *ngTemplateOutlet="fieldInput; context: {args: {field: 'day_phone'}}">
     </ng-container>
     <div class="col-lg-6">
-      <button class="btn btn-outline-dark" 
-        (click)="invalidateField('day_phone')" i18n>Invalidate</button>
+      <ng-container *ngIf="patron.day_phone() && !patron.isnew()">
+        <button class="btn btn-outline-dark" 
+          (click)="invalidateField('day_phone')" i18n>Invalidate</button>
+      </ng-container>
     </div>
   </div>
 
       </button>
     </div>
   </div>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldCombobox, field: 'net_access_level', entries: inetLevels}}">
+  </ng-container>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldCheckbox, field: 'active'}}">
+  </ng-container>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldCheckbox, field: 'barred'}}">
+  </ng-container>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldCheckbox, field: 'master_account'}}">
+  </ng-container>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldInput, field: 'claims_returned_count', type: 'number'}}">
+  </ng-container>
+  <ng-container *ngTemplateOutlet="fieldRow; context: 
+    {args: {template: fieldInput, field: 'claims_never_checked_out_count', type: 'number'}}">
+  </ng-container>
+
+  <div class="row pt-1 pb-1 mt-1" *ngIf="showField('au', 'alert_message')">
+    <ng-container 
+      *ngTemplateOutlet="fieldLabel; context: {args: {field: 'alert_message'}}">
+    </ng-container>
+    <div class="col-lg-3">
+      <textarea
+        class="form-control" 
+        name="au-alert_message-input"
+        id="au-alert_message-input"
+        [ngModel]="objectFromPath(null)['alert_message']()"
+        (ngModelChange)="fieldValueChange(null, 'alert_message', $event)"
+        (change)="afterFieldChange(null, 'alert_message')"
+        [required]="fieldRequired('au', 'alert_message')"
+        [pattern]="fieldPattern('au', 'alert_message')">
+      </textarea>
+    </div>
+  </div>
+
+  <div class="alert alert-success p-2 m-3" i18n>User Settings</div>
+
+  <ng-container *ngTemplateOutlet="userSettingsInputRow; context: 
+    {args: {settingName: 'opac.default_phone'}}">
+  </ng-container>
+
+  <div class="row pt-1 pb-1 mt-1" *ngIf="showField('au', 'home_ou')">
+    <ng-container *ngTemplateOutlet="userSettingLabel; 
+      context: {args: {settingName: 'opac.default_pickup_location'}}">
+    </ng-container>
+    <div class="col-lg-3">
+      <eg-org-select
+        domId="cust-opac.default_pickup_location-input"
+        fieldName="cust-opac.default_pickup_location-input"
+        [initialOrgId]="userSettings['opac.default_pickup_location']"
+        [disableOrgs]="cannotHaveVolsOrgs()"
+        (onChange)="userSettingChange(
+          'opac.default_pickup_location', $event ? $event.id() : null)">
+      </eg-org-select>
+    </div>
+  </div>
+
+  <div class="row pt-1 pb-1 mt-1">
+    <ng-container *ngTemplateOutlet="userSettingLabel; 
+      context: {args: {settingName: 'opac.hold_notify'}}">
+    </ng-container>
+    <div class="col-lg-3">
+      <div class="form-check form-check-inline mr-2">
+        <input class="form-check-input" type="radio" name="hold-notify-phone" 
+          id="hold-notify-phone" [(ngModel)]="holdNotifyTypes.phone"/>
+        <label class="form-check-label" for="hold-notify-phone" i18n>Phone</label>
+      </div>
+      <div class="form-check form-check-inline mr-2">
+        <input class="form-check-input" type="radio" name="hold-notify-email" 
+          id="hold-notify-email" [(ngModel)]="holdNotifyTypes.email"/>
+        <label class="form-check-label" for="hold-notify-email" i18n>Email</label>
+      </div>
+      <div class="form-check form-check-inline mr-2" *ngIf="orgSettings['sms.enable']">
+        <input class="form-check-input" type="radio" name="hold-notify-sms" 
+          id="hold-notify-sms" [(ngModel)]="holdNotifyTypes.sms"/>
+        <label class="form-check-label" for="hold-notify-sms" i18n>SMS</label>
+      </div>
+    </div>
+  </div>
+
+  <ng-container *ngIf="orgSettings['sms.enable']">
+
+    <div class="row pt-1 pb-1 mt-1">
+      <ng-container *ngTemplateOutlet="userSettingLabel; 
+        context: {args: {settingName: 'opac.default_sms_notify'}}">
+      </ng-container>
+      <ng-container *ngTemplateOutlet="userSettingsInput;
+        context: {args: {settingName: 'opac.default_sms_notify'}}">
+      </ng-container>
+      <div class="col-lg-6">
+        <ng-container *ngIf="userSettings['opac.default_sms_notify'] && 
+          userSettings['opac.default_sms_carrier']">
+          <button class="btn btn-outline-dark" 
+            (click)="sendTestMessage('au.sms_text.test')" i18n>
+            Send Test Text
+          </button>
+        </ng-container>
+      </div>
+    </div>
+
+    <div class="row pt-1 pb-1 mt-1">
+      <ng-container *ngTemplateOutlet="userSettingLabel; 
+        context: {args: {settingName: 'opac.default_sms_carrier'}}">
+      </ng-container>
+      <div class="col-lg-3">
+        <eg-combobox [entries]="smsCarriers"
+          name="cust-opac.default_sms_carrier-input"
+          domId="cust-opac.default_sms_carrier-input"
+          [selectedId]="userSettings['opac.default_sms_carrier']"
+          (onChange)="userSettingChange(
+            'opac.default_sms_carrier', $event ? $event.id : null)">
+        </eg-combobox>
+      </div>
+    </div>
+  </ng-container>
+
+
+  <div class="alert alert-success p-2 m-3" i18n>Addresses</div>
+
 </div>
 
index c758924..357dcb8 100644 (file)
@@ -17,6 +17,7 @@ import {StringService} from '@eg/share/string/string.service';
 import {EventService} from '@eg/core/event.service';
 import {PermService} from '@eg/core/perm.service';
 import {SecondaryGroupsDialogComponent} from './secondary-groups.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
 
 const COMMON_USER_SETTING_TYPES = [
   'circ.holds_behind_desk',
@@ -28,6 +29,12 @@ const COMMON_USER_SETTING_TYPES = [
   'opac.default_sms_notify'
 ];
 
+// Duplicate these settings in resolver.service so they can be
+// fetched/cached with the original batch (fewer net calls).
+const ORG_SETTING_TYPES = [
+    'sms.enable'
+]
+
 const PERMS_NEEDED = [
     'EDIT_SELF_IN_CLIENT',
     'UPDATE_USER',
@@ -68,7 +75,10 @@ export class EditComponent implements OnInit {
     nameTab = 'primary';
     loading = false;
 
+    smsCarriers: ComboboxEntry[];
     identTypes: ComboboxEntry[];
+    inetLevels: ComboboxEntry[];
+    orgSettings: {[name: string]: any} = {};
     userSettings: {[name: string]: any} = {};
     userSettingTypes: {[name: string]: IdlObject} = {};
     optInSettingTypes: {[name: string]: IdlObject} = {};
@@ -82,6 +92,8 @@ export class EditComponent implements OnInit {
     // patron we are editing.
     hasPerm: {[name: string]: boolean} = {};
 
+    holdNotifyTypes: {email?: boolean, phone?: boolean, sms?: boolean} = {};
+
     constructor(
         private org: OrgService,
         private net: NetService,
@@ -92,6 +104,7 @@ export class EditComponent implements OnInit {
         private toast: ToastService,
         private perms: PermService,
         private evt: EventService,
+        private serverStore: ServerStoreService,
         private patronService: PatronService,
         public context: PatronContextService
     ) {}
@@ -106,21 +119,43 @@ export class EditComponent implements OnInit {
         .then(_ => this.getSecondaryGroups())
         .then(_ => this.applyPerms())
         .then(_ => this.setIdentTypes())
+        .then(_ => this.setInetLevels())
         .then(_ => this.setOptInSettings())
+        .then(_ => this.setOrgSettings())
+        .then(_ => this.setSmsCarriers())
         .finally(() => this.loading = false);
     }
 
-    getSecondaryGroups(): Promise<any> {
+    setOrgSettings(): Promise<any> {
+        return this.serverStore.getItemBatch(ORG_SETTING_TYPES)
+        .then(settings => this.orgSettings = settings);
+    }
 
-          return this.net.request(
-              'open-ils.actor',
-              'open-ils.actor.user.get_groups',
-              this.auth.token(), this.patronId
+    setSmsCarriers(): Promise<any> {
+        if (!this.orgSettings['sms.enable']) {
+            return Promise.resolve();
+        }
 
-          ).pipe(concatMap(maps => this.pcrud.search('pgt',
-              {id: maps.map(m => m.grp())}, {}, {atomic: true})
+        return this.patronService.getSmsCarriers().then(carriers => {
+            this.smsCarriers = carriers.map(carrier => {
+                return {
+                    id: carrier.id(),
+                    label: carrier.name()
+                };
+            });
+        });
+    }
 
-          )).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
+    getSecondaryGroups(): Promise<any> {
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.get_groups',
+            this.auth.token(), this.patronId
+
+        ).pipe(concatMap(maps => this.pcrud.search('pgt',
+            {id: maps.map(m => m.grp())}, {}, {atomic: true})
+
+        )).pipe(tap(grps => this.secondaryGroups = grps)).toPromise();
     }
 
     setIdentTypes(): Promise<any> {
@@ -130,6 +165,13 @@ export class EditComponent implements OnInit {
         });
     }
 
+    setInetLevels(): Promise<any> {
+        return this.patronService.getInetLevels()
+        .then(levels => {
+            this.inetLevels = levels.map(t => ({id: t.id(), label: t.name()}));
+        });
+    }
+
     applyPerms(): Promise<any> {
 
         const promise = this.permOrgs ?
@@ -196,6 +238,23 @@ export class EditComponent implements OnInit {
             }
         });
 
+        const holdNotify = this.userSettings['opac.hold_notify'];
+        if (holdNotify) {
+            this.holdNotifyTypes.email = holdNotify.match(/email/) !== null;
+            this.holdNotifyTypes.phone = holdNotify.match(/phone/) !== null;
+            this.holdNotifyTypes.sms = holdNotify.match(/sms/) !== null;
+        }
+
+        if (this.userSettings['opac.default_sms_carrier']) {
+            this.userSettings['opac.default_sms_carrier'] =
+                Number(this.userSettings['opac.default_sms_carrier']);
+        }
+
+        if (this.userSettings['opac.default_pickup_location']) {
+            this.userSettings['opac.default_pickup_location'] =
+                Number(this.userSettings['opac.default_pickup_location']);
+        }
+
         this.expireDate = new Date(this.patron.expire_date());
     }
 
@@ -242,6 +301,13 @@ export class EditComponent implements OnInit {
     // so avoid any heavy lifting here.  See afterFieldChange();
     fieldValueChange(path: string, field: string, value: any) {
         if (typeof value === 'boolean') { value = value ? 't' : 'f'; }
+
+        // This can be called in cases where components fire up, even
+        // though the actual value on the patron has not changed.
+        // Exit early in that case so we don't mark the form as dirty.
+        const oldValue = this.getFieldValue(path, field);
+        if (oldValue === value) { return; }
+
         this.changeHandlerNeeded = true;
         this.objectFromPath(path)[field](value);
     }
@@ -253,11 +319,11 @@ export class EditComponent implements OnInit {
 
         // TODO: set dirty
 
-
         const obj = path ? this.patron[path]() : this.patron;
         const value = obj[field]();
 
-        console.debug(`Modifying field path=${path} field=${field} value=${value}`);
+        console.debug(
+            `Modifying field path=${path || ''} field=${field} value=${value}`);
 
         switch (field) {
             // TODO: do many more
@@ -300,6 +366,12 @@ export class EditComponent implements OnInit {
           .map(org => org.id());
     }
 
+    cannotHaveVolsOrgs(): number[] {
+        return this.org.list()
+          .filter(org => org.ou_type().can_have_vols() === 'f')
+          .map(org => org.id());
+    }
+
     setExpireDate() {
         const profile = this.profileSelect.profiles[this.patron.profile()];
         if (!profile) { return; }
index 8c6b217..7f96c44 100644 (file)
@@ -43,6 +43,7 @@ export class PatronResolver implements Resolve<Promise<any[]>> {
           'ui.circ.billing.amount_limit',
           'circ.staff_client.do_not_auto_attempt_print',
           'circ.disable_patron_credit',
+          'sms.enable',
           'credit.processor.default'
         ]).then(settings => {
             this.context.noTallyClaimsReturned =
index 6d8f57a..459975a 100644 (file)
@@ -1,4 +1,5 @@
 import {Injectable} from '@angular/core';
+import {tap} from 'rxjs/operators';
 import {IdlObject} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
@@ -13,7 +14,9 @@ import {BarcodeSelectComponent} from '@eg/staff/share/barcodes/barcode-select.co
 export class PatronService {
 
     identTypes: IdlObject[];
+    inetLevels: IdlObject[];
     profileGroups: IdlObject[];
+    smsCarriers: IdlObject[];
 
     constructor(
         private net: NetService,
@@ -101,6 +104,16 @@ export class PatronService {
         .toPromise().then(types => this.identTypes = types);
     }
 
+    getInetLevels(): Promise<IdlObject[]> {
+        if (this.inetLevels) {
+            return Promise.resolve(this.inetLevels);
+        }
+
+        return this.pcrud.retrieveAll('cnal',
+            {order_by: {cit: ['name']}}, {atomic: true})
+        .toPromise().then(levels => this.inetLevels = levels);
+    }
+
     getProfileGroups(): Promise<IdlObject[]> {
         if (this.profileGroups) {
             return Promise.resolve(this.profileGroups);
@@ -110,5 +123,17 @@ export class PatronService {
             {order_by: {cit: ['name']}}, {atomic: true})
         .toPromise().then(types => this.profileGroups = types);
     }
+
+    getSmsCarriers(): Promise<IdlObject[]> {
+        if (this.smsCarriers) {
+            return Promise.resolve(this.smsCarriers);
+        }
+
+        this.smsCarriers = [];
+        return this.pcrud.search(
+            'csc', {active: 't'}, {order_by: {csc: 'name'}})
+            .pipe(tap(carrier => this.smsCarriers.push(carrier))
+        ).toPromise().then(_ => this.smsCarriers);
+    }
 }