LP1904036 Bills tab
authorBill Erickson <berickxx@gmail.com>
Fri, 5 Mar 2021 21:50:59 +0000 (16:50 -0500)
committerGalen Charlton <gmc@equinoxOLI.org>
Fri, 28 Oct 2022 00:13:25 +0000 (20:13 -0400)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Open-ILS/src/eg2/src/app/staff/circ/patron/bills.component.css
Open-ILS/src/eg2/src/app/staff/circ/patron/bills.component.html
Open-ILS/src/eg2/src/app/staff/circ/patron/bills.component.ts
Open-ILS/src/eg2/src/app/staff/circ/patron/items.component.ts
Open-ILS/src/eg2/src/app/staff/share/circ/circ.service.ts
Open-ILS/src/eg2/src/app/staff/share/circ/grid.component.ts

index 9b8b24f..ce9c2ae 100644 (file)
@@ -4,3 +4,9 @@
   width: 8em;
 }
 
+.striped {
+  background-color: rgba(0,0,0,.03);
+  border-top: 1px solid rgba(0,0,0,.125);
+  border-bottom: 1px solid rgba(0,0,0,.125);
+}
+
index 5b80429..a211e96 100644 (file)
@@ -1,61 +1,65 @@
 
+<!-- SUMMARY  -->
+
 <ng-container *ngIf="summary">
