{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;
<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>
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';
declarations: [
PatronComponent,
PatronAlertsComponent,
- SummaryComponent,
CheckoutComponent,
HoldsComponent,
EditComponent,
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';
'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 {
patronStats: PatronStats;
alerts: PatronAlerts;
- noTallyClaimsReturned = false; // circ.do_not_tally_claims_returned
- tallyLost = false; // circ.tally_lost
-
loaded = false;
lastPatronSearch: PatronSearch;
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);
}
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
// 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 {
// 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;
}
});
}
'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'];
});
}
}
+++ /dev/null
-
-.patron-summary-container .row:nth-child(odd):not(.alert) {
- background-color: rgb(248, 248, 248);
-}
-
-
+++ /dev/null
-
-<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>
+++ /dev/null
-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';
- }
-
-}
-
+<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">
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.
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
if (evt) {
this.toast.danger(evt.toString());
} else {
+ this.toast.success(this.successString.text);
this.close();
}
});
--- /dev/null
+<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">×</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>
+
--- /dev/null
+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() {
+ }
+}
+
+
+
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: [
PatronSearchComponent,
PatronSearchDialogComponent,
ProfileSelectComponent,
+ PatronSummaryComponent,
+ PatronMergeDialogComponent,
PatronPenaltyDialogComponent
],
providers: [
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()
private org: OrgService,
private evt: EventService,
private pcrud: PcrudService,
- private auth: AuthService
+ private auth: AuthService,
+ private store: ServerStoreService
) {}
bcSearch(barcode: string): Observable<any> {
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;
+ })
+ }
}
+<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'
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',
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;
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(); }
+ });
+ }
}
--- /dev/null
+
+.patron-summary-container .row:nth-child(odd):not(.alert) {
+ background-color: rgb(248, 248, 248);
+}
+
+
--- /dev/null
+
+<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>
--- /dev/null
+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() : '';
+ }
+}
+