From: Bill Erickson Date: Mon, 13 Jun 2022 17:51:15 +0000 (-0400) Subject: LP1840773 SCKO Angular X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=b57220a35c56146a6b6ff83bb932660da6a08548;p=working%2FEvergreen.git LP1840773 SCKO Angular Signed-off-by: Bill Erickson --- diff --git a/Open-ILS/src/eg2/src/app/core/auth.service.ts b/Open-ILS/src/eg2/src/app/core/auth.service.ts index 7a04f5874a..f549cba4a6 100644 --- a/Open-ILS/src/eg2/src/app/core/auth.service.ts +++ b/Open-ILS/src/eg2/src/app/core/auth.service.ts @@ -40,6 +40,9 @@ export enum AuthWsState { @Injectable({providedIn: 'root'}) export class AuthService { + // Override this to store authtokens, etc. in a different location + authDomain = 'eg.auth'; + private authChannel: any; private activeUser: AuthUser = null; @@ -62,7 +65,7 @@ export class AuthService { // Returns true if we are currently in op-change mode. opChangeIsActive(): boolean { - return Boolean(this.store.getLoginSessionItem('eg.auth.time.oc')); + return Boolean(this.store.getLoginSessionItem(`${this.authDomain}.time.oc`)); } // - Accessor functions always refer to the active user. @@ -92,8 +95,8 @@ export class AuthService { // Only necessary on new page loads. During op-change, // for example, we already have an activeUser. this.activeUser = new AuthUser( - this.store.getLoginSessionItem('eg.auth.token'), - this.store.getLoginSessionItem('eg.auth.time') + this.store.getLoginSessionItem(`${this.authDomain}.token`), + this.store.getLoginSessionItem(`${this.authDomain}.time`) ); } @@ -175,8 +178,8 @@ export class AuthService { handleLoginOk(args: AuthLoginArgs, evt: EgEvent, isOpChange: boolean): Promise { if (isOpChange) { - this.store.setLoginSessionItem('eg.auth.token.oc', this.token()); - this.store.setLoginSessionItem('eg.auth.time.oc', this.authtime()); + this.store.setLoginSessionItem(`${this.authDomain}.token.oc`, this.token()); + this.store.setLoginSessionItem(`${this.authDomain}.time.oc`, this.authtime()); } this.activeUser = new AuthUser( @@ -185,8 +188,8 @@ export class AuthService { args.workstation ); - this.store.setLoginSessionItem('eg.auth.token', this.token()); - this.store.setLoginSessionItem('eg.auth.time', this.authtime()); + this.store.setLoginSessionItem(`${this.authDomain}.token`, this.token()); + this.store.setLoginSessionItem(`${this.authDomain}.time`, this.authtime()); return Promise.resolve(); } @@ -195,14 +198,14 @@ export class AuthService { if (this.opChangeIsActive()) { this.deleteSession(); this.activeUser = new AuthUser( - this.store.getLoginSessionItem('eg.auth.token.oc'), - this.store.getLoginSessionItem('eg.auth.time.oc'), + this.store.getLoginSessionItem(`${this.authDomain}.token.oc`), + this.store.getLoginSessionItem(`${this.authDomain}.time.oc`), this.activeUser.workstation ); - this.store.removeLoginSessionItem('eg.auth.token.oc'); - this.store.removeLoginSessionItem('eg.auth.time.oc'); - this.store.setLoginSessionItem('eg.auth.token', this.token()); - this.store.setLoginSessionItem('eg.auth.time', this.authtime()); + this.store.removeLoginSessionItem(`${this.authDomain}.token.oc`); + this.store.removeLoginSessionItem(`${this.authDomain}.time.oc`); + this.store.setLoginSessionItem(`${this.authDomain}.token`, this.token()); + this.store.setLoginSessionItem(`${this.authDomain}.time`, this.authtime()); } // Re-fetch the user. return this.testAuthToken(); diff --git a/Open-ILS/src/eg2/src/app/routing.module.ts b/Open-ILS/src/eg2/src/app/routing.module.ts index 9385e996af..087c0cc3ad 100644 --- a/Open-ILS/src/eg2/src/app/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/routing.module.ts @@ -17,6 +17,10 @@ const routes: Routes = [ path: 'staff', resolve : {startup : BaseResolver}, loadChildren: () => import('./staff/staff.module').then(m => m.StaffModule) + }, { + path: 'scko', + resolve : {startup : BaseResolver}, + loadChildren: () => import('./scko/scko.module').then(m => m.SckoModule) } ]; diff --git a/Open-ILS/src/eg2/src/app/scko/banner.component.html b/Open-ILS/src/eg2/src/app/scko/banner.component.html new file mode 100644 index 0000000000..cea5bd0ecb --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/banner.component.html @@ -0,0 +1,132 @@ + +
+
+ +
+
+ +
+ Please log in with your username or library barcode. +
+
+
+
+
+ + + + + + + + + + +
+
+
+
+
+ + +
Please enter an item barcode
+
+
+
+
+ + + + + +
+
+
+
+
Welcome, + {{scko.patronSummary.patron.pref_first_given_name() + || scko.patronSummary.patron.first_given_name()}} +
+
+
+
+ +
+
+ +
+ +
+
+

Staff Account Login

+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+
Login Failed
+
+
+
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/scko/banner.component.ts b/Open-ILS/src/eg2/src/app/scko/banner.component.ts new file mode 100644 index 0000000000..4485f86827 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/banner.component.ts @@ -0,0 +1,142 @@ +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} from './scko.service'; +import {OrgService} from '@eg/core/org.service'; +import {EventService, EgEvent} from '@eg/core/event.service'; + +@Component({ + selector: 'eg-scko-banner', + templateUrl: 'banner.component.html' +}) + +export class SckoBannerComponent implements OnInit, AfterViewInit { + + workstations: any[]; + workstationNotFound = false; + + patronUsername: string; + patronPassword: string; + patronLoginFailed = false; + + 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, + public scko: SckoService + ) {} + + ngOnInit() { + + // NOTE: Displaying a list of workstations will not work for users + // of Hatch until the extension is updated to support /eg2/*/scko + 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('/scko'); + + }); + } + }, + notOk => { + this.staffLoginFailed = true; + } + ); + } + + submitPatronLogin() { + this.patronLoginFailed = false; + this.scko.loadPatron(this.patronUsername, this.patronPassword).finally(() => { + if (this.scko.patronSummary === null) { + this.patronLoginFailed = true; + } else { + this.focusNode('item-barcode'); + } + }); + } + + submitItemBarcode() { + this.scko.resetPatronTimeout(); + this.scko.checkout(this.itemBarcode); + } +} + diff --git a/Open-ILS/src/eg2/src/app/scko/checkout.component.html b/Open-ILS/src/eg2/src/app/scko/checkout.component.html new file mode 100644 index 0000000000..51f549f5a4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/checkout.component.html @@ -0,0 +1,35 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
BarcodeTitleAuthorDue DateRenewals LeftType
+ + + + {{co.circ.target_copy().barcode()}}{{scko.getCircTitle(co.circ)}}{{scko.getCircAuthor(co.circ)}}{{co.circ | egDueDate}}{{co.circ.renewal_remaining()}} + + Renewal + Checkout + +
+
diff --git a/Open-ILS/src/eg2/src/app/scko/checkout.component.ts b/Open-ILS/src/eg2/src/app/scko/checkout.component.ts new file mode 100644 index 0000000000..5afc1f6f29 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/checkout.component.ts @@ -0,0 +1,23 @@ +import {Component, OnInit, ViewEncapsulation} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +import {AuthService} from '@eg/core/auth.service'; +import {IdlObject} from '@eg/core/idl.service'; +import {SckoService} from './scko.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; + +@Component({ + templateUrl: 'checkout.component.html' +}) + +export class SckoCheckoutComponent implements OnInit { + + constructor( + private router: Router, + private route: ActivatedRoute, + public scko: SckoService + ) {} + + ngOnInit() { + } +} + diff --git a/Open-ILS/src/eg2/src/app/scko/fines.component.html b/Open-ILS/src/eg2/src/app/scko/fines.component.html new file mode 100644 index 0000000000..d4444c3cb1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/fines.component.html @@ -0,0 +1,35 @@ +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +
TypeDetailsTotal BilledTotal PaidBalance Owed
+ + Circulation + + + Miscellaneous + + {{getDetails(xact)}}{{xact.summary().total_owed() | currency}}{{xact.summary().total_paid() | currency}}{{xact.summary().balance_owed() | currency}}
+
diff --git a/Open-ILS/src/eg2/src/app/scko/fines.component.ts b/Open-ILS/src/eg2/src/app/scko/fines.component.ts new file mode 100644 index 0000000000..f13bda7615 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/fines.component.ts @@ -0,0 +1,110 @@ +import {Component, OnInit, ViewEncapsulation} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +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} from './scko.service'; +import {PrintService} from '@eg/share/print/print.service'; + + +@Component({ + templateUrl: 'fines.component.html' +}) + +export class SckoFinesComponent implements OnInit { + + xacts: IdlObject[] = []; + + 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(['/scko']); + return; + } + + this.scko.resetPatronTimeout(); + + this.pcrud.search('mbts', + { usr: this.scko.patronSummary.id, + xact_finish: null, + balance_owed: {'<>' : 0} + }, {}, {atomic: true} + ).pipe(switchMap(sums => { + + return this.pcrud.search('mbt', {id: sums.map(s => s.id())}, + { order_by: 'xact_start', + flesh: 5, + flesh_fields: { + mbt: ['summary', 'circulation', 'grocery'], + circ: ['target_copy'], + acp: ['call_number'], + acn: ['record'], + bre: ['flat_display_entries'] + }, + select: {bre : ['id']} + } + ).pipe(tap(xact => this.xacts.push(xact))); + })).toPromise(); + } + + displayValue(xact: IdlObject, field: string): string { + const entry = + xact.circulation().target_copy().call_number().record().flat_display_entries() + .filter(e => e.name() === field)[0]; + + return entry ? entry.value() : ''; + } + + getTitle(xact: IdlObject): string { + const copy = xact.circulation().target_copy(); + + if (Number(copy.call_number().id()) === -1) { + return copy.dummy_title(); + } + + return this.displayValue(xact, 'title'); + } + + getDetails(xact: IdlObject): string { + if (xact.summary().xact_type() === 'circulation') { + return this.getTitle(xact); + } else { + return xact.summary().last_billing_type(); + } + } + + printList() { + + const data = this.xacts.map(x => { + return { + xact: x, // full object if needed + details: this.getDetails(x), + total_owed: x.summary().total_owed(), + total_paid: x.summary().total_paid(), + balance_owed: x.summary().balance_owed(), + }; + }); + + this.printer.print({ + templateName: 'scko_fines', + contextData: { + xacts: data, + user: this.scko.patronSummary.patron + }, + printContext: 'default' + }); + } +} + diff --git a/Open-ILS/src/eg2/src/app/scko/holds.component.html b/Open-ILS/src/eg2/src/app/scko/holds.component.html new file mode 100644 index 0000000000..ff10e9bc7d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/holds.component.html @@ -0,0 +1,33 @@ +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
TitleAuthorStatus
{{hold.title}}{{hold.author}} + + Ready for Pickup + + + #{{hold.relative_queue_position}} in line with {{hold.potentials}} copie(s) + +
+
diff --git a/Open-ILS/src/eg2/src/app/scko/holds.component.ts b/Open-ILS/src/eg2/src/app/scko/holds.component.ts new file mode 100644 index 0000000000..131cf73a23 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/holds.component.ts @@ -0,0 +1,78 @@ +import {Component, OnInit, ViewEncapsulation} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +import {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} from './scko.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {PrintService} from '@eg/share/print/print.service'; + +@Component({ + templateUrl: 'holds.component.html' +}) + +export class SckoHoldsComponent implements OnInit { + + holds: IdlObject[] = []; + + 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(['/scko']); + return; + } + + this.scko.resetPatronTimeout(); + + const orderBy = [ + {shelf_time: {nulls: 'last'}}, + {capture_time: {nulls: 'last'}}, + {request_time: {nulls: 'last'}} + ]; + + const filters = { + usr_id: this.scko.patronSummary.id, + fulfillment_time: null + }; + + let first = true; + this.net.request( + 'open-ils.circ', + 'open-ils.circ.hold.wide_hash.stream', + this.auth.token(), filters, orderBy, 1000, 0, {} + ).subscribe(holdData => { + + if (first) { // First response is the hold count. + first = false; + return; + } + + this.holds.push(holdData); + }); + } + + printList() { + this.printer.print({ + templateName: 'scko_holds', + contextData: { + holds: this.holds, + user: this.scko.patronSummary.patron + }, + printContext: 'default' + }); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/scko/items.component.html b/Open-ILS/src/eg2/src/app/scko/items.component.html new file mode 100644 index 0000000000..c4a247bedf --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/items.component.html @@ -0,0 +1,39 @@ +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
BarcodeTitleAuthorDue DateRenewals LeftType
+ + + + {{circ.target_copy().barcode()}}{{scko.getCircTitle(circ)}}{{scko.getCircAuthor(circ)}}{{circ | egDueDate}}{{circ.renewal_remaining()}} + Renewal + Checkout +
+
diff --git a/Open-ILS/src/eg2/src/app/scko/items.component.ts b/Open-ILS/src/eg2/src/app/scko/items.component.ts new file mode 100644 index 0000000000..7a662a5245 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/items.component.ts @@ -0,0 +1,75 @@ +import {Component, OnInit, ViewEncapsulation} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +import {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} 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[] = []; + + 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(['/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); + })).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' + }); + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/scko/routing.module.ts b/Open-ILS/src/eg2/src/app/scko/routing.module.ts new file mode 100644 index 0000000000..08814530a1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/routing.module.ts @@ -0,0 +1,33 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {SckoComponent} from './scko.component'; +import {SckoCheckoutComponent} from './checkout.component'; +import {SckoItemsComponent} from './items.component'; +import {SckoHoldsComponent} from './holds.component'; +import {SckoFinesComponent} from './fines.component'; + +const routes: Routes = [{ + path: '', + component: SckoComponent, + children: [{ + path: '', + component: SckoCheckoutComponent + }, { + path: 'items', + component: SckoItemsComponent + }, { + path: 'holds', + component: SckoHoldsComponent + }, { + path: 'fines', + component: SckoFinesComponent + }] +}]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) + +export class SckoRoutingModule {} + diff --git a/Open-ILS/src/eg2/src/app/scko/scko.component.css b/Open-ILS/src/eg2/src/app/scko/scko.component.css new file mode 100644 index 0000000000..0150ee34a1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/scko.component.css @@ -0,0 +1,126 @@ +body { + font-family: Arial, Verdana; + font-size: 13px; +} + +A { + text-decoration: none; +} + +#scko-banner { + background: #00593d; /* Old browsers */ +/* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzAwNTkzZCIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiMwMDdhNTQiIHN0b3Atb3BhY2l0eT0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPHJlY3QgeD0iMCIgeT0iMCIgd2lkdGg9IjEiIGhlaWdodD0iMSIgZmlsbD0idXJsKCNncmFkLXVjZ2ctZ2VuZXJhdGVkKSIgLz4KPC9zdmc+); + background: -moz-linear-gradient(top, #00593d 0%, #007a54 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#00593d), color-stop(100%,#007a54)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #00593d 0%,#007a54 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #00593d 0%,#007a54 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #00593d 0%,#007a54 100%); /* IE10+ */ + background: linear-gradient(to bottom, #00593d 0%,#007a54 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00593d', endColorstr='#007a54',GradientType=0 ); /* IE6-8 */ + padding-top: 20px; + text-align: center; + font-weight:bold; +} + +#scko-scan-input-text { + font-size: 16px; + background: none repeat scroll 0 0 #252525; + color: white; + padding: 10px; +} + +#scko-welcome-message { + font-size: 16px; + background: none repeat scroll 0 0 #252525; + color: white; + padding: 10px; +} + +#scko-circ-info-div fieldset { + margin: 15px 10px 15px 10px; + padding: 8px; + background: #ffffff; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9Ijk4JSIgc3RvcC1jb2xvcj0iI2UwZTBlMCIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ1cmwoI2dyYWQtdWNnZy1nZW5lcmF0ZWQpIiAvPgo8L3N2Zz4=); + background: -moz-linear-gradient(top, #ffffff 0%, #e0e0e0 98%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#ffffff), color-stop(98%,#e0e0e0)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* IE10+ */ + background: linear-gradient(to bottom, #ffffff 0%,#e0e0e0 98%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#e0e0e0',GradientType=0 ); /* IE6-8 */ + border: 1px solid #DDDDDD; + color: #333333; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +#scko-circ-info-div fieldset legend { + font-size: 13px; + color: #00593d; + background-color: white; + font-weight: bold; +} + +.scko-button { + color: #FBF9F9; + font-weight: bold; + letter-spacing: 1px; + font-size: 94%; + text-shadow: 1px 1px 1px #555555; + cursor: pointer !important; + border-radius: 12px; + border: 1px solid #007a54; + background: linear-gradient(#007a54, #00593d); + background: -moz-linear-gradient(#007a54, #00593d); + background: -o-linear-gradient(#007a54, #00593d); + background: -webkit-linear-gradient(#007a54, #00593d); + background-color: #00593d; + padding: 5px 10px 6px; + outline: 0 none; + text-decoration: none; +} + + +.oils-selfck-item-table { + width: 98%; + margin-top: 15px; +} + +.oils-selfck-item-table td { + text-align: left; + padding: 10px; +} + +.oils-selfck-item-table thead { + font-weight: bold; + color: #00593D; +} + +.oils-selfck-item-table tbody tr { + border-bottom: 1px solid #888; +} + +.scko-page { + margin-top: 20px; + background: #ffffff; /* Old browsers */ + /* IE9 SVG, needs conditional override of 'filter' to 'none' */ + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDEgMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJncmFkLXVjZ2ctZ2VuZXJhdGVkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjAlIiB5MT0iMCUiIHgyPSIwJSIgeTI9IjEwMCUiPgogICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2ZmZmZmZiIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgICA8c3RvcCBvZmZzZXQ9Ijk4JSIgc3RvcC1jb2xvcj0iI2UwZTBlMCIgc3RvcC1vcGFjaXR5PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ1cmwoI2dyYWQtdWNnZy1nZW5lcmF0ZWQpIiAvPgo8L3N2Zz4=); + background: -moz-linear-gradient(top, #ffffff 0%, #e0e0e0 98%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#ffffff), color-stop(98%,#e0e0e0)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#e0e0e0 98%); /* IE10+ */ + background: linear-gradient(to bottom, #ffffff 0%,#e0e0e0 98%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#e0e0e0',GradientType=0 ); /* IE6-8 */ + border: 1px solid #DDDDDD; + color: #333333; + -webkit-border-radius: 5px; + border-radius: 5px; + padding: 3px; +} + +.scko-status-row { + font-size: 20px; +} diff --git a/Open-ILS/src/eg2/src/app/scko/scko.component.html b/Open-ILS/src/eg2/src/app/scko/scko.component.html new file mode 100644 index 0000000000..1ae367dffe --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/scko.component.html @@ -0,0 +1,108 @@ + + + +
+
+
{{scko.statusDisplayText}}
+
+
+ +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +No more renewals allowed for item {{barcode}} + + + + + + Item {{barcode}} was not found in the system. Try re-scanning the item. + + + + + + Item {{barcode}} is not allowed to circulate + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/scko/scko.component.ts b/Open-ILS/src/eg2/src/app/scko/scko.component.ts new file mode 100644 index 0000000000..1cea153b4e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/scko.component.ts @@ -0,0 +1,43 @@ +import {Component, OnInit, AfterViewInit, ViewChild, ViewEncapsulation} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +import {AuthService} from '@eg/core/auth.service'; +import {NetService} from '@eg/core/net.service'; +import {SckoService} from './scko.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; +import {AlertDialogComponent} from '@eg/share/dialog/alert.component'; + +@Component({ + templateUrl: 'scko.component.html', + styleUrls: ['scko.component.css'], + encapsulation: ViewEncapsulation.None +}) + +export class SckoComponent implements OnInit, AfterViewInit { + + @ViewChild('logoutDialog') logoutDialog: ConfirmDialogComponent; + @ViewChild('alertDialog') alertDialog: ConfirmDialogComponent; + + constructor( + private router: Router, + private route: ActivatedRoute, + private net: NetService, + private auth: AuthService, + public scko: SckoService + ) {} + + ngOnInit() { + this.net.authExpired$.subscribe(how => { + console.debug('SCKO auth expired with info', how); + this.scko.logoutStaff(); + }); + + this.scko.load(); + } + + ngAfterViewInit() { + this.scko.logoutDialog = this.logoutDialog; + this.scko.alertDialog = this.alertDialog; + } +} + diff --git a/Open-ILS/src/eg2/src/app/scko/scko.module.ts b/Open-ILS/src/eg2/src/app/scko/scko.module.ts new file mode 100644 index 0000000000..da4f188a14 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/scko.module.ts @@ -0,0 +1,41 @@ +import {NgModule} from '@angular/core'; +import {EgCommonModule} from '@eg/common.module'; +import {CommonWidgetsModule} from '@eg/share/common-widgets.module'; +import {AudioService} from '@eg/share/util/audio.service'; +import {TitleComponent} from '@eg/share/title/title.component'; +import {PatronModule} from '@eg/staff/share/patron/patron.module'; + +import {SckoComponent} from './scko.component'; +import {SckoRoutingModule} from './routing.module'; +import {SckoService} from './scko.service'; +import {SckoBannerComponent} from './banner.component'; +import {SckoSummaryComponent} from './summary.component'; +import {SckoCheckoutComponent} from './checkout.component'; +import {SckoItemsComponent} from './items.component'; +import {SckoHoldsComponent} from './holds.component'; +import {SckoFinesComponent} from './fines.component'; + +@NgModule({ + declarations: [ + SckoComponent, + SckoBannerComponent, + SckoSummaryComponent, + SckoCheckoutComponent, + SckoItemsComponent, + SckoHoldsComponent, + SckoFinesComponent + ], + imports: [ + EgCommonModule, + CommonWidgetsModule, + PatronModule, + SckoRoutingModule + ], + providers: [ + SckoService, + AudioService + ] +}) + +export class SckoModule {} + diff --git a/Open-ILS/src/eg2/src/app/scko/scko.service.ts b/Open-ILS/src/eg2/src/app/scko/scko.service.ts new file mode 100644 index 0000000000..501e094cde --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/scko.service.ts @@ -0,0 +1,693 @@ +import {Injectable, EventEmitter} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from '@angular/router'; +import {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'; + +interface CheckoutContext { + barcode: string; // item + result: any; + firstEvent: EgEvent; + payload: any; + override: boolean; + redo: boolean; + renew: boolean; + displayText: string; // string key + alertSound: string; + shouldPopup: boolean; + previousCirc?: IdlObject; + renewalFailure?: boolean; +} + +interface SessionCheckout { + circ: IdlObject; + ctx: CheckoutContext; +} + +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 = new EventEmitter(); + patronLoaded: EventEmitter = new EventEmitter(); + + 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(['/scko']); + } + + resetPatron() { + this.statusDisplayText = ''; + this.patronSummary = null; + this.sessionCheckouts = []; + } + + load(): Promise { + 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); + } + }); + } + + getFleshedCircs(circIds: number[]): Observable { + 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 { + return this.getFleshedCircs([circId]).toPromise(); + } + + loadPatron(username: string, password?: string): Promise { + 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 { + + 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(['/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(['/scko']); + }, + this.logoutWarningTimeout * 1000 + ); + } + + sessionTotalCheckouts(): number { + return this.sessionCheckouts.length; + } + + accountTotalCheckouts(): number { + // stats.checkouts.total_out includes claims returned + + return this.sessionTotalCheckouts() + + this.patronSummary.stats.checkouts.out + + this.patronSummary.stats.checkouts.overdue + + this.patronSummary.stats.checkouts.long_overdue; + } + + + checkout(barcode: string, override?: boolean): Promise { + 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(['/scko'])); + } + + renew(barcode: string, override?: boolean): Promise { + + 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'); + + }).then(ctx => { + console.debug('handleCheckoutResult returned', ctx); + + if (ctx.override) { + return this.renew(barcode, true); + } + }); + } + + notifyPatron(ctx: CheckoutContext) { + console.debug('notifyPatron(): ', ctx); + + this.statusDisplayText = ''; + + this.statusDisplaySuccess = !ctx.shouldPopup; + + this.focusBarcode.emit(); + + // TODO on success tell the summary to update its numbers. + + if (this.alertAudio && ctx.alertSound) { + this.audio.play(ctx.alertSound); + } + + if (!ctx.displayText) { return; } + + this.strings.interpolate(ctx.displayText, {barcode: ctx.barcode}) + .then(str => { + this.statusDisplayText = str; + + if (this.alertPopup && ctx.shouldPopup && str) { + this.alertDialog.dialogBody = str; + this.alertDialog.open().toPromise(); + } + }); + } + + handleCheckoutResult( + result: any, barcode: string, action: string): Promise { + + 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: CheckoutContext = { + result: result, + firstEvent: evt, + payload: payload, + barcode: barcode, + displayText: 'scko.unknown', + alertSound: '', + shouldPopup: false, + redo: false, + override: false, + renew: false + }; + + if (evt.textcode === 'SUCCESS') { + ctx.displayText = `scko.${action}.success`; + ctx.alertSound = `success.scko.${action}`; + + return this.getFleshedCirc(payload.circ.id()).then( + 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: CheckoutContext): Promise { + + 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: CheckoutContext): Promise { + 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 { + + 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 { + + 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(['/scko']); + }); + } + + emailReceipt(): Promise { + + 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 { + + 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'); + } + +} + + + diff --git a/Open-ILS/src/eg2/src/app/scko/summary.component.html b/Open-ILS/src/eg2/src/app/scko/summary.component.html new file mode 100644 index 0000000000..f7329a04e3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/summary.component.html @@ -0,0 +1,91 @@ + +
+ + + +
+ Receipt: + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Items Checked Out +
+ Total items this session: + {{scko.sessionTotalCheckouts()}} +
+
+ Total items on account: + {{scko.accountTotalCheckouts()}} +
+ +
+
+ Holds +
+ You have + {{scko.patronSummary.stats.holds.ready}} + item(s) ready for pickup. +
+
+ You have + {{scko.patronSummary.stats.holds.total}} + total holds. +
+ +
+
+ Fines +
+ Total fines on account: + + {{scko.patronSummary.stats.fines.balance_owed | currency}} + +
+ +
+
+ + diff --git a/Open-ILS/src/eg2/src/app/scko/summary.component.ts b/Open-ILS/src/eg2/src/app/scko/summary.component.ts new file mode 100644 index 0000000000..454cabe782 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/scko/summary.component.ts @@ -0,0 +1,53 @@ +import {Component, OnInit, 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} from './scko.service'; +import {OrgService} from '@eg/core/org.service'; +import {EventService, EgEvent} from '@eg/core/event.service'; + +@Component({ + selector: 'eg-scko-summary', + templateUrl: 'summary.component.html' +}) + +export class SckoSummaryComponent implements OnInit { + + showEmailOption = false; + receiptType = 'email'; + + constructor( + public scko: SckoService + ) {} + + ngOnInit() { + this.scko.patronLoaded.subscribe(() => { + if (this.canEmail()) { + this.showEmailOption = true; + this.receiptType = 'email'; + } else { + this.showEmailOption = false; + this.receiptType = 'print'; + } + }); + } + + canEmail(): boolean { + if (!this.scko.patronSummary) { return false; } + + const patron = this.scko.patronSummary.patron; + + const setting = patron.settings().filter( + s => s.name() === 'circ.send_email_checkout_receipts')[0]; + + return ( + Boolean(patron.email()) + && patron.email().match(/.*@.*/) !== null + && setting + && setting.value() === 'true' // JSON + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html index 05cf562123..c82440162e 100644 --- a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html +++ b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.html @@ -15,9 +15,13 @@ diff --git a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts index f195f32094..3b6be8b208 100644 --- a/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts +++ b/Open-ILS/src/eg2/src/app/share/dialog/confirm.component.ts @@ -13,6 +13,9 @@ export class ConfirmDialogComponent extends DialogComponent { // What question are we asking? @Input() public dialogBody: string; @Input() public dialogBodyTemplate: TemplateRef; + + @Input() confirmButtonText: string; + @Input() cancelButtonText: string; } diff --git a/Open-ILS/src/eg2/src/app/share/print/print.service.ts b/Open-ILS/src/eg2/src/app/share/print/print.service.ts index 5723a4cfd9..de6682f0b4 100644 --- a/Open-ILS/src/eg2/src/app/share/print/print.service.ts +++ b/Open-ILS/src/eg2/src/app/share/print/print.service.ts @@ -99,6 +99,9 @@ export class PrintService { } else if (this.status === 404) { console.error('No active template found: ', printReq); reject({notFound: true}); + } else { + console.error( + 'Print template generator returned status: ' + this.status); } reject({}); } diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm index dab85c0a19..aff703552f 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/CStoreEditor.pm @@ -57,6 +57,10 @@ sub personality { # Instance-specific personality if ($app) { + # Reset everything in case this editor instance was + # previously used as a different personality. + delete $self->{session}; + $self->{app} = $app; $self->{personality} = $app; init(); } diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm index d678fb3962..099cb4bbb0 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/WWW/PrintTemplate.pm @@ -52,9 +52,10 @@ sub handler { # Requires staff login return Apache2::Const::FORBIDDEN - unless $e->checkauth && $e->requestor->wsid; + unless $e->checkauth && $e->allowed('STAFF_LOGIN'); # Let pcrud handle the authz + #$e->{app} = 'open-ils.pcrud'; $e->personality('open-ils.pcrud'); my $tmpl_owner = $cgi->param('template_owner') || $e->requestor->ws_ou; @@ -77,7 +78,18 @@ sub handler { return Apache2::Const::HTTP_BAD_REQUEST; } - my ($staff_org) = $U->fetch_org_unit($e->requestor->ws_ou); + my $staff_org = $e->retrieve_actor_org_unit([ + $e->requestor->ws_ou, { + flesh => 1, + flesh_fields => { + aou => [ + 'billing_address', + 'mailing_address', + 'hours_of_operation' + ] + } + } + ]); my $output = ''; my $tt = Template->new; diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 2b5fa01bec..4e7447a378 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -11266,7 +11266,16 @@ $$ SET udata = user_data.$idx -%]
  • -
    Title: [% udata.item_title %]
    + + + + + + + + + +
    Author: [% udata.item_author %]
    Pickup Location: [% udata.pickup_lib %]
    Status: @@ -23206,6 +23215,7 @@ UPDATE config.print_template SET template = $TEMPLATE$ $TEMPLATE$ WHERE name = 'renew'; +<<<<<<< HEAD INSERT into config.workstation_setting_type (name, grp, datatype, label) VALUES ( 'eg.grid.admin.local.cash_reports.desk_payments', 'gui', 'object', @@ -23222,3 +23232,225 @@ VALUES ( 'cwst', 'label' ) ); +======= + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_items_out', 'Self-Checkout Items Out', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET checkouts = template_data.checkouts; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR checkout IN checkouts %] +
    1. +
      [% checkout.title %]
      +
      Barcode: [% checkout.copy.barcode %]
      +
      Due Date: [% + date.format(helpers.format_date( + checkout.circ.due_date, staff_org_timezone), '%x %r') + %] +
      +
    2. + [% END %] +
    +
    +$TEMPLATE$ WHERE name = 'scko_items_out'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_holds', 'Self-Checkout Holds', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET holds = template_data.holds; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR hold IN holds %] +
    1. +
    Title:[% hold.title %]
    author:[% hold.author %]
    + + + + + + + + + + + + + + + + +
    Title:[% hold.title %]
    Author:[% hold.author %]
    Pickup Location:[% helpers.get_org_unit(hold.pickup_lib).name %]
    Status: + [%- IF hold.ready -%] + Ready for pickup + [% ELSE %] + #[% hold.relative_queue_position %] of [% hold.potentials %] copies. + [% END %] +
    +
  • + [% END %] + + +$TEMPLATE$ WHERE name = 'scko_holds'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_fines', 'Self-Checkout Fines', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + USE money = format('$%.2f'); + SET user = template_data.user; + SET xacts = template_data.xacts; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR xact IN xacts %] + [% NEXT IF xact.balance_owed <= 0 %] +
    1. + + + + + + + + + + + + + + + + + +
      Details:[% xact.details %]
      Total Billed:[% money(xact.total_owed) %]
      Total Paid:[% money(xact.total_paid) %]
      Balance Owed:[% money(xact.balance_owed) %]
      +
    2. + [% END %] +
    +
    +$TEMPLATE$ WHERE name = 'scko_fines'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_checkouts', 'Self-Checkout Checkouts', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET checkouts = template_data.checkouts; + SET lib = staff_org; + SET hours = lib.hours_of_operation; + SET lib_addr = staff_org.billing_address || staff_org.mailing_address; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    [% lib.name %]
    +
    [% lib_addr.street1 %] [% lib_addr.street2 %]
    +
    [% lib_addr.city %], [% lib_addr.state %] [% lib_addr.post_code %]
    +
    [% lib.phone %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR checkout IN checkouts %] +
    1. +
      [% checkout.title %]
      +
      Barcode: [% checkout.barcode %]
      + + [% IF checkout.ctx.renewalFailure %] +
      Renewal Failed
      + [% END %] + +
      Due Date: [% date.format(helpers.format_date( + checkout.circ.due_date, staff_org_timezone), '%x') %]
      +
    2. + [% END %] +
    + +
    + Library Hours + [%- + BLOCK format_time; + date.format(time _ ' 1/1/1000', format='%I:%M %p'); + END + -%] +
    + Monday + [% PROCESS format_time time = hours.dow_0_open %] + [% PROCESS format_time time = hours.dow_0_close %] +
    +
    + Tuesday + [% PROCESS format_time time = hours.dow_1_open %] + [% PROCESS format_time time = hours.dow_1_close %] +
    +
    + Wednesday + [% PROCESS format_time time = hours.dow_2_open %] + [% PROCESS format_time time = hours.dow_2_close %] +
    +
    + Thursday + [% PROCESS format_time time = hours.dow_3_open %] + [% PROCESS format_time time = hours.dow_3_close %] +
    +
    + Friday + [% PROCESS format_time time = hours.dow_4_open %] + [% PROCESS format_time time = hours.dow_4_close %] +
    +
    + Saturday + [% PROCESS format_time time = hours.dow_5_open %] + [% PROCESS format_time time = hours.dow_5_close %] +
    +
    + Sunday + [% PROCESS format_time time = hours.dow_6_open %] + [% PROCESS format_time time = hours.dow_6_close %] +
    +
    + +
    +$TEMPLATE$ WHERE name = 'scko_checkouts'; + diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.scko-angular.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.scko-angular.sql new file mode 100644 index 0000000000..fd87f29b5a --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.scko-angular.sql @@ -0,0 +1,229 @@ + +BEGIN; + +-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version); + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_items_out', 'Self-Checkout Items Out', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET checkouts = template_data.checkouts; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR checkout IN checkouts %] +
    1. +
      [% checkout.title %]
      +
      Barcode: [% checkout.copy.barcode %]
      +
      Due Date: [% + date.format(helpers.format_date( + checkout.circ.due_date, staff_org_timezone), '%x %r') + %] +
      +
    2. + [% END %] +
    +
    +$TEMPLATE$ WHERE name = 'scko_items_out'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_holds', 'Self-Checkout Holds', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET holds = template_data.holds; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR hold IN holds %] +
    1. + + + + + + + + + + + + + + + + + +
      Title:[% hold.title %]
      Author:[% hold.author %]
      Pickup Location:[% helpers.get_org_unit(hold.pickup_lib).name %]
      Status: + [%- IF hold.ready -%] + Ready for pickup + [% ELSE %] + #[% hold.relative_queue_position %] of [% hold.potentials %] copies. + [% END %] +
      +
    2. + [% END %] +
    +
    +$TEMPLATE$ WHERE name = 'scko_holds'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_fines', 'Self-Checkout Fines', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + USE money = format('$%.2f'); + SET user = template_data.user; + SET xacts = template_data.xacts; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR xact IN xacts %] + [% NEXT IF xact.balance_owed <= 0 %] +
    1. + + + + + + + + + + + + + + + + + +
      Details:[% xact.details %]
      Total Billed:[% money(xact.total_owed) %]
      Total Paid:[% money(xact.total_paid) %]
      Balance Owed:[% money(xact.balance_owed) %]
      +
    2. + [% END %] +
    +
    +$TEMPLATE$ WHERE name = 'scko_fines'; + +INSERT INTO config.print_template + (name, label, owner, active, locale, content_type, template) +VALUES ('scko_checkouts', 'Self-Checkout Checkouts', 1, TRUE, 'en-US', 'text/html', ''); + +UPDATE config.print_template SET template = $TEMPLATE$ +[%- + USE date; + SET user = template_data.user; + SET checkouts = template_data.checkouts; + SET lib = staff_org; + SET hours = lib.hours_of_operation; + SET lib_addr = staff_org.billing_address || staff_org.mailing_address; +-%] +
    + +
    [% date.format(date.now, '%x %r') %]
    +
    [% lib.name %]
    +
    [% lib_addr.street1 %] [% lib_addr.street2 %]
    +
    [% lib_addr.city %], [% lib_addr.state %] [% lib_addr.post_code %]
    +
    [% lib.phone %]
    +
    + + [% user.pref_family_name || user.family_name %], + [% user.pref_first_given_name || user.first_given_name %] + +
      + [% FOR checkout IN checkouts %] +
    1. +
      [% checkout.title %]
      +
      Barcode: [% checkout.barcode %]
      + + [% IF checkout.ctx.renewalFailure %] +
      Renewal Failed
      + [% END %] + +
      Due Date: [% date.format(helpers.format_date( + checkout.circ.due_date, staff_org_timezone), '%x') %]
      +
    2. + [% END %] +
    + +
    + Library Hours + [%- + BLOCK format_time; + date.format(time _ ' 1/1/1000', format='%I:%M %p'); + END + -%] +
    + Monday + [% PROCESS format_time time = hours.dow_0_open %] + [% PROCESS format_time time = hours.dow_0_close %] +
    +
    + Tuesday + [% PROCESS format_time time = hours.dow_1_open %] + [% PROCESS format_time time = hours.dow_1_close %] +
    +
    + Wednesday + [% PROCESS format_time time = hours.dow_2_open %] + [% PROCESS format_time time = hours.dow_2_close %] +
    +
    + Thursday + [% PROCESS format_time time = hours.dow_3_open %] + [% PROCESS format_time time = hours.dow_3_close %] +
    +
    + Friday + [% PROCESS format_time time = hours.dow_4_open %] + [% PROCESS format_time time = hours.dow_4_close %] +
    +
    + Saturday + [% PROCESS format_time time = hours.dow_5_open %] + [% PROCESS format_time time = hours.dow_5_close %] +
    +
    + Sunday + [% PROCESS format_time time = hours.dow_6_open %] + [% PROCESS format_time time = hours.dow_6_close %] +
    +
    + +
    +$TEMPLATE$ WHERE name = 'scko_checkouts'; + + +COMMIT; + + diff --git a/Open-ILS/web/audio/notifications/error/scko/error.wav b/Open-ILS/web/audio/notifications/error/scko/error.wav new file mode 100644 index 0000000000..76c4ecfb45 Binary files /dev/null and b/Open-ILS/web/audio/notifications/error/scko/error.wav differ diff --git a/Open-ILS/web/audio/notifications/success/scko/success.wav b/Open-ILS/web/audio/notifications/success/scko/success.wav new file mode 100644 index 0000000000..eb83e16393 Binary files /dev/null and b/Open-ILS/web/audio/notifications/success/scko/success.wav differ