LP1904036 adding patron to bucket; merging
authorBill Erickson <berickxx@gmail.com>
Mon, 5 Apr 2021 21:55:37 +0000 (17:55 -0400)
committerGalen Charlton <gmc@equinoxOLI.org>
Fri, 28 Oct 2022 00:13:29 +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>
19 files changed:
Open-ILS/src/eg2/src/app/staff/circ/patron/group.component.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/patron.component.html
Open-ILS/src/eg2/src/app/staff/circ/patron/patron.module.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/patron.service.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/resolver.service.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.css [deleted file]
Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.html [deleted file]
Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.ts [deleted file]
Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts
Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts
Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.ts [new file with mode: 0644]

index 1221995..a4459c7 100644 (file)
@@ -78,7 +78,7 @@ export class PatronGroupComponent implements OnInit {
             {authoritative: true})
         .pipe(concatMap(u => {
 
-            const promise = this.context.getPatronVitalStats(u.id())
+            const promise = this.patronService.getVitalStats(u.id())
             .then(stats => {
                 this.totalOwed += stats.fines.balance_owed;
                 this.totalOut += stats.checkouts.total_out;
index 77507e7..bab79c3 100644 (file)
@@ -20,7 +20,9 @@
     <div class="col-lg-3">
       <div class="sticky-top-with-nav bg-white">
         <ng-container *ngIf="context.patron">
-          <eg-patron-summary></eg-patron-summary>
+          <eg-patron-summary [patron]="context.patron"
+            [stats]="context.patronStats" [alerts]="context.alerts">
+          </eg-patron-summary>
         </ng-container>
       </div>
     </div>
index 77b421b..479f69b 100644 (file)
@@ -12,7 +12,6 @@ import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {PatronContextService} from './patron.service';
 import {PatronComponent} from './patron.component';
 import {PatronAlertsComponent} from './alerts.component';
-import {SummaryComponent} from './summary.component';
 import {CheckoutComponent} from './checkout.component';
 import {HoldsComponent} from './holds.component';
 import {EditComponent} from './edit.component';
@@ -36,7 +35,6 @@ import {PatronMessagesComponent} from './messages.component';
   declarations: [
     PatronComponent,
     PatronAlertsComponent,
-    SummaryComponent,
     CheckoutComponent,
     HoldsComponent,
     EditComponent,
index bb703bc..e25a05b 100644 (file)
@@ -3,7 +3,8 @@ import {IdlObject} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
-import {PatronService} from '@eg/staff/share/patron/patron.service';
+import {PatronService, PatronStats, PatronAlerts
+    } from '@eg/staff/share/patron/patron.service';
 import {PatronSearch} from '@eg/staff/share/patron/search.component';
 import {StoreService} from '@eg/core/store.service';
 import {CircService, CircDisplayInfo} from '@eg/staff/share/circ/circ.service';
@@ -43,52 +44,6 @@ const PATRON_FLESH_FIELDS = [
     'groups'
 ];
 
-interface PatronStats {
-    fines: {
-        balance_owed: number,
-        group_balance_owed: number
-    };
-    checkouts: {
-        overdue: number,
-        claims_returned: number,
-        lost: number,
-        out: number,
-        total_out: number,
-        long_overdue: number,
-        noncat: number
-    };
-    holds: {
-        ready: number;
-        total: number;
-    };
-}
-
-export class PatronAlerts {
-    holdsReady = 0;
-    accountExpired = false;
-    accountExpiresSoon = false;
-    patronBarred = false;
-    patronInactive = false;
-    retrievedWithInactive = false;
-    invalidAddress = false;
-    alertMessage: string = null;
-    alertPenalties: IdlObject[] = [];
-
-    hasAlerts(): boolean {
-        return (
-            this.holdsReady > 0 ||
-            this.accountExpired ||
-            this.accountExpiresSoon ||
-            this.patronBarred ||
-            this.patronInactive ||
-            this.retrievedWithInactive ||
-            this.invalidAddress ||
-            this.alertMessage !== null ||
-            this.alertPenalties.length > 0
-        );
-    }
-}
-
 @Injectable()
 export class PatronContextService {
 
@@ -96,9 +51,6 @@ export class PatronContextService {
     patronStats: PatronStats;
     alerts: PatronAlerts;
 
-    noTallyClaimsReturned = false; // circ.do_not_tally_claims_returned
-    tallyLost = false; // circ.tally_lost
-
     loaded = false;
 
     lastPatronSearch: PatronSearch;
@@ -111,16 +63,13 @@ export class PatronContextService {
 
     constructor(
         private store: StoreService,
-        private net: NetService,
         private org: OrgService,
-        private auth: AuthService,
         private circ: CircService,
-        public patronService: PatronService
+        public patrons: PatronService
     ) {}
 
     loadPatron(id: number): Promise<any> {
         this.loaded = false;
-        this.patron = null;
         this.checkouts = [];
         return this.refreshPatron(id).then(_ => this.loaded = true);
     }
@@ -131,45 +80,12 @@ export class PatronContextService {
 
         this.alerts = new PatronAlerts();
 
-        return this.net.request(
-            'open-ils.actor',
-            'open-ils.actor.user.fleshed.retrieve',
-            this.auth.token(), id, PATRON_FLESH_FIELDS).toPromise()
+        return this.patrons.getFleshedById(id, PATRON_FLESH_FIELDS)
         .then(p => this.patron = p)
         .then(_ => this.getPatronStats(id))
         .then(_ => this.compileAlerts());
     }
 
-    getPatronVitalStats(id: number): Promise<PatronStats> {
-
-        return this.net.request(
-            'open-ils.actor',
-            'open-ils.actor.user.opac.vital_stats.authoritative',
-            this.auth.token(), id).toPromise()
-
-        .then((stats: PatronStats) => {
-
-            // force numeric values
-            stats.fines.balance_owed = Number(stats.fines.balance_owed);
-
-            Object.keys(stats.checkouts).forEach(key =>
-                stats.checkouts[key] = Number(stats.checkouts[key]));
-
-            stats.checkouts.total_out = stats.checkouts.out +
-                stats.checkouts.overdue + stats.checkouts.long_overdue;
-
-            if (!this.noTallyClaimsReturned) {
-                stats.checkouts.total_out += stats.checkouts.claims_returned;
-            }
-
-            if (this.tallyLost) {
-                stats.checkouts.total_out += stats.checkouts.lost;
-            }
-
-            return stats;
-        });
-    }
-
     getPatronStats(id: number): Promise<any> {
 
         // When quickly navigating patron search results it's possible
@@ -177,39 +93,8 @@ export class PatronContextService {
         // is called.  Exit early instead of making an unneeded call.
         if (!this.patron) { return Promise.resolve(); }
 
-        return this.getPatronVitalStats(id)
-
-        .then(stats => this.patronStats = stats)
-
-        .then(_ => {
-            if (!this.patron) { return; }
-
-            return this.net.request(
-                'open-ils.circ',
-                'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
-                this.auth.token(), id
-            ).toPromise();
-
-        }).then(noncats => {
-            if (!this.patron) { return; }
-
-            if (noncats && this.patronStats) {
-                this.patronStats.checkouts.noncat = noncats.length;
-            }
-
-            return this.net.request(
-                'open-ils.actor',
-                'open-ils.actor.usergroup.members.balance_owed.authoritative',
-                this.auth.token(), this.patron.usrgroup()
-            ).toPromise();
-
-        }).then(fines => {
-            if (!this.patron) { return; }
-
-            let total = 0;
-            fines.forEach(f => total += Number(f.balance_owed) * 100);
-            this.patronStats.fines.group_balance_owed = total / 100;
-        });
+        return this.patrons.getVitalStats(this.patron)
+        .then(stats => this.patronStats = stats);
     }
 
     patronAlertsShown(): boolean {
@@ -225,28 +110,15 @@ export class PatronContextService {
         // User navigated to a different patron mid-data load.
         if (!this.patron) { return Promise.resolve(); }
 
-        this.alerts.holdsReady = this.patronStats.holds.ready;
-        this.alerts.patronBarred = this.patron.barred() === 't';
-        this.alerts.patronInactive = this.patron.active() === 'f';
-        this.alerts.invalidAddress = this.patron.addresses()
-            .filter(a => a.valid() === 'f').length > 0;
-        this.alerts.alertMessage = this.patron.alert_message();
-        this.alerts.alertPenalties = this.patron.standing_penalties()
-            .filter(p => p.standing_penalty().staff_alert() === 't');
-
-        if (this.searchBarcode) {
-            const card = this.patron.cards()
-                .filter(c => c.barcode() === this.searchBarcode)[0];
-            this.alerts.retrievedWithInactive = card && card.active() === 'f';
-            this.searchBarcode = null;
-        }
+        return this.patrons.compileAlerts(this.patron, this.patronStats)
+        .then(alerts => {
+            this.alerts = alerts;
 
-        return this.patronService.testExpire(this.patron)
-        .then(value => {
-            if (value === 'expired') {
-                this.alerts.accountExpired = true;
-            } else if (value === 'soon') {
-                this.alerts.accountExpiresSoon = true;
+            if (this.searchBarcode) {
+                const card = this.patron.cards()
+                    .filter(c => c.barcode() === this.searchBarcode)[0];
+                this.alerts.retrievedWithInactive = card && card.active() === 'f';
+                this.searchBarcode = null;
             }
         });
     }
index a781d55..db2787c 100644 (file)
@@ -130,9 +130,6 @@ export class PatronResolver implements Resolve<Promise<any[]>> {
           'ui.admin.patron_log.max_entries'
         ]).then(settings => {
             this.context.settingsCache = settings;
-            this.context.noTallyClaimsReturned =
-                settings['circ.do_not_tally_claims_returned'];
-            this.context.tallyLost = settings['circ.tally_lost'];
         });
     }
 }
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.css b/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.css
deleted file mode 100644 (file)
index 2c7d847..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-
-.patron-summary-container .row:nth-child(odd):not(.alert) {
-  background-color: rgb(248, 248, 248);
-}
-
-
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.html b/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.html
deleted file mode 100644 (file)
index 0a523db..0000000
+++ /dev/null
@@ -1,205 +0,0 @@
-
-<div class="patron-summary-container">
-
-  <div class="row d-flex" *ngIf="patron()">
-    <div class="flex-1 pt-1">
-      <h4 class="font-weight-bold" i18n>
-        {{patron().family_name()}}, 
-        {{patron().first_given_name()}} 
-        {{patron().second_given_name()}}
-      </h4>
-    </div>
-    <ng-container *ngIf="hasPrefName()">
-      <div class="mr-2 ml-2 text-info font-italic" i18n>account</div>
-    </ng-container>
-  </div>
-
-  <div class="row d-flex border-top" *ngIf="hasPrefName()">
-    <div class="flex-1 pt-1">
-      <h4 class="font-weight-bold" i18n>
-        {{patronService.namePart(patron(), 'family_name')}}, 
-        {{patronService.namePart(patron(), 'first_given_name')}} 
-        {{patronService.namePart(patron(), 'second_given_name')}}
-      </h4>
-    </div>
-    <div class="mr-2 ml-2 text-info font-italic" i18n>preferred</div>
-  </div>
-
-  <div class="row mb-1 alert alert-danger p-0" 
-    *ngIf="context.alerts.accountExpiresSoon">
-    <div class="col-lg-12" i18n>
-      Patron account will expire soon.  Please renew.
-    </div>
-  </div>
-
-  <div class="row mb-1 alert alert-danger p-0" 
-    *ngFor="let pen of context.alerts.alertPenalties">
-    <div class="col-lg-9"
-      title="{{pen.standing_penalty().name()}}">
-      {{pen.note() || pen.standing_penalty().label()}}
-    </div>
-    <div class="col-lg-3">{{pen.set_date() | date:'shortDate'}}</div>
-  </div>
-
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Profile</div>
-    <div class="col-lg-7">{{patron().profile().name()}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Home Library</div>
-    <div class="col-lg-7">{{context.orgSn(patron().home_ou())}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Net Access</div>
-    <div class="col-lg-7">{{patron().net_access_level().name()}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Date of Birth</div>
-    <div class="col-lg-7">{{patron().dob() | date:'shortDate'}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Parent/Guardian</div>
-    <div class="col-lg-7">{{patron().guardian()}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Last Activity</div>
-    <div class="col-lg-7">
-      <ng-container *ngIf="patron().usr_activity()[0]">
-        {{patron().usr_activity()[0].event_time() | date:'shortDate'}}
-      </ng-container>
-    </div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Last Updated</div>
-    <div class="col-lg-7">{{patron().last_update_time() | date:'shortDate'}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Create Date</div>
-    <div class="col-lg-7">{{patron().create_date() | date:'shortDate'}}</div>
-  </div>
-  <div class="row" [ngClass]="{'alert alert-danger p-0': context.alerts.accountExpired}">
-    <div class="col-lg-5" i18n>Expire Date</div>
-    <div class="col-lg-7">{{patron().expire_date() | date:'shortDate'}}</div>
-  </div>
-
-  <hr class="m-1"/>
-
-  <ng-container *ngIf="stats()">
-
-    <div class="row mb-1"
-      [ngClass]="{'alert alert-danger p-0': stats().fines.balance_owed > 0}">
-      <div class="col-lg-5" i18n>Fines Owed</div>
-      <div class="col-lg-7">{{stats().fines.balance_owed | currency}}</div>
-    </div>
-
-    <ng-container 
-      *ngIf="stats().fines.group_balance_owed > stats().fines.balance_owed">
-      <div class="row mb-1 alert alert-danger p-0">
-        <div class="col-lg-5" i18n>Group Fines</div>
-        <div class="col-lg-7">{{stats().fines.group_balance_owed | currency}}</div>
-      </div>
-    </ng-container>
-
-    <div class="row mb-1">
-      <div class="col-lg-5" i18n>Items Out</div>
-      <div class="col-lg-7">{{stats().checkouts.total_out}}</div>
-    </div>
-    <div class="row mb-1"
-      [ngClass]="{'alert alert-danger p-0': stats().checkouts.overdue > 0}">
-      <div class="col-lg-5" i18n>Overdue</div>
-      <div class="col-lg-7">{{stats().checkouts.overdue}}</div>
-    </div>
-    <div class="row mb-1"
-      [ngClass]="{'alert alert-danger p-0': stats().checkouts.long_overdue > 0}">
-      <div class="col-lg-5" i18n>Long Overdue</div>
-      <div class="col-lg-7">{{stats().checkouts.long_overdue}}</div>
-    </div>
-    <div class="row mb-1"
-      [ngClass]="{'alert alert-danger p-0': stats().checkouts.claims_returned > 0}">
-      <div class="col-lg-5" i18n>Claimed Returned</div>
-      <div class="col-lg-7">{{stats().checkouts.claims_returned}}</div>
-    </div>
-    <div class="row mb-1"
-      [ngClass]="{'alert alert-danger p-0': stats().checkouts.lost > 0}">
-      <div class="col-lg-5" i18n>Lost</div>
-      <div class="col-lg-7">{{stats().checkouts.lost}}</div>
-    </div>
-    <div class="row mb-1">
-      <div class="col-lg-5" i18n>Non-Cataloged</div>
-      <div class="col-lg-7">{{stats().checkouts.noncat}}</div>
-    </div>
-    <div class="row">
-      <div class="col-lg-5" i18n>Holds</div>
-      <div class="col-lg-7">
-        {{stats().holds.ready}} / {{stats().holds.total}}
-      </div>
-    </div>
-
-    <hr class="m-1"/>
-  </ng-container>
-
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Card</div>
-    <div class="col-lg-7">
-      {{patron().card() ? patron().card().barcode() : ''}}
-    </div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Username</div>
-    <div class="col-lg-7">{{patron().usrname()}}</div>
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Day Phone</div>
-    <div class="col-lg-7">{{patron().day_phone()}}</div> 
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Evening Phone</div>
-    <div class="col-lg-7">{{patron().evening_phone()}}</div> 
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Other Phone</div>
-    <div class="col-lg-7">{{patron().other_phone()}}</div> 
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>ID1 </div>
-    <div class="col-lg-7">{{patron().ident_value()}}</div> 
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>ID2</div>
-    <div class="col-lg-7">{{patron().ident_value2()}}</div> 
-  </div>
-  <div class="row mb-1">
-    <div class="col-lg-5" i18n>Email</div>
-    <div class="col-lg-7">
-      <!-- TODO: mailto link -->
-      {{patron().email()}}
-    </div> 
-  </div>
-
-  <hr class="m-1"/>
-
-  <div class="row mb-1" *ngFor="let addr of patron().addresses()">
-    <div class="col-lg-12">
-      <fieldset>
-        <legend class="d-flex" [ngClass]="{'alert alert-danger p-0': addr.valid() == 'f'}">
-          <div class="flex-1">{{addr.address_type()}}</div>
-          <div>
-            <a class="mr-2" href="javascript:;" 
-              (click)="copyAddress(addr)" i18n>copy</a>
-            <a class="mr-2" href="javascript:;" 
-              (click)="printAddress(addr)" i18n>print</a>
-          </div>
-        </legend>
-        <div i18n>{{addr.street1()}} {{addr.street2()}}</div>
-        <div i18n>{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</div>
-      </fieldset>
-
-      <!-- hidden textare used only for copying the text -->
-      <textarea id="patron-address-copy-{{addr.id()}}" rows="2"              
-        style="visibility:hidden">
-{{patron().first_given_name()}} {{patron().second_given_name()}} {{patron().family_name()}}
-{{addr.street1()}} {{addr.street2()}}
-{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</textarea>
-    </div>
-  </div>
-</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/summary.component.ts
deleted file mode 100644 (file)
index 119e1cf..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-import {Component, OnInit, Input} from '@angular/core';
-import {Router, ActivatedRoute, ParamMap} from '@angular/router';
-import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
-import {OrgService} from '@eg/core/org.service';
-import {IdlObject} from '@eg/core/idl.service';
-import {NetService} from '@eg/core/net.service';
-import {PatronService} from '@eg/staff/share/patron/patron.service';
-import {PatronContextService} from './patron.service';
-import {PrintService} from '@eg/share/print/print.service';
-
-@Component({
-  templateUrl: 'summary.component.html',
-  styleUrls: ['summary.component.css'],
-  selector: 'eg-patron-summary'
-})
-export class SummaryComponent implements OnInit {
-
-    constructor(
-        private org: OrgService,
-        private net: NetService,
-        private printer: PrintService,
-        public patronService: PatronService,
-        public context: PatronContextService
-    ) {}
-
-    ngOnInit() {
-    }
-
-    patron(): IdlObject {
-        return this.context.patron;
-    }
-
-    stats(): any {
-      return this.context.patronStats;
-    }
-
-    hasPrefName(): boolean {
-        if (this.patron()) {
-            return (
-                this.patron().pref_first_given_name() ||
-                this.patron().pref_second_given_name() ||
-                this.patron().pref_family_name()
-            );
-        }
-    }
-
-    printAddress(addr: IdlObject) {
-        this.printer.print({
-            templateName: 'patron_address',
-            contextData: {
-                patron: this.context.patron,
-                address: addr
-            },
-            printContext: 'default'
-        });
-    }
-
-    copyAddress(addr: IdlObject) {
-        // Note navigator.clipboard requires special permissions.
-        // This is hinky, but gets the job done without the perms.
-
-        const node = document.getElementById(
-            `patron-address-copy-${addr.id()}`) as HTMLTextAreaElement;
-
-        // Un-hide the textarea just long enough to copy its data.
-        // Using node.style instead of *ngIf in hopes it
-        // will be quicker, so the user never sees the textarea.
-        node.style.visibility = 'visible';
-        node.focus();
-        node.select();
-
-        if (!document.execCommand('copy')) {
-            console.error('Copy command failed');
-        }
-
-        node.style.visibility = 'hidden';
-    }
-
-}
-
index fcd885f..f92f9d6 100644 (file)
@@ -1,3 +1,6 @@
+<eg-string #successString i18n-text text="Successfully added to bucket">
+</eg-string>
+
 <ng-template #dialogContent>
   <div class="modal-header bg-info">
     <h4 class="modal-title">
index b743edd..478c3a9 100644 (file)
@@ -10,6 +10,7 @@ import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {StringComponent} from '@eg/share/string/string.component'
 
 /**
  * Dialog for adding bib records to new and existing record buckets.
@@ -42,7 +43,8 @@ export class BucketDialogComponent extends DialogComponent implements OnInit {
     bucketFmClass: 'ccb' | 'ccnb' | 'cbreb' | 'cub';
     targetField: string;
 
-    @ViewChild('confirmAddToShared', {static: true}) confirmAddToShared: ConfirmDialogComponent;
+    @ViewChild('confirmAddToShared') confirmAddToShared: ConfirmDialogComponent;
+    @ViewChild('successString') successString: StringComponent;
 
     constructor(
         private modal: NgbModal, // required for passing to parent
@@ -196,6 +198,7 @@ export class BucketDialogComponent extends DialogComponent implements OnInit {
             if (evt) {
                 this.toast.danger(evt.toString());
             } else {
+                this.toast.success(this.successString.text);
                 this.close();
             }
         });
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.html
new file mode 100644 (file)
index 0000000..bc5551d
--- /dev/null
@@ -0,0 +1,51 @@
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title"><span i18n>Merge Patrons</span></h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row" *ngIf="loading">
+      <div class="col-lg-6 offset-lg-3">
+        <eg-progress-inline></eg-progress-inline>
+      </div>
+    </div>
+    <div class="row" *ngIf="!loading">
+      <div class="col-lg-6">
+        <div>
+                                       <div class="form-check form-check-inline">
+                                               <input class="form-check-input" type="radio" name="lead" id="lead-record-1"
+                                                       [value]="context1.patron.id()" [(ngModel)]="leadAccount">
+                                               <label class="form-check-label" for="lead-record-1">Use as Lead?</label>
+                                       </div>
+        </div>
+        <eg-patron-summary [patron]="context1.patron"
+          [stats]="context1.stats" [alerts]="context1.alerts">
+        </eg-patron-summary>  
+      </div>
+      <div class="col-lg-6">
+        <div>
+                                       <div class="form-check form-check-inline">
+                                               <input class="form-check-input" type="radio" name="lead" id="lead-record-2" 
+                                                       [value]="context2.patron.id()" [(ngModel)]="leadAccount">
+                                               <label class="form-check-label" for="lead-record-2">Use as Lead?</label>
+                                       </div>
+        </div>
+        <eg-patron-summary [patron]="context2.patron"
+          [stats]="context2.stats" [alerts]="context2.alerts">
+        </eg-patron-summary>  
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <ng-container>
+      <button type="button" class="btn btn-success" 
+        [disabled]="leadAccount" (click)="merge()" i18n>Merge</button>
+      <button type="button" class="btn btn-warning" 
+        (click)="close()" i18n>Cancel</button>
+    </ng-container>
+  </div>
+</ng-template>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/merge-dialog.component.ts
new file mode 100644 (file)
index 0000000..c3d9eb4
--- /dev/null
@@ -0,0 +1,84 @@
+import {Component, OnInit, Input, Output, ViewChild} from '@angular/core';
+import {from} from 'rxjs';
+import {switchMap, concatMap} from 'rxjs/operators';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {PatronService, PatronStats, PatronAlerts} from './patron.service';
+
+/**
+ */
+
+const PATRON_FLESH_FIELDS = [
+    'card',
+    'cards',
+    'settings',
+    'standing_penalties',
+    'addresses',
+    'billing_address',
+    'mailing_address',
+    'waiver_entries',
+    'usr_activity',
+    'notes',
+    'profile',
+    'net_access_level',
+    'ident_type',
+    'ident_type2',
+    'groups'
+];
+
+class MergeContext {
+    patron: IdlObject;
+    stats: PatronStats;
+    alerts: PatronAlerts;
+}
+
+@Component({
+  selector: 'eg-patron-merge-dialog',
+  templateUrl: 'merge-dialog.component.html'
+})
+
+export class PatronMergeDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() patronIds: [number, number];
+
+       context1: MergeContext;
+       context2: MergeContext;
+
+    leadAccount: number = null;
+    loading = true;
+
+    constructor(
+        private modal: NgbModal,
+        private patrons: PatronService
+    ) { super(modal); }
+
+    ngOnInit() {
+        this.onOpen$.subscribe(_ => {
+            this.loading = true;
+            this.loadPatron(this.patronIds[0])
+            .then(ctx => this.context1 = ctx)
+            .then(_ => this.loadPatron(this.patronIds[1]))
+            .then(ctx => this.context2 = ctx)
+            .then(_ => this.loading = false);
+        });
+    }
+
+    loadPatron(id: number): Promise<MergeContext> {
+        const ctx = new MergeContext();
+        return this.patrons.getFleshedById(id, PATRON_FLESH_FIELDS)
+        .then(patron => ctx.patron = patron)
+        .then(_ => this.patrons.getVitalStats(ctx.patron))
+        .then(stats => ctx.stats = stats)
+        .then(_ => this.patrons.compileAlerts(ctx.patron, ctx.stats))
+        .then(alerts => ctx.alerts = alerts)
+        .then(_ => ctx);
+    }
+
+    merge() {
+    }
+}
+
+
+
index 48ab175..41e4115 100644 (file)
@@ -7,12 +7,16 @@ import {PatronSearchDialogComponent} from './search-dialog.component';
 import {ProfileSelectComponent} from './profile-select.component';
 import {PatronPenaltyDialogComponent} from './penalty-dialog.component';
 import {BarcodesModule} from '@eg/staff/share/barcodes/barcodes.module';
+import {PatronMergeDialogComponent} from './merge-dialog.component';
+import {PatronSummaryComponent} from './summary.component';
 
 @NgModule({
     declarations: [
         PatronSearchComponent,
         PatronSearchDialogComponent,
         ProfileSelectComponent,
+        PatronSummaryComponent,
+        PatronMergeDialogComponent,
         PatronPenaltyDialogComponent
     ],
     imports: [
@@ -24,6 +28,8 @@ import {BarcodesModule} from '@eg/staff/share/barcodes/barcodes.module';
         PatronSearchComponent,
         PatronSearchDialogComponent,
         ProfileSelectComponent,
+        PatronSummaryComponent,
+        PatronMergeDialogComponent,
         PatronPenaltyDialogComponent
     ],
     providers: [
index 32a5c79..3333ee0 100644 (file)
@@ -8,6 +8,54 @@ import {PcrudService} from '@eg/core/pcrud.service';
 import {AuthService} from '@eg/core/auth.service';
 import {Observable} from 'rxjs';
 import {BarcodeSelectComponent} from '@eg/staff/share/barcodes/barcode-select.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+
+export interface PatronStats {
+    fines: {
+        balance_owed: number,
+        group_balance_owed: number
+    };
+    checkouts: {
+        overdue: number,
+        claims_returned: number,
+        lost: number,
+        out: number,
+        total_out: number,
+        long_overdue: number,
+        noncat: number
+    };
+    holds: {
+        ready: number;
+        total: number;
+    };
+}
+
+export class PatronAlerts {
+    holdsReady = 0;
+    accountExpired = false;
+    accountExpiresSoon = false;
+    patronBarred = false;
+    patronInactive = false;
+    retrievedWithInactive = false;
+    invalidAddress = false;
+    alertMessage: string = null;
+    alertPenalties: IdlObject[] = [];
+
+    hasAlerts(): boolean {
+        return (
+            this.holdsReady > 0 ||
+            this.accountExpired ||
+            this.accountExpiresSoon ||
+            this.patronBarred ||
+            this.patronInactive ||
+            this.retrievedWithInactive ||
+            this.invalidAddress ||
+            this.alertMessage !== null ||
+            this.alertPenalties.length > 0
+        );
+    }
+}
+
 
 
 @Injectable()
@@ -25,7 +73,8 @@ export class PatronService {
         private org: OrgService,
         private evt: EventService,
         private pcrud: PcrudService,
-        private auth: AuthService
+        private auth: AuthService,
+        private store: ServerStoreService
     ) {}
 
     bcSearch(barcode: string): Observable<any> {
@@ -193,5 +242,101 @@ export class PatronService {
                 surveys.sort((s1, s2) => s1.name() < s2.name() ? -1 : 1);
         });
     }
+
+    getVitalStats(patron: IdlObject): Promise<PatronStats> {
+
+        let patronStats: PatronStats;
+        let noTallyClaimsReturned, tallyLost;
+
+        return this.store.getItemBatch([
+          'circ.do_not_tally_claims_returned',
+          'circ.tally_lost'
+
+        ]).then(settings => {
+
+            noTallyClaimsReturned = settings['circ.do_not_tally_claims_returned'];
+            tallyLost = settings['circ.tally_lost'];
+
+        }).then(_ => {
+
+            return this.net.request(
+                'open-ils.actor',
+                'open-ils.actor.user.opac.vital_stats.authoritative',
+                this.auth.token(), patron.id()).toPromise()
+
+        }).then((stats: PatronStats) => {
+
+            // force numeric values
+            stats.fines.balance_owed = Number(stats.fines.balance_owed);
+
+            Object.keys(stats.checkouts).forEach(key =>
+                stats.checkouts[key] = Number(stats.checkouts[key]));
+
+            stats.checkouts.total_out = stats.checkouts.out +
+                stats.checkouts.overdue + stats.checkouts.long_overdue;
+
+            if (!noTallyClaimsReturned) {
+                stats.checkouts.total_out += stats.checkouts.claims_returned;
+            }
+
+            if (tallyLost) {
+                stats.checkouts.total_out += stats.checkouts.lost;
+            }
+
+            return patronStats = stats;
+        })
+
+        .then(_ => {
+            return this.net.request(
+                'open-ils.circ',
+                'open-ils.circ.open_non_cataloged_circulation.user.authoritative',
+                this.auth.token(), patron.id()
+            ).toPromise();
+
+        }).then(noncats => {
+            if (noncats && patronStats) {
+                patronStats.checkouts.noncat = noncats.length;
+            }
+
+            return this.net.request(
+                'open-ils.actor',
+                'open-ils.actor.usergroup.members.balance_owed.authoritative',
+                this.auth.token(), patron.usrgroup()
+            ).toPromise();
+
+        }).then(fines => {
+
+            let total = 0;
+            fines.forEach(f => total += Number(f.balance_owed) * 100);
+            patronStats.fines.group_balance_owed = total / 100;
+
+            return patronStats;
+        });
+    }
+
+    compileAlerts(patron: IdlObject, stats: PatronStats): Promise<PatronAlerts> {
+
+        const alerts = new PatronAlerts();
+
+        alerts.holdsReady = stats.holds.ready;
+        alerts.patronBarred = patron.barred() === 't';
+        alerts.patronInactive = patron.active() === 'f';
+        alerts.invalidAddress = patron.addresses()
+            .filter(a => a.valid() === 'f').length > 0;
+        alerts.alertMessage = patron.alert_message();
+        alerts.alertPenalties = patron.standing_penalties()
+            .filter(p => p.standing_penalty().staff_alert() === 't');
+
+        return this.testExpire(patron)
+        .then(value => {
+            if (value === 'expired') {
+                alerts.accountExpired = true;
+            } else if (value === 'soon') {
+                alerts.accountExpiresSoon = true;
+            }
+
+            return alerts;
+        })
+    }
 }
 
index fe8bcd8..647f1f6 100644 (file)
@@ -1,4 +1,9 @@
 
+<eg-bucket-dialog #addToBucket bucketClass="user" bucketType="staff_client">
+</eg-bucket-dialog>
+<eg-patron-merge-dialog #mergeDialog>
+</eg-patron-merge-dialog>
+
 <div class="patron-search-form">
   <div class="row m-0 mb-2">
     <div class="col-lg-2 pl-1 pr-1">
     [dataSource]="dataSource" 
     [showDeclaredFieldsOnly]="true"> 
 
+    <eg-grid-toolbar-button label="Add to Bucket" i18n-label 
+      [disabled]="getSelected().length == 0"
+      (onClick)="addSelectedToBucket($event)"></eg-grid-toolbar-button>
+    <eg-grid-toolbar-button label="Merge Patrons" i18n-label 
+      [disabled]="getSelected().length !== 2"
+      (onClick)="mergePatrons($event)"></eg-grid-toolbar-button>
+
     <eg-grid-column path='id' 
       i18n-label label="ID"></eg-grid-column>      
     <eg-grid-column path='card.barcode' 
index 88025d7..b75f868 100644 (file)
@@ -11,6 +11,8 @@ import {ServerStoreService} from '@eg/core/server-store.service';
 import {GridComponent} from '@eg/share/grid/grid.component';
 import {GridDataSource} from '@eg/share/grid/grid';
 import {Pager} from '@eg/share/util/pager';
+import {BucketDialogComponent} from '@eg/staff/share/buckets/bucket-dialog.component';
+import {PatronMergeDialogComponent} from './merge-dialog.component';
 
 const DEFAULT_SORT = [
    'family_name ASC',
@@ -49,7 +51,9 @@ export interface PatronSearch {
 
 export class PatronSearchComponent implements OnInit, AfterViewInit {
 
-    @ViewChild('searchGrid', {static: false}) searchGrid: GridComponent;
+    @ViewChild('searchGrid') searchGrid: GridComponent;
+    @ViewChild('addToBucket') addToBucket: BucketDialogComponent;
+    @ViewChild('mergeDialog') mergeDialog: PatronMergeDialogComponent;
 
     startWithFired = false;
     @Input() startWithSearch: PatronSearch;
@@ -308,5 +312,17 @@ export class PatronSearchComponent implements OnInit, AfterViewInit {
 
         return chunk;
     }
+
+    addSelectedToBucket(rows: IdlObject[]) {
+        this.addToBucket.itemIds = rows.map(r => r.id());
+        this.addToBucket.open().subscribe();
+    }
+
+    mergePatrons(rows: IdlObject[]) {
+        this.mergeDialog.patronIds = [rows[0].id(), rows[1].id()];
+        this.mergeDialog.open({size: 'lg'}).subscribe(changes => {
+            if (changes) { this.searchGrid.reload(); }
+        });
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.css b/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.css
new file mode 100644 (file)
index 0000000..2c7d847
--- /dev/null
@@ -0,0 +1,6 @@
+
+.patron-summary-container .row:nth-child(odd):not(.alert) {
+  background-color: rgb(248, 248, 248);
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.html b/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.html
new file mode 100644 (file)
index 0000000..c41f46a
--- /dev/null
@@ -0,0 +1,205 @@
+
+<div class="patron-summary-container">
+
+  <div class="row d-flex">
+    <div class="flex-1 pt-1">
+      <h4 class="font-weight-bold" i18n>
+        {{patron.family_name()}}, 
+        {{patron.first_given_name()}} 
+        {{patron.second_given_name()}}
+      </h4>
+    </div>
+    <ng-container *ngIf="hasPrefName()">
+      <div class="mr-2 ml-2 text-info font-italic" i18n>account</div>
+    </ng-container>
+  </div>
+
+  <div class="row d-flex border-top" *ngIf="hasPrefName()">
+    <div class="flex-1 pt-1">
+      <h4 class="font-weight-bold" i18n>
+        {{patronService.namePart(patron, 'family_name')}}, 
+        {{patronService.namePart(patron, 'first_given_name')}} 
+        {{patronService.namePart(patron, 'second_given_name')}}
+      </h4>
+    </div>
+    <div class="mr-2 ml-2 text-info font-italic" i18n>preferred</div>
+  </div>
+
+  <div class="row mb-1 alert alert-danger p-0" 
+    *ngIf="alerts.accountExpiresSoon">
+    <div class="col-lg-12" i18n>
+      Patron account will expire soon.  Please renew.
+    </div>
+  </div>
+
+  <div class="row mb-1 alert alert-danger p-0" 
+    *ngFor="let pen of alerts.alertPenalties">
+    <div class="col-lg-9"
+      title="{{pen.standing_penalty().name()}}">
+      {{pen.note() || pen.standing_penalty().label()}}
+    </div>
+    <div class="col-lg-3">{{pen.set_date() | date:'shortDate'}}</div>
+  </div>
+
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Profile</div>
+    <div class="col-lg-7">{{patron.profile().name()}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Home Library</div>
+    <div class="col-lg-7">{{orgSn(patron.home_ou())}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Net Access</div>
+    <div class="col-lg-7">{{patron.net_access_level().name()}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Date of Birth</div>
+    <div class="col-lg-7">{{patron.dob() | date:'shortDate'}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Parent/Guardian</div>
+    <div class="col-lg-7">{{patron.guardian()}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Last Activity</div>
+    <div class="col-lg-7">
+      <ng-container *ngIf="patron.usr_activity()[0]">
+        {{patron.usr_activity()[0].event_time() | date:'shortDate'}}
+      </ng-container>
+    </div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Last Updated</div>
+    <div class="col-lg-7">{{patron.last_update_time() | date:'shortDate'}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Create Date</div>
+    <div class="col-lg-7">{{patron.create_date() | date:'shortDate'}}</div>
+  </div>
+  <div class="row" [ngClass]="{'alert alert-danger p-0': alerts.accountExpired}">
+    <div class="col-lg-5" i18n>Expire Date</div>
+    <div class="col-lg-7">{{patron.expire_date() | date:'shortDate'}}</div>
+  </div>
+
+  <hr class="m-1"/>
+
+  <ng-container *ngIf="stats">
+
+    <div class="row mb-1"
+      [ngClass]="{'alert alert-danger p-0': stats.fines.balance_owed > 0}">
+      <div class="col-lg-5" i18n>Fines Owed</div>
+      <div class="col-lg-7">{{stats.fines.balance_owed | currency}}</div>
+    </div>
+
+    <ng-container 
+      *ngIf="stats.fines.group_balance_owed > stats.fines.balance_owed">
+      <div class="row mb-1 alert alert-danger p-0">
+        <div class="col-lg-5" i18n>Group Fines</div>
+        <div class="col-lg-7">{{stats.fines.group_balance_owed | currency}}</div>
+      </div>
+    </ng-container>
+
+    <div class="row mb-1">
+      <div class="col-lg-5" i18n>Items Out</div>
+      <div class="col-lg-7">{{stats.checkouts.total_out}}</div>
+    </div>
+    <div class="row mb-1"
+      [ngClass]="{'alert alert-danger p-0': stats.checkouts.overdue > 0}">
+      <div class="col-lg-5" i18n>Overdue</div>
+      <div class="col-lg-7">{{stats.checkouts.overdue}}</div>
+    </div>
+    <div class="row mb-1"
+      [ngClass]="{'alert alert-danger p-0': stats.checkouts.long_overdue > 0}">
+      <div class="col-lg-5" i18n>Long Overdue</div>
+      <div class="col-lg-7">{{stats.checkouts.long_overdue}}</div>
+    </div>
+    <div class="row mb-1"
+      [ngClass]="{'alert alert-danger p-0': stats.checkouts.claims_returned > 0}">
+      <div class="col-lg-5" i18n>Claimed Returned</div>
+      <div class="col-lg-7">{{stats.checkouts.claims_returned}}</div>
+    </div>
+    <div class="row mb-1"
+      [ngClass]="{'alert alert-danger p-0': stats.checkouts.lost > 0}">
+      <div class="col-lg-5" i18n>Lost</div>
+      <div class="col-lg-7">{{stats.checkouts.lost}}</div>
+    </div>
+    <div class="row mb-1">
+      <div class="col-lg-5" i18n>Non-Cataloged</div>
+      <div class="col-lg-7">{{stats.checkouts.noncat}}</div>
+    </div>
+    <div class="row">
+      <div class="col-lg-5" i18n>Holds</div>
+      <div class="col-lg-7">
+        {{stats.holds.ready}} / {{stats.holds.total}}
+      </div>
+    </div>
+
+    <hr class="m-1"/>
+  </ng-container>
+
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Card</div>
+    <div class="col-lg-7">
+      {{patron.card() ? patron.card().barcode() : ''}}
+    </div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Username</div>
+    <div class="col-lg-7">{{patron.usrname()}}</div>
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Day Phone</div>
+    <div class="col-lg-7">{{patron.day_phone()}}</div> 
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Evening Phone</div>
+    <div class="col-lg-7">{{patron.evening_phone()}}</div> 
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Other Phone</div>
+    <div class="col-lg-7">{{patron.other_phone()}}</div> 
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>ID1 </div>
+    <div class="col-lg-7">{{patron.ident_value()}}</div> 
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>ID2</div>
+    <div class="col-lg-7">{{patron.ident_value2()}}</div> 
+  </div>
+  <div class="row mb-1">
+    <div class="col-lg-5" i18n>Email</div>
+    <div class="col-lg-7">
+      <!-- TODO: mailto link -->
+      {{patron.email()}}
+    </div> 
+  </div>
+
+  <hr class="m-1"/>
+
+  <div class="row mb-1" *ngFor="let addr of patron.addresses()">
+    <div class="col-lg-12">
+      <fieldset>
+        <legend class="d-flex" [ngClass]="{'alert alert-danger p-0': addr.valid() == 'f'}">
+          <div class="flex-1">{{addr.address_type()}}</div>
+          <div>
+            <a class="mr-2" href="javascript:;" 
+              (click)="copyAddress(addr)" i18n>copy</a>
+            <a class="mr-2" href="javascript:;" 
+              (click)="printAddress(addr)" i18n>print</a>
+          </div>
+        </legend>
+        <div i18n>{{addr.street1()}} {{addr.street2()}}</div>
+        <div i18n>{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</div>
+      </fieldset>
+
+      <!-- hidden textare used only for copying the text -->
+      <textarea id="patron-address-copy-{{addr.id()}}" rows="2"              
+        style="visibility:hidden">
+{{patron.first_given_name()}} {{patron.second_given_name()}} {{patron.family_name()}}
+{{addr.street1()}} {{addr.street2()}}
+{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</textarea>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/patron/summary.component.ts
new file mode 100644 (file)
index 0000000..a80835e
--- /dev/null
@@ -0,0 +1,78 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {OrgService} from '@eg/core/org.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {PatronService, PatronStats, PatronAlerts} from './patron.service';
+
+@Component({
+  templateUrl: 'summary.component.html',
+  styleUrls: ['summary.component.css'],
+  selector: 'eg-patron-summary'
+})
+export class PatronSummaryComponent implements OnInit {
+
+    @Input() patron: IdlObject;
+    @Input() stats: PatronStats;
+    @Input() alerts: PatronAlerts;
+
+    constructor(
+        private org: OrgService,
+        private net: NetService,
+        private printer: PrintService,
+        public patronService: PatronService
+    ) {}
+
+    ngOnInit() {
+    }
+
+    hasPrefName(): boolean {
+        if (this.patron) {
+            return (
+                this.patron.pref_first_given_name() ||
+                this.patron.pref_second_given_name() ||
+                this.patron.pref_family_name()
+            );
+        }
+    }
+
+    printAddress(addr: IdlObject) {
+        this.printer.print({
+            templateName: 'patron_address',
+            contextData: {
+                patron: this.patron,
+                address: addr
+            },
+            printContext: 'default'
+        });
+    }
+
+    copyAddress(addr: IdlObject) {
+        // Note navigator.clipboard requires special permissions.
+        // This is hinky, but gets the job done without the perms.
+
+        const node = document.getElementById(
+            `patron-address-copy-${addr.id()}`) as HTMLTextAreaElement;
+
+        // Un-hide the textarea just long enough to copy its data.
+        // Using node.style instead of *ngIf in hopes it
+        // will be quicker, so the user never sees the textarea.
+        node.style.visibility = 'visible';
+        node.focus();
+        node.select();
+
+        if (!document.execCommand('copy')) {
+            console.error('Copy command failed');
+        }
+
+        node.style.visibility = 'hidden';
+    }
+
+    orgSn(orgId: number): string {
+        const org = this.org.get(orgId);
+        return org ? org.shortname() : '';
+    }
+}
+