--- /dev/null
+
+.badge {
+ font-size: 110%;
+}
--- /dev/null
+<eg-staff-banner i18n-bannerText bannerText="Renew Items">
+</eg-staff-banner>
+
+<eg-circ-components></eg-circ-components>
+<eg-progress-dialog #progressDialog></eg-progress-dialog>
+<eg-barcode-select #barcodeSelect></eg-barcode-select>
+<eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog>
+<eg-cancel-transit-dialog #cancelTransitDialog></eg-cancel-transit-dialog>
+<eg-worklog-strings-components></eg-worklog-strings-components>
+<eg-string #itemNeverCircedStr i18n-text
+ text="Item '{{itemNeverCirced}}' has never circulated."></eg-string>
+
+<div class="row mb-3 pb-3 border-bottom">
+ <div class="col-lg-12 d-flex">
+ <div class="form-inline">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text" i18n>Barcode</span>
+ </div>
+ <input type="text" class="form-control" id="barcode-input"
+ placeholder="Barcode..." i18n-placeholder [(ngModel)]="barcode"
+ i18n-aria-label aria-label="Barcode Input" (keydown.enter)="renew()" />
+ <div class="input-group-append">
+ <button class="btn btn-outline-dark" (keydown.enter)="renew()"
+ (click)="renew()" i18n>Submit</button>
+ </div>
+ </div>
+ </div>
+ <div class="flex-1"></div>
+ <div class="mr-2">
+ <div class="form-inline">
+ <div class="form-check form-check-inline">
+ <input class="form-check-input" type="checkbox" id="use-date-cbox"
+ [(ngModel)]="useDueDate"/>
+ <label class="form-check-label"
+ for="use-date-cbox" i18n>Specific Due Date</label>
+ </div>
+ <eg-date-select [initialIso]="dueDate"
+ (onChangeAsIso)="dueDate = $event"></eg-date-select>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- doc_id below because checkin returns an MVR -->
+<ng-template #titleTemplate let-r="row">
+ <ng-container *ngIf="r.record">
+ <a routerLink="/staff/catalog/record/{{r.record.doc_id()}}">{{r.title}}</a>
+ </ng-container>
+ <ng-container *ngIf="!r.record">{{r.title}}</ng-container>
+</ng-template>
+
+<ng-template #barcodeTemplate let-r="row">
+ <ng-container *ngIf="r.copy">
+ <a href="/eg/staff/cat/item/{{r.copy.id()}}">{{r.copy.barcode()}}</a>
+ </ng-container>
+</ng-template>
+
+<div class="row">
+ <div class="col-lg-12">
+ <eg-grid #grid [dataSource]="gridDataSource" [sortable]="true"
+ [useLocalSort]="true" [cellTextGenerator]="cellTextGenerator"
+ [disablePaging]="true" persistKey="circ.renew">
+
+ <eg-grid-toolbar-action
+ group="Mark" i18n-group i18n-label label="Mark Item Damaged"
+ (onClick)="markDamaged($event)"></eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Edit" i18n-label label="Manage Item Alerts"
+ [disabled]="grid.context.rowSelector.selected().length !== 1"
+ (onClick)="manageItemAlerts($event)">
+ </eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Add" i18n-label label="Add Item Alerts"
+ (onClick)="addItemAlerts($event)">
+ </eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Circulation" i18n-label label="Cancel Transits"
+ (onClick)="cancelTransits($event)">
+ </eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Show" i18n-label
+ label="Retrieve Last Patron Who Circulated Item"
+ [disabled]="grid.context.rowSelector.selected().length !== 1"
+ (onClick)="retrieveLastPatron($event)">
+ </eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ i18n-group group="Show" i18n-label
+ label="Show Last Few Circs"
+ [disabled]="grid.context.rowSelector.selected().length !== 1"
+ (onClick)="showRecentCircs($event)">
+ </eg-grid-toolbar-action>
+
+ <!-- COLUMNS -->
+
+ <eg-grid-column path="index" [index]="true"
+ label="Row Index" i18n-label [hidden]="true"></eg-grid-column>
+
+ <eg-grid-column path="mbts.balance_owed" label="Balance Owed"
+ datatype="money" i18n-label></eg-grid-column>
+
+ <eg-grid-column path="copy.barcode" label="Barcode"
+ i18n-label [cellTemplate]="barcodeTemplate"></eg-grid-column>
+
+ <eg-grid-column path="circ.id" label="Bill #" i18n-label>
+ </eg-grid-column>
+
+ <eg-grid-column path="circ.checkin_time" label="Checkin Date" i18n-label
+ datatype="timestamp" [datePlusTime]="true"></eg-grid-column>
+
+ <eg-grid-column path="patron.family_name" label="Family Name" i18n-label>
+ </eg-grid-column>
+
+ <eg-grid-column path="circ.xact_finish" label="Finish" i18n-label
+ datatype="timestamp" [datePlusTime]="true"></eg-grid-column>
+
+ <eg-grid-column path="copy.location.name" label="Location" i18n-label>
+ </eg-grid-column>
+
+ <eg-grid-column name="routeTo" label="Route To" i18n-label>
+ </eg-grid-column>
+
+ <eg-grid-column path="circ.xact_start" label="Start" i18n-label
+ datatype="timestamp" [datePlusTime]="true"></eg-grid-column>
+
+ <eg-grid-column path="title" label="Title" i18n-label
+ [cellTemplate]="titleTemplate"></eg-grid-column>
+
+ <eg-grid-column path="copy.circ_modifier"
+ label="Circulation Modifier" i18n-label></eg-grid-column>
+
+ <eg-grid-column path="copy.circ_lib.shortname"
+ label="Circulation Library" i18n-label></eg-grid-column>
+
+ <eg-grid-column path="copy.status.name"
+ label="Item Status" i18n-label></eg-grid-column>
+
+ </eg-grid>
+ </div>
+</div>
+
+<div class="row mt-3 pt-3">
+ <div class="col-lg-12 d-flex">
+ <div class="flex-1"></div>
+ <div class="mr-3">
+ <button class="btn btn-outline-dark"
+ (click)="printReceipt()" i18n>Print Receipt</button>
+ </div>
+ <div class="mr-3">
+ <div class="form-check form-check-inline">
+ <input class="form-check-input" type="checkbox"
+ id="trim-list-cbox" [(ngModel)]="trimList"/>
+ <label class="form-check-label"
+ for="trim-list-cbox" i18n>Trim List (20)</label>
+ </div>
+ </div>
+ <div class="mr-3">
+ <div class="form-check form-check-inline">
+ <input class="form-check-input" type="checkbox"
+ (ngModelChange)="toggleStrictBarcode($event)"
+ id="strict-barcode-cbox" [(ngModel)]="strictBarcode"/>
+ <label class="form-check-label"
+ for="strict-barcode-cbox" i18n>Strict Barcode</label>
+ </div>
+ </div>
+ </div>
+</div>
+
+
--- /dev/null
+import {Component, ViewChild, OnInit, AfterViewInit, HostListener} from '@angular/core';
+import {Location} from '@angular/common';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {empty, from} from 'rxjs';
+import {concatMap} from 'rxjs/operators';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
+import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {Pager} from '@eg/share/util/pager';
+import {CircService, CircDisplayInfo, CheckoutParams, CheckoutResult
+ } from '@eg/staff/share/circ/circ.service';
+import {BarcodeSelectComponent
+ } from '@eg/staff/share/barcodes/barcode-select.component';
+import {PrintService} from '@eg/share/print/print.service';
+import {MarkDamagedDialogComponent
+ } from '@eg/staff/share/holdings/mark-damaged-dialog.component';
+import {CopyAlertsDialogComponent
+ } from '@eg/staff/share/holdings/copy-alerts-dialog.component';
+import {BucketDialogComponent
+ } from '@eg/staff/share/buckets/bucket-dialog.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {CancelTransitDialogComponent
+ } from '@eg/staff/share/circ/cancel-transit-dialog.component';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+
+
+interface RenewGridEntry extends CheckoutResult {
+ // May need to extend...
+ foo?: number; // Empty interfaces are not allowed.
+}
+
+const TRIM_LIST_TO = 20;
+
+@Component({
+ templateUrl: 'renew.component.html',
+ styleUrls: ['renew.component.css']
+})
+export class RenewComponent implements OnInit, AfterViewInit {
+ renewals: RenewGridEntry[] = [];
+ autoIndex = 0;
+
+ barcode: string;
+ dueDate: string;
+ useDueDate = false;
+ fineTally = 0;
+ strictBarcode = false;
+ trimList = false;
+ itemNeverCirced: string;
+
+ gridDataSource: GridDataSource = new GridDataSource();
+ cellTextGenerator: GridCellTextGenerator;
+
+ private copiesInFlight: {[barcode: string]: boolean} = {};
+
+ @ViewChild('grid') private grid: GridComponent;
+ @ViewChild('barcodeSelect') private barcodeSelect: BarcodeSelectComponent;
+ @ViewChild('markDamagedDialog') private markDamagedDialog: MarkDamagedDialogComponent;
+ @ViewChild('copyAlertsDialog') private copyAlertsDialog: CopyAlertsDialogComponent;
+ @ViewChild('itemNeverCircedStr') private itemNeverCircedStr: StringComponent;
+ @ViewChild('cancelTransitDialog') private cancelTransitDialog: CancelTransitDialogComponent;
+
+ constructor(
+ private router: Router,
+ private route: ActivatedRoute,
+ private ngLocation: Location,
+ private net: NetService,
+ private org: OrgService,
+ private auth: AuthService,
+ private store: ServerStoreService,
+ private circ: CircService,
+ private toast: ToastService,
+ private printer: PrintService,
+ private holdings: HoldingsService,
+ private anonCache: AnonCacheService,
+ public patronService: PatronService
+ ) {}
+
+ ngOnInit() {
+
+ this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+ return from(this.renewals);
+ };
+
+ this.store.getItemBatch(['circ.renew.strict_barcode'])
+ .then(sets => {
+ this.strictBarcode = sets['circ.renew.strict_barcode'];
+ }).then(_ => this.circ.applySettings());
+ }
+
+ ngAfterViewInit() {
+ this.focusInput();
+ }
+
+ focusInput() {
+ const input = document.getElementById('barcode-input');
+ if (input) { input.focus(); }
+ }
+
+ renew(params?: CheckoutParams, override?: boolean): Promise<CheckoutResult> {
+ if (!this.barcode) { return Promise.resolve(null); }
+
+ const promise = params ? Promise.resolve(params) : this.collectParams();
+
+ return promise.then((collectedParams: CheckoutParams) => {
+ if (!collectedParams) { return null; }
+
+ if (this.copiesInFlight[this.barcode]) {
+ console.debug('Item ' + this.barcode + ' is already mid-renewal');
+ return null;
+ }
+
+ this.copiesInFlight[this.barcode] = true;
+ return this.circ.renew(collectedParams);
+ })
+
+ .then((result: CheckoutResult) => {
+ if (result && result.success) {
+ this.gridifyResult(result);
+ }
+ delete this.copiesInFlight[this.barcode];
+ this.resetForm();
+ return result;
+ })
+
+ .finally(() => delete this.copiesInFlight[this.barcode]);
+ }
+
+ collectParams(): Promise<CheckoutParams> {
+
+ const params: CheckoutParams = {
+ copy_barcode: this.barcode,
+ due_date: this.useDueDate ? this.dueDate : null,
+ _renewal: true
+ };
+
+ return this.barcodeSelect.getBarcode('asset', this.barcode)
+ .then(selection => {
+ if (selection) {
+ params.copy_id = selection.id;
+ params.copy_barcode = selection.barcode;
+ return params;
+ } else {
+ // User canceled the multi-match selection dialog.
+ return null;
+ }
+ });
+ }
+
+ resetForm() {
+ this.barcode = '';
+ this.focusInput();
+ }
+
+ gridifyResult(result: CheckoutResult) {
+ const entry: RenewGridEntry = result;
+ entry.index = this.autoIndex++;
+
+ if (result.copy) {
+ result.copy.circ_lib(this.org.get(result.copy.circ_lib()));
+ }
+
+ if (result.mbts) {
+ this.fineTally =
+ ((this.fineTally * 100) + (result.mbts.balance_owed() * 100)) / 100;
+ }
+
+ this.renewals.unshift(entry);
+
+ if (this.trimList && this.renewals.length >= TRIM_LIST_TO) {
+ this.renewals.length = TRIM_LIST_TO;
+ }
+ this.grid.reload();
+ }
+
+ toggleStrictBarcode(active: boolean) {
+ if (active) {
+ this.store.setItem('circ.checkin.strict_barcode', true);
+ } else {
+ this.store.removeItem('circ.checkin.strict_barcode');
+ }
+ }
+
+ printReceipt() {
+ if (this.renewals.length === 0) { return; }
+
+ this.printer.print({
+ printContext: 'default',
+ templateName: 'renew',
+ contextData: {renewals: this.renewals}
+ });
+ }
+
+ getCopyIds(rows: RenewGridEntry[], skipStatus?: number): number[] {
+ return this.getCopies(rows, skipStatus).map(c => Number(c.id()));
+ }
+
+ getCopies(rows: RenewGridEntry[], skipStatus?: number): IdlObject[] {
+ let copies = rows.filter(r => r.copy).map(r => r.copy);
+ if (skipStatus) {
+ copies = copies.filter(
+ c => Number(c.status().id()) !== Number(skipStatus));
+ }
+ return copies;
+ }
+
+
+ markDamaged(rows: RenewGridEntry[]) {
+ const copyIds = this.getCopyIds(rows, 14 /* ignore damaged */);
+ if (copyIds.length === 0) { return; }
+
+ from(copyIds).pipe(concatMap(id => {
+ this.markDamagedDialog.copyId = id;
+ return this.markDamagedDialog.open({size: 'lg'});
+ }));
+ }
+
+ addItemAlerts(rows: RenewGridEntry[]) {
+ const copyIds = this.getCopyIds(rows);
+ if (copyIds.length === 0) { return; }
+
+ this.copyAlertsDialog.copyIds = copyIds;
+ this.copyAlertsDialog.mode = 'create';
+ this.copyAlertsDialog.open({size: 'lg'}).subscribe();
+ }
+
+ manageItemAlerts(rows: RenewGridEntry[]) {
+ const copyIds = this.getCopyIds(rows);
+ if (copyIds.length === 0) { return; }
+
+ this.copyAlertsDialog.copyIds = copyIds;
+ this.copyAlertsDialog.mode = 'manage';
+ this.copyAlertsDialog.open({size: 'lg'}).subscribe();
+ }
+
+ retrieveLastPatron(rows: RenewGridEntry[]) {
+ const copy = this.getCopies(rows).pop();
+ if (!copy) { return; }
+
+ this.circ.lastCopyCirc(copy.id()).then(circ => {
+ if (circ) {
+ this.router.navigate(['/staff/circ/patron', circ.usr(), 'checkout']);
+ } else {
+ this.itemNeverCirced = copy.barcode();
+ setTimeout(() => this.toast.danger(this.itemNeverCircedStr.text));
+ }
+ });
+ }
+
+ cancelTransits(rows: RenewGridEntry[]) {
+
+ rows = rows.filter(row => row.copy && row.copy.status().id() === 6);
+
+ // Copies in transit are not always accompanied by their transit.
+ from(rows).pipe(concatMap(row => {
+ return from(
+ this.circ.findCopyTransit(row)
+ .then(transit => row.transit = transit)
+ );
+ }))
+ .pipe(concatMap(_ => {
+
+ const ids = rows
+ .filter(row => Boolean(row.transit))
+ .map(row => row.transit.id());
+
+ if (ids.length > 0) {
+ this.cancelTransitDialog.transitIds = ids;
+ return this.cancelTransitDialog.open();
+ } else {
+ return empty();
+ }
+
+ })).subscribe();
+ }
+
+ showRecentCircs(rows: RenewGridEntry[]) {
+ const copyId = this.getCopyIds(rows)[0];
+ if (copyId) {
+ const url = `/eg/staff/cat/item/${copyId}/circs`;
+ window.open(url);
+ }
+ }
+}
+
--- /dev/null
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {RenewRoutingModule} from './routing.module';
+import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
+import {HoldsModule} from '@eg/staff/share/holds/holds.module';
+import {BillingModule} from '@eg/staff/share/billing/billing.module';
+import {CircModule} from '@eg/staff/share/circ/circ.module';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
+import {BookingModule} from '@eg/staff/share/booking/booking.module';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
+import {BarcodesModule} from '@eg/staff/share/barcodes/barcodes.module';
+import {RenewComponent} from './renew.component';
+import {WorkLogModule} from '@eg/staff/share/worklog/worklog.module';
+
+@NgModule({
+ declarations: [
+ RenewComponent
+ ],
+ imports: [
+ StaffCommonModule,
+ RenewRoutingModule,
+ FmRecordEditorModule,
+ BillingModule,
+ CircModule,
+ HoldsModule,
+ HoldingsModule,
+ BookingModule,
+ PatronModule,
+ BarcodesModule,
+ WorkLogModule
+ ],
+ providers: [
+ ]
+})
+
+export class RenewModule {}
+
--- /dev/null
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {RenewComponent} from './renew.component';
+
+const routes: Routes = [{
+ path: '',
+ component: RenewComponent
+}];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+
+export class RenewRoutingModule {}
path: 'checkin',
loadChildren: () =>
import('./checkin/checkin.module').then(m => m.CheckinModule)
+}, {
+ path: 'renew',
+ loadChildren: () =>
+ import('./renew/renew.module').then(m => m.RenewModule)
}];
@NgModule({
<span class="material-icons" aria-hidden="true">autorenew</span>
<span i18n>Renew Items</span>
</a>
+ <a class="dropdown-item" routerLink="/staff/circ/renew">
+ <span class="material-icons" aria-hidden="true">autorenew</span>
+ <span i18n>Renew Items (Experimental)</span>
+ </a>
<a class="dropdown-item" href="/eg/staff/circ/patron/register"
egAccessKey keyCtx="navbar" i18n-keySpec i18n-keyDesc
keySpec="shift+f1" keyDesc="Register Patron">
patron?: IdlObject;
transit?: IdlObject;
copyAlerts?: IdlObject[];
+ mbts?: IdlObject;
// Calculated values
title?: string;
export interface CheckinResult extends CircResultCommon {
params: CheckinParams;
- mbts?: IdlObject;
routeTo?: string; // org name or in-branch destination
destOrg?: IdlObject;
destAddress?: IdlObject;
result.nonCatCirc = payload.noncat_circ;
return this.fleshCommonData(result).then(_ => {
- const action = params._renewal ? 'renewal' :
+ const action = params._renewal ? 'renew' :
(params.noncat ? 'noncat_checkout' : 'checkout');
this.addWorkLog(action, result);
return result;
barcode: result.params.copy_barcode
});
+ case 'ASSET_COPY_NOT_FOUND':
+ this.audio.play(`error.${key}.not_found`);
+ return this.exitAlert({
+ textcode: result.firstEvent.textcode,
+ barcode: result.params.copy_barcode
+ });
+
default:
this.audio.play(`error.${key}.unknown`);
return this.exitAlert({
<eg-string key="staff.circ.events.PATRON_ACCOUNT_EXPIRED" i18n-text
text="This account has expired and may not circulate items."></eg-string>
+<eg-string key="staff.circ.events.ASSET_COPY_NOT_FOUND" i18n-text
+ text="Item was not found and cannot be renewed."></eg-string>
+
<ng-template #claimsReturnsTmpl>
Item "{{barcode}}" is marked as Claims Returned</ng-template>
<eg-string key="staff.circ.events.CIRC_CLAIMS_RETURNED"
record(entry: WorkLogEntry) {
+console.log('1');
if (this.maxEntries === null) {
throw new Error('WorkLogService.loadSettings() required');
return;
'Add <eg-worklog-strings-components/> to your component for worklog support');
return;
}
+console.log('1');
entry.when = new Date();
entry.actor = this.auth.user().usrname();
+ console.log(`worklog_${entry.action}`);
+ console.log(this.workLogStrings[`worklog_${entry.action}`]);
entry.msg = this.workLogStrings[`worklog_${entry.action}`].text;
+console.log('1');
const workLog: WorkLogEntry[] =
this.store.getLocalItem('eg.work_log') || [];
let patronLog: WorkLogEntry[] =
this.store.getLocalItem('eg.patron_log') || [];
+console.log('1');
workLog.push(entry);
if (workLog.length > this.maxEntries) {
workLog.shift();
}
+console.log('1');
this.store.setLocalItem('eg.work_log', workLog);
+console.log('1');
if (entry.patron_id) {
// Remove existing entries that match this patron
patronLog = patronLog.filter(e => e.patron_id !== entry.patron_id);
+console.log('1');
patronLog.push(entry);
if (patronLog.length > this.maxPatrons) {
[% l('Renew Items') %]
</a>
</li>
+ <li ng-if="username">
+ <a href="/eg2/staff/circ/renew">
+ <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span>
+ [% l('Renew Items (Experimental)') %]
+ </a>
+ </li>
+
<li ng-if="!username">
<a href="" ng-click="rs.active_tab('renew')" target="_self"
eg-accesskey="[% l('ctrl+f2') %]"