-  <div class="row border-bottom pb-2 pt-2 mb-4 border border-secondary rounded">
+  <div class="row border-bottom border-secondary pb-2 pt-2 mb-4">
     <div class="col-lg-3 pr-0 mr-0 border-right">
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Total Owed:</div>
-        <div class="flex-1">{{(summary.total_paid() || 0) | currency}}</div>
+        <div class="flex-1" 
+          [ngClass]="{'font-weight-bold' : summary.balance_owed() > 0}">
+          {{summary.balance_owed() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1">
         <div class="flex-4" i18n>Total Billed:</div>
-        <div class="flex-1">{{(summary.total_owed() || 0) | currency}}</div>
+        <div class="flex-1">{{summary.total_owed() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Total Paid/Credited:</div>
-        <div class="flex-1">{{(summary.total_paid() || 0) | currency}}</div>
+        <div class="flex-1">{{summary.total_paid() | currency}}</div>
       </div>
     </div>
 
     <div class="col-lg-3 pr-0 mr-0 border-right">
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Owed for Selected:</div>
         <div class="flex-1">{{owedSelected() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1">
         <div class="flex-4" i18n>Billed for Selected:</div>
         <div class="flex-1">{{billedSelected() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Paid/Credited for Selected:</div>
         <div class="flex-1">{{paidSelected() | currency}}</div>
       </div>
     </div>
 
     <div class="col-lg-3 pr-0 mr-0 border-right">
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Refunds Available:</div>
         <div class="flex-1">{{refundsAvailable() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1">
         <div class="flex-4" i18n>Credit Available:</div>
         <div class="flex-1">{{patron().credit_forward_balance() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Session Voided:</div>
         <div class="flex-1">{{sessionVoided | currency}}</div>
       </div>
     </div>
 
     <div class="col-lg-3 pr-0 mr-0">
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4">&nbsp;</div>
         <div class="flex-1">&nbsp;</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1">
         <div class="flex-4" i18n>Pending Payment:</div>
         <div class="flex-1 font-weight-bold">{{pendingPayment() | currency}}</div>
       </div>
-      <div class="d-flex">
+      <div class="d-flex pt-1 pb-1 striped">
         <div class="flex-4" i18n>Pending Change:</div>
         <div class="flex-1 font-weight-bold">{{pendingChange() | currency}}</div>
       </div>
   </div>
 </ng-container>
 
-<div class="row bg-light border border-dark rounded pt-2 pb-2 mt-2 mb-2 payment-form">
+<!-- BILL PAY FORM -->
+
+<h3 i18n>Pay Bill</h3>
+
+<div class="row bg-light border border-dark rounded pt-2 pb-2 mt-2 mb-4 payment-form">
   <div class="col-lg-12 d-flex form-inline form-validated">
-    <div class="font-weight-bold" i18n>Pay Bill:</div>
-    <div class="ml-4"><label for="payment-type" i18n>Payment Type:</label></div>
-    <div class="ml-2">
-      <select [(ngModel)]="paymentType" class="form-control ml-2" id="payment-type">
+    <div class="flex-1"></div>
+    <div class="ml-2"><label for="payment-type" i18n>Payment Type:</label></div>
+    <div class="ml-1">
+      <select [(ngModel)]="paymentType" class="form-control" id="payment-type">
         <option value="cash_payment" i18n>Cash</option>
         <option value="check_payment" i18n>Check</option>
         <option value="credit_card_payment" i18n>Credit Card</option>
         <option value="goods_payment" i18n>Goods</option>
       </select>
     </div>
-    <div class="ml-4"><label for="check-number" i18n>Check Number:</label></div>
-    <div class="ml-2">
+    <div class="ml-2"><label for="check-number" i18n>Check Number:</label></div>
+    <div class="ml-1">
       <input type="text" class="form-control" [(ngModel)]="checkNumber"
         id="check-number" [disabled]="paymentType !== 'check_payment'"/>
     </div>
-    <div class="ml-4"><label for="pay-amount" i18n>Payment Received:</label></div>
-    <div class="ml-2">
+    <div class="ml-2"><label for="pay-amount" i18n>Payment Received:</label></div>
+    <div class="ml-1">
       <input type="number" class="form-control" [(ngModel)]="payAmount"
         id="pay-amount" [min]="0"/>
     </div>
-    <div class="ml-4 form-check form-check-inline">
+    <div class="ml-2 form-check form-check-inline">
       <input class="form-check-input" type="checkbox" id="annotate" [(ngModel)]="annotatePayment"/>
       <label class="form-check-label" for="annotate" i18n>Annotate</label>
     </div>
-    <div class="ml-4">
+    <div class="ml-2">
       <button class="btn btn-outline-dark" (click)="applyPayment()" i18n>Apply Payment</button>
     </div>
   </div>
 </div>
 
+<!-- BILLS GRID -->
+
+<ng-template #titleTemplate let-r="row">
+  <ng-container *ngIf="r.record">
+    <a routerLink="/staff/catalog/record/{{r.record.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>
+
+<ng-template #callNumberTemplate let-r="row">
+  <ng-container *ngIf="r.volume">
+    {{r.volume.prefix().label()}} {{r.volume.label()}} {{r.volume.suffix().label()}} 
+  </ng-container>
+  <ng-container *ngIf="!r.volume" i18n>UNCATALOGED</ng-container>
+</ng-template>
+
+<eg-grid #billGrid [dataSource]="gridDataSource" 
+  [sortable]="true" [useLocalSort]="true"
+  [cellTextGenerator]="cellTextGenerator">
+
+  <eg-grid-column path="xact.id" [index]="true" label="Bill #" i18n-label>
+  </eg-grid-column>
+
+  <eg-grid-column path="xact.xact_start" datatype="timestamp" [datePlusTime]="true"
+    label="Start" i18n-label></eg-grid-column>
+
+  <eg-grid-column path="xact.summary.xact_type"
+    label="Type" i18n-label></eg-grid-column>
+
+  <eg-grid-column path="xact.summary.last_billing_type"
+    label="Last Billing Type" i18n-label></eg-grid-column>
+
+  <eg-grid-column path="billingLocation"
+    label="Billing Location" i18n-label></eg-grid-column>
+    
+  <eg-grid-column path="call_number" label="Call Number" i18n-label
+    [cellTemplate]="callNumberTemplate"></eg-grid-column>
+
+  <eg-grid-column name="copy_barcode" label="Item Barcode" i18n-label
+    [cellTemplate]="barcodeTemplate"></eg-grid-column>
+
+  <eg-grid-column path="copy.location.name" label="Shelving Location" 
+    i18n-label></eg-grid-column>
+
+  <eg-grid-column name="title" label="Title" i18n-label 
+    [cellTemplate]="titleTemplate"></eg-grid-column>
+
+  <eg-grid-column path="xact.summary.balance_owed" datatype="money" 
+    label="Balance Owed" i18n-label></eg-grid-column>
+
+  <eg-grid-column path="xact.summary.total_owed" datatype="money" 
+    label="Total Billed" i18n-label></eg-grid-column>
+
+  <eg-grid-column path="xact.summary.total_paid" datatype="money" 
+    label="Total Paid" i18n-label></eg-grid-column>
+
+  <eg-grid-column name="paymentPending" datatype="money"
+    label="Payment Pending" i18n-label></eg-grid-column>
+
+  <eg-grid-column name="author" [hidden]="true" 
+    label="Author" i18n-label></eg-grid-column>
+  <eg-grid-column path="usr" [hidden]="true"></eg-grid-column>
+  <eg-grid-column path="unrecovered" [hidden]="true"></eg-grid-column>
+
+</eg-grid>
 
index 6f60f61..ba8d39c 100644 (file)
@@ -1,5 +1,7 @@
-import {Component, Input, OnInit, AfterViewInit} from '@angular/core';
+import {Component, Input, OnInit, AfterViewInit, ViewChild} from '@angular/core';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {from, empty} from 'rxjs';
+import {concatMap, tap} from 'rxjs/operators';
 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {IdlObject} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
@@ -8,6 +10,36 @@ 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 {PatronContextService} from './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} from '@eg/staff/share/circ/circ.service';
+
+interface BillGridEntry extends CircDisplayInfo {
+    xact: IdlObject // mbt
+    billingLocation?: string;
+    paymentPending?: number;
+}
+
+const XACT_FLESH_DEPTH = 5;
+const XACT_FLESH_FIELDS = {
+  mbt: ['summary', 'circulation', 'grocery'],
+  circ: ['target_copy', 'workstation', 'checkin_workstation', 'circ_lib'],
+  acp:  [
+    'call_number',
+    'holds_count',
+    'status',
+    'circ_lib',
+    'location',
+    'floating',
+    'age_protect',
+    'parts'
+  ],
+  acpm: ['part'],
+  acn:  ['record', 'owning_lib', 'prefix', 'suffix'],
+  bre:  ['wide_display_entry']
+};
+
 
 @Component({
   templateUrl: 'bills.component.html',
@@ -22,6 +54,12 @@ export class BillsComponent implements OnInit, AfterViewInit {
     paymentType = 'cash_payment';
     checkNumber: string;
     annotatePayment = false;
+    entries: BillGridEntry[];
+
+    gridDataSource: GridDataSource = new GridDataSource();
+    cellTextGenerator: GridCellTextGenerator;
+
+    @ViewChild('billGrid') private billGrid: GridComponent;
 
     constructor(
         private router: Router,
@@ -30,11 +68,31 @@ export class BillsComponent implements OnInit, AfterViewInit {
         private pcrud: PcrudService,
         private auth: AuthService,
         private store: ServerStoreService,
+        private circ: CircService,
         public patronService: PatronService,
         public context: PatronContextService
     ) {}
 
     ngOnInit() {
+
+        this.cellTextGenerator = {
+            title: row => row.title,
+            copy_barcode: row => row.copy ? row.copy.barcode() : '',
+            call_number: row => row.volume ? row.volume.label() : ''
+        };
+
+        // The grid never fetches data directly, it only serves what
+        // we have manually retrieved.
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            if (!this.entries) { return empty(); }
+
+            const page =
+                this.entries.slice(pager.offset, pager.offset + pager.limit)
+                .filter(entry => entry !== undefined);
+
+            return from(page);
+        };
+
         this.load();
     }
 
@@ -45,8 +103,53 @@ export class BillsComponent implements OnInit, AfterViewInit {
 
     load() {
 
+        const xactIds = [];
+
+
+        // TODO: run this in a single pcrud transaction
+
         this.pcrud.retrieve('mous', this.patronId, {}, {authoritative : true})
-        .subscribe(sum => this.summary = sum);
+        .pipe(tap(sum => this.summary = sum))
+        .pipe(concatMap(_ => {
+            return this.pcrud.search('mbts',
+                {usr: this.patronId, balance_owed: {'<>' : 0}},
+                {select: {mbts: ['id']}}, {authoritative : true}
+            ).pipe(tap(summary => xactIds.push(summary.id())));
+        }))
+        .pipe(concatMap(_ => {
+            this.entries = [];
+            return this.pcrud.search('mbt', {id: xactIds}, {
+                flesh: XACT_FLESH_DEPTH,
+                flesh_fields: XACT_FLESH_FIELDS,
+                order_by: {mbts : ['xact_start']},
+                select: {bre : ['id']}
+                }, {authoritative : true}
+            ).pipe(tap(xact => this.entries.push(this.formatForDisplay(xact))));
+        }))
+        .subscribe(null, null, () => this.billGrid.reload());
+    }
+
+    formatForDisplay(xact: IdlObject): BillGridEntry {
+
+        const entry: BillGridEntry = {
+            xact: xact,
+            paymentPending: 0
+        };
+
+        if (xact.summary().xact_type() !== 'circulation') {
+            entry.title = xact.summary().last_billing_type();
+            entry.billingLocation =
+                xact.grocery().billing_location().shortname();
+            return entry;
+        }
+
+        const circDisplay: CircDisplayInfo =
+            this.circ.getDisplayInfo(xact.circulation());
+
+        entry.billingLocation =
+            xact.circulation().circ_lib().shortname();
+
+        return Object.assign(entry, circDisplay);
     }
 
     patron(): IdlObject {
index d645033..791e8f2 100644 (file)
@@ -208,7 +208,10 @@ export class ItemsComponent implements OnInit, AfterViewInit {
             const entry: CircGridEntry = {
                 index: `ancc-${circ.id()}`,
                 title: circ.item_type().name(),
-                dueDate: circ.duedate()
+                dueDate: circ.duedate(),
+                copy: null,
+                author: '',
+                isbn: ''
             };
 
             this.nonCatGrid.appendGridEntry(entry);
index 8414732..931afac 100644 (file)
@@ -12,6 +12,15 @@ import {AudioService} from '@eg/share/util/audio.service';
 import {CircEventsComponent} from './events-dialog.component';
 import {CircComponentsComponent} from './components.component';
 
+export interface CircDisplayInfo {
+    title?: string;
+    author?: string;
+    isbn?: string;
+    copy?: IdlObject;        // acp
+    volume?: IdlObject;      // acn
+    record?: IdlObject;      // bre
+    display?: IdlObject;     // mwde
+}
 
 const CAN_OVERRIDE_CHECKOUT_EVENTS = [
     'PATRON_EXCEEDS_OVERDUE_COUNT',
@@ -172,6 +181,39 @@ export class CircService {
         private bib: BibRecordService,
     ) {}
 
+    // 'circ' is fleshed with copy, vol, bib, wide_display_entry
+    // Extracts some display info from a fleshed circ.
+    getDisplayInfo(circ: IdlObject): CircDisplayInfo {
+
+        const copy = circ.target_copy();
+
+        if (copy.call_number().id() === -1) { // precat
+            return {
+                title: copy.dummy_title(),
+                author: copy.dummy_author(),
+                isbn: copy.dummy_isbn(),
+                copy: copy
+            };
+        }
+
+        const volume = copy.call_number();
+        const record = volume.record();
+        const display = record.wide_display_entry();
+
+        let isbn = JSON.parse(display.isbn());
+        if (Array.isArray(isbn)) { isbn = isbn.join(','); }
+
+        return {
+            title: JSON.parse(display.title()),
+            author: JSON.parse(display.author()),
+            isbn: isbn,
+            copy: copy,
+            volume: volume,
+            record: record,
+            display: display
+        };
+    }
+
     getNonCatTypes(): Promise<IdlObject[]> {
 
         if (this.nonCatTypes) {
index 0a28134..180ff45 100644 (file)
@@ -8,7 +8,7 @@ import {NetService} from '@eg/core/net.service';
 import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {CheckoutParams, CheckoutResult, CheckinParams, CheckinResult,
-    CircService} from './circ.service';
+    CircDisplayInfo, CircService} from './circ.service';
 import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
 import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
@@ -33,15 +33,9 @@ import {ClaimsReturnedDialogComponent} from './claims-returned-dialog.component'
 import {ToastService} from '@eg/share/toast/toast.service';
 import {AddBillingDialogComponent} from './billing-dialog.component';
 
-export interface CircGridEntry {
+export interface CircGridEntry extends CircDisplayInfo {
     index: string; // class + id -- row index
-    title?: string;
-    author?: string;
-    isbn?: string;
-    copy?: IdlObject;
     circ?: IdlObject;
-    volume?: IdlObject;
-    record?: IdlObject;
     dueDate?: string;
     copyAlertCount?: number;
     nonCatCount?: number;
@@ -246,40 +240,22 @@ export class CircGridComponent implements OnInit {
 
     gridify(circ: IdlObject): CircGridEntry {
 
+        const circDisplay = this.circ.getDisplayInfo(circ);
+
         const entry: CircGridEntry = {
             index: `circ-${circ.id()}`,
             circ: circ,
             dueDate: circ.due_date(),
+            title: circDisplay.title,
+            author: circDisplay.author,
+            isbn: circDisplay.isbn,
+            copy: circDisplay.copy,
+            volume: circDisplay.volume,
+            record: circDisplay.copy,
+            display: circDisplay.display,
             copyAlertCount: 0 // TODO
         };
 
-        const copy = circ.target_copy();
-        entry.copy = copy;
-
-        // Some values have to be manually extracted / normalized
-        if (copy.call_number().id() === -1) {
-
-            entry.title = copy.dummy_title();
-            entry.author = copy.dummy_author();
-            entry.isbn = copy.dummy_isbn();
-
-        } else {
-
-            entry.volume = copy.call_number();
-            entry.record = entry.volume.record();
-
-            // display entries are JSON-encoded and some are lists
-            const display = entry.record.wide_display_entry();
-
-            entry.title = JSON.parse(display.title());
-            entry.author = JSON.parse(display.author());
-            entry.isbn = JSON.parse(display.isbn());
-
-            if (Array.isArray(entry.isbn)) {
-                entry.isbn = entry.isbn.join(',');
-            }
-        }
-
         return entry;
     }