LP1840773 SCKO Renewals continued; login failed message user/slink/lp1840113-scko-angular-v3_signoff
authorBill Erickson <berickxx@gmail.com>
Fri, 15 Jul 2022 14:57:20 +0000 (10:57 -0400)
committerShula Link <slink@gchrl.org>
Wed, 27 Jul 2022 20:07:15 +0000 (16:07 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Shula Link <slink@gchrl.org>
Open-ILS/src/eg2/src/app/share/string/string.service.ts
Open-ILS/src/eg2/src/app/staff/scko/banner.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/scko/items.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/scko/scko.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/scko/scko.service.ts [new file with mode: 0644]

index 88d0c8a..e51874c 100644 (file)
@@ -56,6 +56,9 @@ export class StringService {
 
     processPending() {
         const pstring = this.pending[0];
+
+        console.debug('STRING', pstring.key, pstring.ctx);
+
         this.strings[pstring.key].resolver(pstring.ctx).then(
             txt => {
                 pstring.resolve(txt);
diff --git a/Open-ILS/src/eg2/src/app/staff/scko/banner.component.ts b/Open-ILS/src/eg2/src/app/staff/scko/banner.component.ts
new file mode 100644 (file)
index 0000000..9327379
--- /dev/null
@@ -0,0 +1,157 @@
+import {Component, OnInit, AfterViewInit, NgZone, HostListener} from '@angular/core';
+import {Location} from '@angular/common';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
+import {AuthService, AuthWsState} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {StoreService} from '@eg/core/store.service';
+import {SckoService, ActionContext} from './scko.service';
+import {OrgService} from '@eg/core/org.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {HatchService} from '@eg/core/hatch.service';
+
+@Component({
+  selector: 'eg-scko-banner',
+  templateUrl: 'banner.component.html'
+})
+
+export class SckoBannerComponent implements OnInit, AfterViewInit {
+
+    workstations: any[];
+    workstationNotFound = false;
+
+    patronUsername: string;
+    patronPassword: string;
+
+    staffUsername: string;
+    staffPassword: string;
+    staffWorkstation: string;
+    staffLoginFailed = false;
+
+    itemBarcode: string;
+
+    constructor(
+        private route: ActivatedRoute,
+        private store: StoreService,
+        private net: NetService,
+        private auth: AuthService,
+        private evt: EventService,
+        private ngLocation: Location,
+        private org: OrgService,
+        private hatch: HatchService,
+        public scko: SckoService
+    ) {}
+
+    ngOnInit() {
+
+        this.hatch.connect();
+
+        this.store.getWorkstations()
+        .then(wsList => {
+            this.workstations = wsList;
+            return this.store.getDefaultWorkstation();
+        }).then(def => {
+            this.staffWorkstation = def;
+            this.applyWorkstation();
+        });
+    }
+
+    ngAfterViewInit() {
+        if (this.auth.token()) {
+            this.focusNode('patron-username');
+        } else {
+            this.focusNode('staff-username');
+        }
+
+        this.scko.focusBarcode.subscribe(_ => this.focusNode('item-barcode'));
+    }
+
+    focusNode(id: string) {
+        setTimeout(() => {
+            const node = document.getElementById(id);
+            if (node) { (node as HTMLInputElement).select(); }
+        });
+    }
+
+    applyWorkstation() {
+        const wanted = this.route.snapshot.queryParamMap.get('workstation');
+        if (!wanted) { return; } // use the default
+
+        const exists = this.workstations.filter(w => w.name === wanted)[0];
+        if (exists) {
+            this.staffWorkstation = wanted;
+        } else {
+            console.error(`Unknown workstation requested: ${wanted}`);
+        }
+    }
+
+    submitStaffLogin() {
+
+        this.staffLoginFailed = false;
+
+        const args = {
+            type: 'persistent',
+            username: this.staffUsername,
+            password: this.staffPassword,
+            workstation: this.staffWorkstation
+        };
+
+        this.staffLoginFailed = false;
+        this.workstationNotFound = false;
+
+        this.auth.login(args).then(
+            ok => {
+
+                if (this.auth.workstationState === AuthWsState.NOT_FOUND_SERVER) {
+                    this.staffLoginFailed = true;
+                    this.workstationNotFound = true;
+
+                } else {
+
+                    // Initial login clears cached org unit setting values
+                    // and user/workstation setting values
+                    this.org.clearCachedSettings().then(_ => {
+
+                        // Force reload of the app after a successful login.
+                        window.location.href =
+                            this.ngLocation.prepareExternalUrl('/staff/scko');
+
+                    });
+                }
+            },
+            notOk => {
+                this.staffLoginFailed = true;
+            }
+        );
+    }
+
+    submitPatronLogin() {
+        this.patronUsername = (this.patronUsername || '').trim();
+        this.scko.loadPatron(this.patronUsername, this.patronPassword)
+        .finally(() => {
+
+            if (this.scko.patronSummary === null) {
+
+                const ctx: ActionContext = {
+                    username: this.patronUsername,
+                    shouldPopup: true,
+                    alertSound: 'error.scko.login_failed',
+                    displayText: 'scko.error.login_failed'
+                };
+
+                this.scko.notifyPatron(ctx);
+
+            } else {
+                this.focusNode('item-barcode');
+            }
+
+            this.patronUsername = '';
+            this.patronPassword = '';
+        });
+    }
+
+    submitItemBarcode() {
+        this.scko.resetPatronTimeout();
+        this.scko.checkout(this.itemBarcode);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/scko/items.component.ts b/Open-ILS/src/eg2/src/app/staff/scko/items.component.ts
new file mode 100644 (file)
index 0000000..83bd419
--- /dev/null
@@ -0,0 +1,127 @@
+import {Component, OnInit, ViewEncapsulation} from '@angular/core';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
+import {of, from} from 'rxjs';
+import {switchMap, tap} from 'rxjs/operators';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {NetService} from '@eg/core/net.service';
+import {IdlObject} from '@eg/core/idl.service';
+import {SckoService, ActionContext} from './scko.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {PrintService} from '@eg/share/print/print.service';
+
+@Component({
+  templateUrl: 'items.component.html'
+})
+
+export class SckoItemsComponent implements OnInit {
+
+    circs: IdlObject[] = [];
+    selected: {[id: number]: boolean} = {};
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private printer: PrintService,
+        public  scko: SckoService
+    ) {}
+
+    ngOnInit() {
+
+        if (!this.scko.patronSummary) {
+            this.router.navigate(['/staff/scko']);
+            return;
+        }
+
+        this.scko.resetPatronTimeout();
+
+        this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_out.authoritative',
+            this.auth.token(), this.scko.patronSummary.id).toPromise()
+
+        .then(data => {
+            const ids = data.out.concat(data.overdue).concat(data.long_overdue);
+            return this.scko.getFleshedCircs(ids).pipe(tap(circ => {
+                this.circs.push(circ);
+                this.selected[circ.id()] = true;
+            })).toPromise();
+        });
+    }
+
+    printList() {
+
+        const data = this.circs.map(c => {
+            return {
+                circ: c,
+                copy: c.target_copy(),
+                title: this.scko.getCircTitle(c),
+                author: this.scko.getCircAuthor(c)
+            };
+        });
+
+        this.printer.print({
+            templateName: 'scko_items_out',
+            contextData: {
+                checkouts: data,
+                user: this.scko.patronSummary.patron
+            },
+            printContext: 'default'
+        });
+    }
+
+    toggleSelect() {
+        const selectMe =
+            Object.values(this.selected).filter(v => v).length < this.circs.length;
+        Object.keys(this.selected).forEach(key => this.selected[key] = selectMe);
+    }
+
+    renewSelected() {
+
+        const renewList = this.circs.filter(c => this.selected[c.id()]);
+        if (renewList.length === 0) { return; }
+
+        const contexts: ActionContext[] = [];
+
+        from(renewList).pipe(switchMap(circ => {
+            return of(
+                this.scko.renew(circ.target_copy().barcode())
+                .then(ctx => {
+                    contexts.push(ctx);
+
+                    if (!ctx.newCirc) { return; }
+
+                    // Replace the renewed circ with the new circ.
+                    const circs = [];
+                    this.circs.forEach(c => {
+                        if (c.id() === circ.id()) {
+                            circs.push(ctx.newCirc);
+                        } else {
+                            circs.push(c);
+                        }
+                    });
+                    this.circs = circs;
+                })
+            );
+        })).toPromise().then(_ => {
+
+            // Create one ActionContext to represent the batch for
+            // notification purposes.  Avoid popups and audio on batch
+            // renewals.
+
+            const notifyCtx: ActionContext = {
+                displayText: 'scko.batch_renew.result',
+                renewSuccessCount: contexts.filter(c => c.newCirc).length,
+                renewFailCount: contexts.filter(c => !c.newCirc).length
+            };
+
+            this.scko.notifyPatron(notifyCtx);
+        });
+    }
+}
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/scko/scko.component.html b/Open-ILS/src/eg2/src/app/staff/scko/scko.component.html
new file mode 100644 (file)
index 0000000..f7fd5d8
--- /dev/null
@@ -0,0 +1,126 @@
+
+<eg-scko-banner></eg-scko-banner>
+
+<div class="d-flex scko-status-row mt-2">
+  <div class="flex-1"></div>
+  <div [ngClass]="{
+    'text-success': scko.statusDisplaySuccess, 
+    'text-danger': !scko.statusDisplaySuccess}">{{scko.statusDisplayText}}</div>
+  <div class="flex-1"></div>
+</div>
+
+<div *ngIf="scko.auth.token() && scko.patronSummary" class="row mr-0 mt-5">
+  <div class="col-lg-9">
+    <div class="ml-2 scko-page">
+      <router-outlet></router-outlet>
+    </div>
+  </div>
+  <div class="col-lg-3"><eg-scko-summary></eg-scko-summary></div>
+</div>
+
+<eg-confirm-dialog #logoutDialog 
+  i18n-dialogTitle i18n-dialogBody
+  i18n-confirmButtonText i18n-cancelButtonText
+  dialogTitle="Logout Notice"
+  dialogBody="Your login session will timeout due to inactivity"
+  confirmButtonText="Continue Session"
+  cancelButtonText="Logout">
+</eg-confirm-dialog>
+
+<eg-alert-dialog #alertDialog i18n-dialogTitle="Notice"></eg-alert-dialog>
+
+<!-- global toast alerts -->
+<eg-toast></eg-toast>
+
+<!-- global print handler component -->
+<eg-print></eg-print>
+
+<!-- context menu DOM insertion point -->
+<eg-context-menu-container></eg-context-menu-container>
+
+<eg-string i18n-text key="scko.unknown" text="Unknown Error Occurred"></eg-string>
+<eg-string i18n-text key="scko.checkout.success" text="Checkout Succeeded"></eg-string>
+<eg-string i18n-text key="scko.renew.success" text="Renewal Succeeded"></eg-string>
+<eg-string i18n-text key="scko.item.not_found" 
+  text="Item was not found in the system. Try re-scanning the item."></eg-string>
+<eg-string i18n-text key="scko.checkout.already_out" 
+  text="Item is checked out to another patron"></eg-string>
+
+<ng-template i18n let-ctx="ctx" #loginFailed>
+  Login for "{{ctx ? ctx.username : ''}}" failed.
+</ng-template>
+<eg-string i18n-text key="scko.error.login_failed" [template]="loginFailed"></eg-string>
+
+<ng-template i18n let-ctx="ctx" #maxRenew>
+  No more renewals allowed for item {{ctx ? ctx.barcode : ''}}
+</ng-template>
+<eg-string i18n-text key="scko.error.max_renewals" [template]="maxRenew"></eg-string>
+
+<eg-string key="scko.error.patron_fines"
+  text="This account has too many fines to checkout."></eg-string>
+
+<ng-template i18n let-ctx="ctx" #itemNotCataloged>
+  Item {{ctx ? ctx.barcode : ctx}} was not found in the system.  Try re-scanning the item.
+</ng-template>
+<eg-string key="scko.error.item_not_cataloged" [template]="itemNotCataloged"> </eg-string>
+
+
+<ng-template i18n let-ctx="ctx" #copyCircNotAllowed>
+  Item {{ctx ? ctx.barcode : ''}} is not allowed to circulate</ng-template>
+<eg-string key="scko.error.copy_circ_not_allowed" [template]="copyCircNotAllowed">
+</eg-string>
+
+<ng-template let-ctx="ctx" #batchRenewResultTmpl>
+  <span class="mr-1" *ngIf="ctx && ctx.renewSuccessCount > 0" i18n>
+    {{ctx.renewSuccessCount}} item(s) successfully renewed.
+  </span>
+  <span class="mr-1" *ngIf="ctx && ctx.renewFailCount > 0" i18n>
+    {{ctx.renewFailCount}} item(s) failed to renew.
+  </span>
+</ng-template>
+<eg-string key="scko.batch_renew.result" [template]="batchRenewResultTmpl">
+</eg-string>
+
+<eg-string i18n-text key="scko.error.actor_usr_barred" 
+  text="The patron is barred"></eg-string>
+<eg-string i18n-text key="scko.error.asset_copy_circulate" 
+  text="The item does not circulate"></eg-string>
+<eg-string i18n-text key="scko.error.asset_copy_location_circulate" 
+  text="Items from this shelving location do not circulate"></eg-string>
+<eg-string i18n-text key="scko.error.asset_copy_status" 
+  text="The item cannot circulate at this time"></eg-string>
+<eg-string i18n-text key="scko.error.circ_holds_target_skip_me" 
+text="The item's circulation library does not fulfill holds"></eg-string>
+<eg-string i18n-text key="scko.error.config_circ_matrix_circ_mod_test" 
+  text="The patron has too many items of this type checked out"></eg-string>
+<eg-string i18n-text key="scko.error.config_circ_matrix_test_available_copy_hold_ratio" 
+  text="The available item-to-hold ratio is too low"></eg-string>
+<eg-string i18n-text key="scko.error.config_circ_matrix_test_circulate"
+  text="Circulation rules reject this item as non-circulatable"></eg-string>
+<eg-string i18n-text key="scko.error.config_circ_matrix_test_total_copy_hold_ratio"
+  text="The total item-to-hold ratio is too low"></eg-string>
+<eg-string i18n-text key="scko.error.config_hold_matrix_test_holdable"
+  text="Hold rules reject this item as unholdable"></eg-string>
+<eg-string i18n-text key="scko.error.config_hold_matrix_test_max_holds"
+  text="The patron has reached the maximum number of holds"></eg-string>
+<eg-string i18n-text key="scko.error.item.holdable"
+  text="The item is not holdable"></eg-string>
+<eg-string i18n-text key="scko.error.location.holdable"
+  text="The item's location is not holdable"></eg-string>
+<eg-string i18n-text key="scko.error.status.holdable"
+  text="The item is not in a holdable status"></eg-string>
+<eg-string i18n-text key="scko.error.config_rule_age_hold_protect_prox"
+  text="The item is too new to transit this far"></eg-string>
+<eg-string i18n-text key="scko.error.no_item"
+  text="The system could not find this item"></eg-string>
+<eg-string i18n-text key="scko.error.no_ultimate_items"
+  text="The system could not find any items to match this hold request"></eg-string>
+<eg-string i18n-text key="scko.error.no_matchpoint"
+  text="System rules do not define how to handle this item"></eg-string>
+<eg-string i18n-text key="scko.error.no_user"
+  text="The system could not find this patron"></eg-string>
+<eg-string i18n-text key="scko.error.transit_range"
+  text="The item cannot transit this far"></eg-string>
+<eg-string i18n-text key="scko.error.patron_exceeds_checkout_count"
+  text="Maximum checkouts reached on this account"></eg-string>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/scko/scko.service.ts b/Open-ILS/src/eg2/src/app/staff/scko/scko.service.ts
new file mode 100644 (file)
index 0000000..c4db1bf
--- /dev/null
@@ -0,0 +1,707 @@
+import {Injectable, EventEmitter} from '@angular/core';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
+import {empty, Observable} from 'rxjs';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {EventService, EgEvent} from '@eg/core/event.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {StoreService} from '@eg/core/store.service';
+import {PatronService, PatronSummary, PatronStats} from '@eg/staff/share/patron/patron.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {PrintService} from '@eg/share/print/print.service';
+import {AudioService} from '@eg/share/util/audio.service';
+import {StringService} from '@eg/share/string/string.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+
+export interface ActionContext {
+    barcode?: string; // item
+    username?: string; // patron username or barcode
+    result?: any;
+    firstEvent?: EgEvent;
+    payload?: any;
+    override?: boolean;
+    redo?: boolean;
+    renew?: boolean;
+    displayText?: string; // string key
+    alertSound?: string;
+    shouldPopup?: boolean;
+    previousCirc?: IdlObject;
+    renewalFailure?: boolean;
+    newCirc?: IdlObject;
+    external?: boolean; // not from main checkout input.
+    renewSuccessCount?: number;
+    renewFailCount?: number;
+}
+
+interface SessionCheckout {
+    circ: IdlObject;
+    ctx: ActionContext;
+}
+
+const CIRC_FLESH_DEPTH = 4;
+const CIRC_FLESH_FIELDS = {
+  circ: ['target_copy'],
+  acp:  ['call_number'],
+  acn:  ['record'],
+  bre:  ['flat_display_entries']
+};
+
+@Injectable({providedIn: 'root'})
+export class SckoService {
+
+    // Currently active patron account object.
+    patronSummary: PatronSummary;
+    statusDisplayText = '';
+    statusDisplaySuccess: boolean;
+
+    barcodeRegex: RegExp;
+    patronPasswordRequired = false;
+    patronIdleTimeout: number;
+    patronTimeoutId: number;
+    logoutWarningTimeout = 20;
+    logoutWarningTimerId: number;
+
+    alertAudio = false;
+    alertPopup = false;
+    orgSettings: any;
+    overrideCheckoutEvents: string[] = [];
+    blockStatuses: number[] = [];
+
+    sessionCheckouts: SessionCheckout[] = [];
+
+    // We get this from the main scko component.
+    logoutDialog: ConfirmDialogComponent;
+    alertDialog: AlertDialogComponent;
+    focusBarcode: EventEmitter<void> = new EventEmitter<void>();
+    patronLoaded: EventEmitter<void> = new EventEmitter<void>();
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private org: OrgService,
+        private net: NetService,
+        private evt: EventService,
+        public auth: AuthService,
+        private pcrud: PcrudService,
+        private printer: PrintService,
+        private audio: AudioService,
+        private strings: StringService,
+        private patrons: PatronService,
+    ) {}
+
+    logoutStaff() {
+        this.resetPatron();
+        this.auth.logout();
+        this.router.navigate(['/staff/scko']);
+    }
+
+    resetPatron() {
+        this.statusDisplayText = '';
+        this.patronSummary = null;
+        this.sessionCheckouts = [];
+    }
+
+    load(): Promise<any> {
+        this.auth.authDomain = 'eg.scko';
+
+        return this.auth.testAuthToken()
+
+        .then(_ => {
+
+            // Note we cannot use server-store unless we are logged
+            // in with a workstation.
+            return this.org.settings([
+                'opac.barcode_regex',
+                'circ.selfcheck.patron_login_timeout',
+                'circ.selfcheck.auto_override_checkout_events',
+                'circ.selfcheck.patron_password_required',
+                'circ.checkout_auto_renew_age',
+                'circ.selfcheck.workstation_required',
+                'circ.selfcheck.alert.popup',
+                'circ.selfcheck.alert.sound',
+                'credit.payments.allow',
+                'circ.selfcheck.block_checkout_on_copy_status'
+            ]);
+
+        }).then(sets => {
+            this.orgSettings = sets;
+
+            const regPattern = sets['opac.barcode_regex'] || /^\d/;
+            this.barcodeRegex = new RegExp(regPattern);
+            this.patronPasswordRequired =
+                sets['circ.selfcheck.patron_password_required'];
+
+            this.alertAudio = sets['circ.selfcheck.alert.sound'];
+            this.alertPopup = sets['circ.selfcheck.alert.popup'];
+
+            this.overrideCheckoutEvents =
+                sets['circ.selfcheck.auto_override_checkout_events'] || [];
+
+            this.blockStatuses =
+                sets['circ.selfcheck.block_checkout_on_copy_status'] ?
+                sets['circ.selfcheck.block_checkout_on_copy_status'].map(s => Number(s)) :
+                [];
+
+            this.patronIdleTimeout =
+                Number(sets['circ.selfcheck.patron_login_timeout'] || 160);
+
+            // Compensate for the warning dialog
+            this.patronIdleTimeout -= this.logoutWarningTimeout;
+
+            // Load a patron by barcode via URL params.
+            // Useful for development.
+            const username = this.route.snapshot.queryParamMap.get('patron');
+
+            if (username && !this.patronPasswordRequired) {
+                return this.loadPatron(username);
+            } else {
+                // Go to the base checkout page by default.
+                this.router.navigate(['/staff/scko']);
+            }
+        }).catch(_ => {}); // console errors
+    }
+
+    getFleshedCircs(circIds: number[]): Observable<IdlObject> {
+        if (circIds.length === 0) { return empty(); }
+
+        return this.pcrud.search('circ', {id: circIds}, {
+            flesh: CIRC_FLESH_DEPTH,
+            flesh_fields: CIRC_FLESH_FIELDS,
+            order_by : {circ : 'due_date'},
+            select: {bre : ['id']}
+        });
+    }
+
+    getFleshedCirc(circId: number): Promise<IdlObject> {
+        return this.getFleshedCircs([circId]).toPromise();
+    }
+
+    loadPatron(username: string, password?: string): Promise<any> {
+        this.resetPatron();
+
+        if (!username) { return; }
+
+        let barcode;
+        if (username.match(this.barcodeRegex)) {
+            barcode = username;
+            username = null;
+        }
+
+        if (!this.patronPasswordRequired) {
+            return this.fetchPatron(username, barcode);
+        }
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.verify_user_password',
+            this.auth.token(), barcode, username, null, password)
+
+        .toPromise().then(verified => {
+            if (Number(verified) === 1) {
+                return this.fetchPatron(username, barcode);
+            } else {
+                return Promise.reject('Bad password');
+            }
+        });
+    }
+
+    fetchPatron(username: string, barcode: string): Promise<any> {
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+            this.auth.token(), barcode, username).toPromise()
+
+        .then(patronId => {
+
+            const evt = this.evt.parse(patronId);
+
+            if (evt || !patronId) {
+                console.error('Cannot find user: ', evt);
+                return Promise.reject('User not found');
+            }
+
+            return this.patrons.getFleshedById(patronId);
+        })
+        .then(patron => this.patronSummary = new PatronSummary(patron))
+        .then(_ => this.patrons.getVitalStats(this.patronSummary.patron))
+        .then(stats => this.patronSummary.stats = stats)
+        .then(_ => this.resetPatronTimeout())
+        .then(_ => this.patronLoaded.emit());
+    }
+
+    resetPatronTimeout() {
+        console.debug('Resetting patron timeout=' + this.patronIdleTimeout);
+        if (this.patronTimeoutId) {
+            clearTimeout(this.patronTimeoutId);
+        }
+        this.startPatronTimer();
+    }
+
+    startPatronTimer() {
+        this.patronTimeoutId = setTimeout(
+            () => this.showPatronLogoutWarning(),
+            this.patronIdleTimeout * 1000
+        );
+    }
+
+    showPatronLogoutWarning() {
+        console.debug('Session timing out.  Show warning dialog');
+
+        this.logoutDialog.open().subscribe(remain => {
+            if (remain) {
+                clearTimeout(this.logoutWarningTimerId);
+                this.logoutWarningTimerId = null;
+                this.resetPatronTimeout();
+            } else {
+                this.resetPatron();
+                this.router.navigate(['/staff/scko']);
+            }
+        });
+
+        // Force the session to end if no action is taken on the
+        // logout warning dialog.
+        this.logoutWarningTimerId = setTimeout(
+            () => {
+                console.debug('Clearing patron on warning dialog timeout');
+                this.resetPatron();
+                this.router.navigate(['/staff/scko']);
+            },
+            this.logoutWarningTimeout * 1000
+        );
+    }
+
+    sessionTotalCheckouts(): number {
+        return this.sessionCheckouts.length;
+    }
+
+    accountTotalCheckouts(): number {
+        // stats.checkouts.total_out includes claims returned
+        // Exclude locally renewed items from the total checkouts
+
+        return this.sessionCheckouts.filter(co => !co.ctx.external).length +
+            this.patronSummary.stats.checkouts.out +
+            this.patronSummary.stats.checkouts.overdue +
+            this.patronSummary.stats.checkouts.long_overdue;
+    }
+
+    checkout(barcode: string, override?: boolean): Promise<any> {
+        this.resetPatronTimeout();
+
+        barcode = (barcode || '').trim();
+        if (!barcode) { return Promise.resolve(); }
+
+        let method = 'open-ils.circ.checkout.full';
+        if (override) { method += '.override'; }
+
+        return this.net.request(
+            'open-ils.circ', method, this.auth.token(), {
+            patron_id: this.patronSummary.id,
+            copy_barcode: barcode
+        }).toPromise()
+
+        .then(result => {
+
+            console.debug('CO returned', result);
+
+            return this.handleCheckoutResult(result, barcode, 'checkout');
+
+        }).then(ctx => {
+            console.debug('handleCheckoutResult returned', ctx);
+
+            if (ctx.override) {
+                return this.checkout(barcode, true);
+            } else if (ctx.redo) {
+                return this.checkout(barcode);
+            } else if (ctx.renew) {
+                return this.renew(barcode);
+            }
+
+            return ctx;
+
+        // Checkout actions always takes us back to the main page
+        // so we can see our items out in progress.
+        })
+        .then(ctx => this.notifyPatron(ctx))
+        .finally(() => this.router.navigate(['/staff/scko']));
+    }
+
+    renew(barcode: string,
+        override?: boolean, external?: boolean): Promise<ActionContext> {
+
+        let method = 'open-ils.circ.renew';
+        if (override) { method += '.override'; }
+
+        return this.net.request(
+            'open-ils.circ', method, this.auth.token(), {
+            patron_id: this.patronSummary.id,
+            copy_barcode: barcode
+        }).toPromise()
+
+        .then(result => {
+            console.debug('Renew returned', result);
+
+            return this.handleCheckoutResult(result, barcode, 'renew', external);
+
+        }).then(ctx => {
+            console.debug('handleCheckoutResult returned', ctx);
+
+            if (ctx.override) {
+                return this.renew(barcode, true, external);
+            }
+
+            return ctx;
+        });
+    }
+
+    notifyPatron(ctx: ActionContext) {
+        console.debug('notifyPatron(): ', ctx);
+
+        this.statusDisplayText = '';
+
+        this.statusDisplaySuccess = !ctx.shouldPopup;
+
+        this.focusBarcode.emit();
+
+        if (this.alertAudio && ctx.alertSound) {
+            this.audio.play(ctx.alertSound);
+        }
+
+        if (!ctx.displayText) { return; }
+
+        this.strings.interpolate(ctx.displayText, {ctx: ctx})
+        .then(str => {
+            this.statusDisplayText = str;
+            console.debug('Displaying text to user:', str);
+
+            if (this.alertPopup && ctx.shouldPopup && str) {
+                this.alertDialog.dialogBody = str;
+                this.alertDialog.open().toPromise();
+            }
+        });
+    }
+
+    handleCheckoutResult(result: any, barcode: string,
+        action: string, external?: boolean): Promise<ActionContext> {
+
+        if (Array.isArray(result)) {
+            result = result[0];
+        }
+
+        const evt: any = this.evt.parse(result) || {};
+        const payload = evt.payload || {};
+
+        if (evt.textcode === 'NO_SESSION') {
+            this.logoutStaff();
+            return;
+        }
+
+        const ctx: ActionContext = {
+            result: result,
+            firstEvent: evt,
+            payload: payload,
+            barcode: barcode,
+            displayText: 'scko.unknown',
+            alertSound: '',
+            shouldPopup: false,
+            redo: false,
+            override: false,
+            renew: false,
+            external: external
+        };
+
+        if (evt.textcode === 'SUCCESS') {
+            ctx.displayText = `scko.${action}.success`;
+            ctx.alertSound = `success.scko.${action}`;
+
+            return this.getFleshedCirc(payload.circ.id()).then(
+                circ => {
+                    ctx.newCirc = circ;
+                    this.sessionCheckouts.push({circ: circ, ctx: ctx});
+                    return ctx;
+                }
+            );
+        }
+
+        if (evt.textcode === 'OPEN_CIRCULATION_EXISTS' && action === 'checkout') {
+            return this.handleOpenCirc(ctx);
+        }
+
+        return this.handleEvents(ctx);
+    }
+
+    handleOpenCirc(ctx: ActionContext): Promise<any> {
+
+        if (ctx.payload.old_circ) {
+            const age = this.orgSettings['circ.checkout_auto_renew_age'];
+
+            if (!age || (age && ctx.payload.auto_renew)) {
+                ctx.renew = true;
+
+                // Flesh the previous circ so we can show the title,
+                // etc. in the receipt.
+                return this.getFleshedCirc(ctx.payload.old_circ.id())
+                .then(oldCirc => {
+                    ctx.previousCirc = oldCirc;
+                    return ctx;
+                });
+            }
+        }
+
+        // LOST items can be checked in and made usable if configured.
+        if (ctx.payload.copy
+            && Number(ctx.payload.copy.status()) === /* LOST */ 3
+            && this.overrideCheckoutEvents.length
+            && this.overrideCheckoutEvents.includes('COPY_STATUS_LOST')) {
+
+            return this.checkin(ctx.barcode).then(ok => {
+                if (ok) {
+                    ctx.redo = true;
+                } else {
+                    ctx.shouldPopup = true;
+                    ctx.alertSound = 'error.scko.checkout';
+                    ctx.displayText = 'scko.checkout.already_out';
+                }
+
+                return ctx;
+            });
+        }
+
+        ctx.shouldPopup = true;
+        ctx.alertSound = 'error.scko.checkout';
+        ctx.displayText = 'scko.checkout.already_out';
+
+        return Promise.resolve(ctx);
+    }
+
+    handleEvents(ctx: ActionContext): Promise<ActionContext> {
+        let override = true;
+        let abortTransit = false;
+        let lastErrorText = '';
+
+        [].concat(ctx.result).some(res => {
+
+            if (!this.overrideCheckoutEvents.includes(res.textcode)) {
+                console.debug('We are not configured to override', res.textcode);
+                lastErrorText = this.getErrorDisplyText(this.evt.parse(res));
+                return override = false;
+            }
+
+            if (this.blockStatuses.length > 0) {
+                let stat = res.payload.status();
+                if (typeof stat === 'object') { stat = stat.id(); }
+
+                if (this.blockStatuses.includes(Number(stat))) {
+                    return override = false;
+                }
+            }
+
+            if (res.textcode === 'COPY_IN_TRANSIT') {
+                abortTransit = true;
+            }
+
+            return true;
+        });
+
+        if (!override) {
+            ctx.shouldPopup = true;
+            ctx.alertSound = 'error.scko.checkout';
+            ctx.renewalFailure = true;
+            ctx.displayText = lastErrorText;
+            return Promise.resolve(ctx);
+        }
+
+        if (!abortTransit) {
+            ctx.override = true;
+            return Promise.resolve(ctx);
+        }
+
+        return this.checkin(ctx.barcode, true).then(ok => {
+            if (ok) {
+                ctx.redo = true;
+            } else {
+                ctx.shouldPopup = true;
+                ctx.alertSound = 'error.scko.checkout';
+            }
+            return ctx;
+        });
+    }
+
+    getErrorDisplyText(evt: EgEvent): string {
+
+        switch (evt.textcode) {
+            case 'PATRON_EXCEEDS_CHECKOUT_COUNT':
+                return 'scko.error.patron_exceeds_checkout_count';
+            case 'MAX_RENEWALS_REACHED':
+                return 'scko.error.max_renewals';
+            case 'ITEM_NOT_CATALOGED':
+                return 'scko.error.item_not_cataloged';
+            case 'COPY_CIRC_NOT_ALLOWED':
+                return 'scko.error.copy_circ_not_allowed';
+            case 'OPEN_CIRCULATION_EXISTS':
+                return 'scko.error.already_out';
+            case 'PATRON_EXCEEDS_FINES':
+                return 'scko.error.patron_fines';
+            default:
+                if (evt.payload && evt.payload.fail_part) {
+                    return 'scko.error.' +
+                        evt.payload.fail_part.replace(/\./g, '_');
+                }
+        }
+
+        return 'scko.error.unknown';
+    }
+
+    checkin(barcode: string, abortTransit?: boolean): Promise<boolean> {
+
+        let promise = Promise.resolve(true);
+
+        if (abortTransit) {
+
+            promise = this.net.request(
+                'open-ils.circ',
+                'open-ils.circ.transit.abort',
+                this.auth.token(), {barcode: barcode}).toPromise()
+
+            .then(resp => {
+
+                console.debug('Transit abort returned', resp);
+                return Number(resp) === 1;
+            });
+        }
+
+        promise = promise.then(ok => {
+            if (!ok) { return false; }
+
+            return this.net.request(
+                'open-ils.circ',
+                'open-ils.circ.checkin.override',
+                this.auth.token(), {
+                    patron_id : this.patronSummary.id,
+                    copy_barcode : barcode,
+                    noop : true
+                }
+
+            ).toPromise().then(resp => {
+
+                // If any response events are non-success, report the
+                // checkin as a failure.
+                let success = true;
+                [].concat(resp).forEach(evt => {
+                    console.debug('Checkin returned', resp);
+
+                    const code = evt.textcode;
+                    if (code !== 'SUCCESS' && code !== 'NO_CHANGE') {
+                        success = false;
+                    }
+                });
+
+                return success;
+
+            });
+        });
+
+        return promise;
+    }
+
+    logoutPatron(receiptType: string): Promise<any> {
+
+        let promise;
+
+        switch (receiptType) {
+            case 'email':
+                promise = this.emailReceipt();
+                break;
+            case 'print':
+                promise = this.printReceipt();
+                break;
+            default:
+                promise = Promise.resolve();
+        }
+
+        return promise.then(_ => {
+            this.resetPatron();
+            this.router.navigate(['/staff/scko']);
+        });
+    }
+
+    emailReceipt(): Promise<any> {
+
+        const circIds = this.sessionCheckouts
+            .filter(c => Boolean(c.circ)).map(c => c.circ.id());
+
+        return this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.checkout.batch_notify.session.atomic',
+            this.auth.token(), this.patronSummary.id, circIds
+        ).toPromise();
+    }
+
+    printReceipt(): Promise<any> {
+
+        return new Promise((resolve, reject) => {
+
+            const sub = this.printer.printJobQueued$.subscribe(_ => {
+                sub.unsubscribe();
+                // Give the print operation just a bit more time after
+                // the data is passed to the printer just to be safe.
+                setTimeout(() => resolve(null), 1000);
+            });
+
+            const data = this.sessionCheckouts.map(c => {
+                const circ = c.circ || c.ctx.previousCirc;
+                return {
+                    checkout: c,
+                    barcode: c.ctx.barcode,
+                    circ: circ,
+                    copy: circ ? circ.target_copy() : null,
+                    title: this.getCircTitle(circ),
+                    author: this.getCircAuthor(circ)
+                };
+            });
+
+            this.printer.print({
+                templateName: 'scko_checkouts',
+                contextData: {
+                    checkouts: data,
+                    user: this.patronSummary.patron
+                },
+                printContext: 'default'
+            });
+        });
+    }
+
+    copyIsPrecat(copy: IdlObject): boolean {
+        return Number(copy.id()) === -1;
+    }
+
+    circDisplayValue(circ: IdlObject, field: string): string {
+        if (!circ) { return ''; }
+
+        const entry =
+            circ.target_copy().call_number().record().flat_display_entries()
+            .filter(e => e.name() === field)[0];
+
+        return entry ? entry.value() : '';
+    }
+
+    getCircTitle(circ: IdlObject): string {
+        if (!circ) { return ''; }
+        const copy = circ.target_copy();
+        if (this.copyIsPrecat(copy)) { return copy.dummy_title(); }
+        return this.circDisplayValue(circ, 'title');
+    }
+
+    getCircAuthor(circ: IdlObject): string {
+        if (!circ) { return ''; }
+        const copy = circ.target_copy();
+        if (this.copyIsPrecat(copy)) { return copy.dummy_author(); }
+        return this.circDisplayValue(circ, 'author');
+    }
+
+}
+
+
+