From a7a8afb9fe6b4ef1b7a1dfa58c42dd691aba795c Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Mon, 18 Mar 2019 17:46:42 -0400 Subject: [PATCH] LP1821382 Angular holdings maintenance continued. Support for various context menu actions. Signed-off-by: Bill Erickson Signed-off-by: Dan Wells --- .../app/share/combobox/combobox.component.ts | 2 +- .../src/app/staff/catalog/catalog.module.ts | 3 +- .../catalog/record/holdings.component.css | 41 ++ .../catalog/record/holdings.component.html | 178 ++++++- .../catalog/record/holdings.component.ts | 434 +++++++++++++++--- .../catalog/record/pagination.component.ts | 46 +- .../catalog/record/record.component.html | 2 +- .../src/app/staff/catalog/resolver.service.ts | 1 - .../bib-summary/bib-summary.component.html | 8 +- .../bib-summary/bib-summary.component.ts | 33 +- .../copy-alerts-dialog.component.html | 109 +++++ .../holdings/copy-alerts-dialog.component.ts | 185 ++++++++ .../staff/share/holdings/holdings.module.ts | 10 +- .../staff/share/holdings/holdings.service.ts | 9 +- .../replace-barcode-dialog.component.html | 50 ++ .../replace-barcode-dialog.component.ts | 111 +++++ .../app/staff/share/holds/grid.component.ts | 4 +- 17 files changed, 1108 insertions(+), 118 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index 8b018d5e38..63f964ef81 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -89,7 +89,7 @@ export class ComboboxComponent implements OnInit { // Useful for massaging the match string prior to comparison // and display. Default version trims leading/trailing spaces. - formatDisplayString: (ComboboxEntry) => string; + formatDisplayString: (e: ComboboxEntry) => string; constructor( private elm: ElementRef, diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts index 1855000f60..46d25d7d1c 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts @@ -47,7 +47,8 @@ import {HoldingsMaintenanceComponent} from './record/holdings.component'; StaffCommonModule, CatalogCommonModule, CatalogRoutingModule, - HoldsModule + HoldsModule, + HoldingsModule ], providers: [ StaffCatalogService diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css new file mode 100644 index 0000000000..61b04cd3e2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.css @@ -0,0 +1,41 @@ +/* +:host /deep/ allows us to share style with child components. +In this case, the holdings components wants its grid to see +the CSS we have defined for different row types + +See https://v2.angular.io/docs/ts/latest/guide/component-styles.html +*/ + +/* grid row colors are bootstrap class="bg-info" with opacity */ + +/* +:host /deep/ .holdings-copy-row { +} +*/ + +:host /deep/ .holdings-volume-row { + color: #004085; + background-color: rgb(23,162,184,0.2); +} + +:host /deep/ .holdings-org-row-0 { + color: #004085; + background-color: rgb(23,162,184); +} + +:host /deep/ .holdings-org-row-1 { + color: #004085; + background-color: rgb(23,162,184,0.8); +} + +:host /deep/ .holdings-org-row-2 { + color: #004085; + background-color: rgb(23,162,184,0.6); +} + +:host /deep/ .holdings-org-row-3 { + color: #004085; + background-color: rgb(23,162,184,0.4); +} + +/* Add additional classes for more deeply nested org unit hierarchies */ \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html index 3cfcb273a5..4b46e62120 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.html @@ -1,4 +1,21 @@ + + +
+
+
+
+
Holdings Maintenance
+
+ + +
+
+
+ + +
@@ -18,23 +35,28 @@
+ + - - Yes - - No + + + + + + +
- + @@ -49,14 +71,110 @@ #emptyLibsCheckbox (onChange)="toggleShowEmptyLibs($event)"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - @@ -64,31 +182,59 @@ - + + + + name="circ_lib.name" datatype="org_unit"> - + - + + + - + + + + + + path="copy.age_protect.name" name="age_protect.name"> + + + + - + + + + +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts index 0f9e4ad990..3e69e90b96 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts @@ -3,16 +3,26 @@ import {Observable, Observer, of} from 'rxjs'; import {map} from 'rxjs/operators'; import {Pager} from '@eg/share/util/pager'; import {IdlObject} from '@eg/core/idl.service'; -import {NetService} from '@eg/core/net.service'; import {StaffCatalogService} from '../catalog.service'; import {OrgService} from '@eg/core/org.service'; -import {AuthService} from '@eg/core/auth.service'; import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; import {GridDataSource} from '@eg/share/grid/grid'; import {GridComponent} from '@eg/share/grid/grid.component'; -import {GridToolbarCheckboxComponent} from '@eg/share/grid/grid-toolbar-checkbox.component'; +import {GridToolbarCheckboxComponent + } from '@eg/share/grid/grid-toolbar-checkbox.component'; +import {StoreService} from '@eg/core/store.service'; import {ServerStoreService} from '@eg/core/server-store.service'; - +import {MarkDamagedDialogComponent + } from '@eg/staff/share/holdings/mark-damaged-dialog.component'; +import {MarkMissingDialogComponent + } from '@eg/staff/share/holdings/mark-missing-dialog.component'; +import {AnonCacheService} from '@eg/share/util/anon-cache.service'; +import {HoldingsService} from '@eg/staff/share/holdings/holdings.service'; +import {CopyAlertsDialogComponent + } from '@eg/staff/share/holdings/copy-alerts-dialog.component'; +import {ReplaceBarcodeDialogComponent + } from '@eg/staff/share/holdings/replace-barcode-dialog.component'; // The holdings grid models a single HoldingsTree, composed of HoldingsTreeNodes // flattened on-demand into a list of HoldingEntry objects. @@ -53,26 +63,44 @@ class HoldingsEntry { @Component({ selector: 'eg-holdings-maintenance', - templateUrl: 'holdings.component.html' + templateUrl: 'holdings.component.html', + styleUrls: ['holdings.component.css'] }) export class HoldingsMaintenanceComponent implements OnInit { - recId: number; initDone = false; gridDataSource: GridDataSource; gridTemplateContext: any; @ViewChild('holdingsGrid') holdingsGrid: GridComponent; // Manage visibility of various sub-sections - @ViewChild('volsCheckbox') volsCheckbox: GridToolbarCheckboxComponent; - @ViewChild('copiesCheckbox') copiesCheckbox: GridToolbarCheckboxComponent; - @ViewChild('emptyVolsCheckbox') emptyVolsCheckbox: GridToolbarCheckboxComponent; - @ViewChild('emptyLibsCheckbox') emptyLibsCheckbox: GridToolbarCheckboxComponent; + @ViewChild('volsCheckbox') + private volsCheckbox: GridToolbarCheckboxComponent; + @ViewChild('copiesCheckbox') + private copiesCheckbox: GridToolbarCheckboxComponent; + @ViewChild('emptyVolsCheckbox') + private emptyVolsCheckbox: GridToolbarCheckboxComponent; + @ViewChild('emptyLibsCheckbox') + private emptyLibsCheckbox: GridToolbarCheckboxComponent; + @ViewChild('markDamagedDialog') + private markDamagedDialog: MarkDamagedDialogComponent; + @ViewChild('markMissingDialog') + private markMissingDialog: MarkMissingDialogComponent; + @ViewChild('copyAlertsDialog') + private copyAlertsDialog: CopyAlertsDialogComponent; + @ViewChild('replaceBarcode') + private replaceBarcode: ReplaceBarcodeDialogComponent; - contextOrg: IdlObject; holdingsTree: HoldingsTree; - holdingsTreeOrgCache: {[id: number]: HoldingsTreeNode}; + + // nodeType => id => tree node cache + treeNodeCache: {[nodeType: string]: {[id: number]: HoldingsTreeNode}}; + + // When true and a grid reload is called, the holdings data will be + // re-fetched from the server. refreshHoldings: boolean; + + // Used as a row identifier in th grid, since we're mixing object types. gridIndex: number; // List of copies whose due date we need to retrieve. @@ -82,35 +110,54 @@ export class HoldingsMaintenanceComponent implements OnInit { // When not true, render based on the current "expanded" state of each node. // Rendering from prefs happens on initial load and when any prefs change. renderFromPrefs: boolean; + rowClassCallback: (row: any) => string; + private _recId: number; @Input() set recordId(id: number) { - this.recId = id; + this._recId = id; // Only force new data collection when recordId() // is invoked after ngInit() has already run. if (this.initDone) { - this.refreshHoldings = true; - this.holdingsGrid.reload(); + this.hardRefresh(); } } + get recordId(): number { + return this._recId; + } + + contextOrg: IdlObject; constructor( - private net: NetService, private org: OrgService, - private auth: AuthService, private pcrud: PcrudService, + private auth: AuthService, private staffCat: StaffCatalogService, - private store: ServerStoreService + private store: ServerStoreService, + private localStore: StoreService, + private holdings: HoldingsService, + private anonCache: AnonCacheService ) { // Set some sane defaults before settings are loaded. - this.contextOrg = this.org.get(this.auth.user().ws_ou()); this.gridDataSource = new GridDataSource(); this.refreshHoldings = true; this.renderFromPrefs = true; + // TODO: need a separate setting for this? + this.contextOrg = this.staffCat.searchContext.searchOrg; + this.rowClassCallback = (row: any): string => { - if (row.volume && !row.copy) { - return 'bg-info'; + if (row.volume) { + if (row.copy) { + return 'holdings-copy-row'; + } else { + return 'holdings-volume-row'; + } + } else { + // Add a generic org unit class and a depth-specific + // class for styling different levels of the org tree. + return 'holdings-org-row holdings-org-row-' + + row.treeNode.target.ou_type().depth(); } } @@ -142,7 +189,7 @@ export class HoldingsMaintenanceComponent implements OnInit { ngOnInit() { this.initDone = true; - // These are pre-cached via the resolver. + // These are pre-cached via the catalog resolver. const settings = this.store.getItemBatchCached([ 'cat.holdings_show_empty_org', 'cat.holdings_show_empty', @@ -150,18 +197,31 @@ export class HoldingsMaintenanceComponent implements OnInit { 'cat.holdings_show_vols' ]); - this.volsCheckbox.checked(settings['cat.holdings_show_vols']); + // Show volumes by default when no preference is set. + let showVols = settings['cat.holdings_show_vols']; + if (showVols === null) { showVols = true; } + + this.volsCheckbox.checked(showVols); this.copiesCheckbox.checked(settings['cat.holdings_show_copies']); this.emptyVolsCheckbox.checked(settings['cat.holdings_show_empty']); this.emptyLibsCheckbox.checked(settings['cat.holdings_show_empty_org']); + this.initHoldingsTree(); this.gridDataSource.getRows = (pager: Pager, sort: any[]) => { return this.fetchHoldings(pager); }; } - ngAfterViewInit() { + contextOrgChanged(org: IdlObject) { + this.contextOrg = org; + this.hardRefresh(); + } + hardRefresh() { + this.renderFromPrefs = true; + this.refreshHoldings = true; + this.initHoldingsTree(); + this.holdingsGrid.reload(); } toggleShowCopies(value: boolean) { @@ -210,33 +270,40 @@ export class HoldingsMaintenanceComponent implements OnInit { initHoldingsTree() { + const visibleOrgs = this.org.fullPath(this.contextOrg, true); + // The initial tree simply matches the org unit tree const traverseOrg = (node: HoldingsTreeNode) => { - node.expanded = true; node.target.children().forEach((org: IdlObject) => { + if (visibleOrgs.indexOf(org.id()) == -1) { + return; // Org is outside of scope + } const nodeChild = new HoldingsTreeNode(); nodeChild.nodeType = 'org'; nodeChild.target = org; nodeChild.parentNode = node; node.children.push(nodeChild); - this.holdingsTreeOrgCache[org.id()] = nodeChild; + this.treeNodeCache.org[org.id()] = nodeChild; traverseOrg(nodeChild); }); } + this.treeNodeCache = { + org: {}, + volume: {}, + copy: {} + }; + this.holdingsTree = new HoldingsTree(); this.holdingsTree.root.nodeType = 'org'; this.holdingsTree.root.target = this.org.root(); - - this.holdingsTreeOrgCache = {}; - this.holdingsTreeOrgCache[this.org.root().id()] = this.holdingsTree.root; + this.treeNodeCache.org[this.org.root().id()] = this.holdingsTree.root; traverseOrg(this.holdingsTree.root); } // Org node children are sorted with any child org nodes pushed to the // front, followed by the call number nodes sorted alphabetcially by label. - // TODO: prefix/suffix sortOrgNodeChildren(node: HoldingsTreeNode) { node.children = node.children.sort((a, b) => { if (a.nodeType === 'org') { @@ -248,7 +315,9 @@ export class HoldingsMaintenanceComponent implements OnInit { } else if (b.nodeType === 'org') { return 1; } else { - return a.target.label() < b.target.label() ? -1 : 1; + // TODO: should this use label sortkey instead of + // the compiled volume label? + return a.target._label < b.target._label ? -1 : 1; } }); } @@ -309,7 +378,7 @@ export class HoldingsMaintenanceComponent implements OnInit { switch(node.nodeType) { case 'org': - if (this.renderFromPrefs && node.volumeCount === 0 + if (node.volumeCount === 0 && !this.emptyLibsCheckbox.checked()) { return; } @@ -321,7 +390,16 @@ export class HoldingsMaintenanceComponent implements OnInit { break; case 'volume': - entry.locationLabel = node.target.label(); // TODO prefix/suffix + if (this.renderFromPrefs) { + if (!this.volsCheckbox.checked()) { + return; + } + if (node.copyCount === 0 + && !this.emptyVolsCheckbox.checked()) { + return; + } + } + entry.locationLabel = node.target._label; entry.locationDepth = node.parentNode.target.ou_type().depth() + 1; entry.callNumberLabel = entry.locationLabel; entry.volume = node.target; @@ -357,9 +435,9 @@ export class HoldingsMaintenanceComponent implements OnInit { this.renderFromPrefs = false; } - + // Grab volumes, copies, and related data. fetchHoldings(pager: Pager): Observable { - if (!this.recId) { return of([]); } + if (!this.recordId) { return of([]); } return new Observable(observer => { @@ -368,12 +446,11 @@ export class HoldingsMaintenanceComponent implements OnInit { return; } - this.initHoldingsTree(); this.itemCircsNeeded = []; this.pcrud.search('acn', - { record: this.recId, - owning_lib: this.org.ancestors(this.contextOrg, true), + { record: this.recordId, + owning_lib: this.org.fullPath(this.contextOrg, true), deleted: 'f', label: {'!=' : '##URI##'} }, { @@ -384,7 +461,8 @@ export class HoldingsMaintenanceComponent implements OnInit { acn: ['prefix', 'suffix', 'copies'], acli: ['inventory_workstation'] } - } + }, + {authoritative: true} ).subscribe( vol => this.appendVolume(vol), err => {}, @@ -413,28 +491,278 @@ export class HoldingsMaintenanceComponent implements OnInit { })).toPromise(); } + // Compile prefix + label + suffix into field volume._label; + setVolumeLabel(volume: IdlObject) { + const pfx = volume.prefix() ? volume.prefix().label() : ''; + const sfx = volume.suffix() ? volume.suffix().label() : ''; + volume._label = pfx ? pfx + ' ' : ''; + volume._label += volume.label(); + volume._label += sfx ? ' ' + sfx : ''; + } + + // Create the tree node for the volume if it doesn't already exist. + // Do the same for its linked copies. appendVolume(volume: IdlObject) { + let volNode = this.treeNodeCache.volume[volume.id()]; + this.setVolumeLabel(volume); + + if (volNode) { + const pNode = this.treeNodeCache.org[volume.owning_lib()] + if (volNode.parentNode.target.id() !== pNode.target.id()) { + // Volume owning library changed. Un-link it from the previous + // org unit collection before adding to the new one. + // XXX TODO: ^-- + volNode.parentNode = pNode; + volNode.parentNode.children.push(volNode); + } + } else { + volNode = new HoldingsTreeNode(); + volNode.nodeType = 'volume'; + volNode.parentNode = this.treeNodeCache.org[volume.owning_lib()] + volNode.parentNode.children.push(volNode); + this.treeNodeCache.volume[volume.id()] = volNode; + } - const volNode = new HoldingsTreeNode(); - volNode.parentNode = this.holdingsTreeOrgCache[volume.owning_lib()]; - volNode.parentNode.children.push(volNode); - volNode.nodeType = 'volume'; volNode.target = volume; volume.copies() .sort((a: IdlObject, b: IdlObject) => a.barcode() < b.barcode() ? -1 : 1) - .forEach((copy: IdlObject) => { - const copyNode = new HoldingsTreeNode(); + .forEach((copy: IdlObject) => this.appendCopy(volNode, copy)); + } + + // Find or create a copy node. + appendCopy(volNode: HoldingsTreeNode, copy: IdlObject) { + let copyNode = this.treeNodeCache.copy[copy.id()]; + + if (copyNode) { + const oldParent = copyNode.parentNode; + if (oldParent.target.id() !== volNode.target.id()) { + // TODO: copy changed owning volume. Remove it from + // the previous volume before adding to the new volume. copyNode.parentNode = volNode; volNode.children.push(copyNode); - copyNode.nodeType = 'copy'; - copyNode.target = copy; - const stat = Number(copy.status().id()); - if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) { - this.itemCircsNeeded.push(copy); - } - }); + } + } else { + // New node required + copyNode = new HoldingsTreeNode(); + copyNode.nodeType = 'copy'; + volNode.children.push(copyNode); + copyNode.parentNode = volNode; + this.treeNodeCache.copy[copy.id()] = copyNode; + } + + copyNode.target = copy; + const stat = Number(copy.status().id()); + + if (stat === 1 /* checked out */ || stat === 16 /* long overdue */) { + // Avoid looking up circs on items that are not checked out. + this.itemCircsNeeded.push(copy); + } } -} + // Which copies in the grid are selected. + selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] { + let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy); + if (skipStatus) { + copyRows = copyRows.filter( + c => Number(c.status().id()) !== Number(skipStatus)); + } + return copyRows.map(c => Number(c.id())); + } + + selectedVolumeIds(rows: HoldingsEntry[]): number[] { + return rows + .filter(r => r.treeNode.nodeType === 'volume') + .map(r => Number(r.volume.id())); + } + + async showMarkDamagedDialog(rows: HoldingsEntry[]) { + const copyIds = this.selectedCopyIds(rows, 14 /* ignore damaged */); + + if (copyIds.length === 0) { return; } + + let rowsModified = false; + + const markNext = async(ids: number[]) => { + if (ids.length === 0) { + return Promise.resolve(); + } + + this.markDamagedDialog.copyId = ids.pop(); + return this.markDamagedDialog.open({size: 'lg'}).then( + ok => { + if (ok) { rowsModified = true; } + return markNext(ids); + }, + dismiss => markNext(ids) + ); + }; + + await markNext(copyIds); + if (rowsModified) { + this.refreshHoldings = true; + this.holdingsGrid.reload(); + } + } + + showMarkMissingDialog(rows: any[]) { + const copyIds = this.selectedCopyIds(rows, 4 /* ignore missing */); + if (copyIds.length > 0) { + this.markMissingDialog.copyIds = copyIds; + this.markMissingDialog.open({}).then( + rowsModified => { + if (rowsModified) { + this.refreshHoldings = true; + this.holdingsGrid.reload(); + } + }, + dismissed => {} // avoid console errors + ); + } + } + + // Mark record, library, and potentially the selected call number + // as the current transfer target. + markLibCnForTransfer(rows: HoldingsEntry[]) { + if (rows.length === 0) { + return; + } + + // Action may only apply to a single org or volume row. + const node = rows[0].treeNode; + if (node.nodeType === 'copy') { + return; + } + + let orgId: number; + + if (node.nodeType === 'org') { + orgId = node.target.id(); + + // Clear volume target when performed on an org unit row + this.localStore.removeLocalItem('eg.cat.transfer_target_vol'); + } else if (node.nodeType === 'volume') { + + // All volume nodes are children of org nodes. + orgId = node.parentNode.target.id(); + + // Add volume target when performed on a volume row. + this.localStore.setLocalItem( + 'eg.cat.transfer_target_vol', node.target.id()) + } + + this.localStore.setLocalItem('eg.cat.transfer_target_record', this.recordId); + this.localStore.setLocalItem('eg.cat.transfer_target_lib', orgId); + } + + openAngJsWindow(path: string) { + const url = `/eg/staff/${path}`; + window.open(url, '_blank'); + } + + openItemHolds(rows: HoldingsEntry[]) { + if (rows.length > 0 && rows[0].copy) { + this.openAngJsWindow(`cat/item/${rows[0].copy.id()}/holds`); + } + } + + openItemStatusList(rows: HoldingsEntry[]) { + const ids = this.selectedCopyIds(rows); + if (ids.length > 0) { + return this.openAngJsWindow(`cat/item/search/${ids.join(',')}`); + } + } + + openItemStatus(rows: HoldingsEntry[]) { + if (rows.length > 0 && rows[0].copy) { + return this.openAngJsWindow(`cat/item/${rows[0].copy.id()}`); + } + } + + openItemTriggeredEvents(rows: HoldingsEntry[]) { + if (rows.length > 0 && rows[0].copy) { + return this.openAngJsWindow( + `cat/item/${rows[0].copy.id()}/triggered_events`); + } + } + + openItemPrintLabels(rows: HoldingsEntry[]) { + const ids = this.selectedCopyIds(rows); + if (ids.length === 0) { return; } + + this.anonCache.setItem(null, 'print-labels-these-copies', {copies: ids}) + .then(key => this.openAngJsWindow(`cat/printlabels/${key}`)); + } + + openVolCopyEdit(rows: HoldingsEntry[], addVols: boolean, addCopies: boolean) { + + // The user may select a set of volumes by selecting volume and/or + // copy rows. + const volumes = []; + rows.forEach(r => { + if (r.treeNode.nodeType === 'volume') { + volumes.push(r.volume); + } else if (r.treeNode.nodeType === 'copy') { + volumes.push(r.treeNode.parentNode.target); + } + }); + + if (addCopies && !addVols) { + // Adding copies to an existing set of volumes. + if (volumes.length > 0) { + const volIds = volumes.map(v => Number(v.id())); + this.holdings.spawnAddHoldingsUi(this.recordId, volIds); + } + + } else if (addVols) { + const entries = []; + + if (volumes.length > 0) { + + // When adding volumes, if any are selected in the grid, + // create volumes that have the same label and owner. + volumes.forEach(v => + entries.push({label: v.label(), owner: v.owning_lib()})); + + } else { + + // Otherwise create new volumes from scratch. + entries.push({owner: this.auth.user().ws_ou()}) + } + + this.holdings.spawnAddHoldingsUi( + this.recordId, null, entries, !addCopies); + } + } + + openItemNotes(rows: HoldingsEntry[], mode: string) { + const copyIds = this.selectedCopyIds(rows); + if (copyIds.length === 0) { return; } + + this.copyAlertsDialog.copyIds = copyIds; + this.copyAlertsDialog.mode = mode; + this.copyAlertsDialog.open({size: 'lg'}).then( + modified => { + if (modified) { + this.hardRefresh(); + } + }, + dismissed => {} + ) + } + + openReplaceBarcodeDialog(rows: HoldingsEntry[]) { + const ids = this.selectedCopyIds(rows); + if (ids.length === 0) { return; } + this.replaceBarcode.copyIds = ids; + this.replaceBarcode.open({}).then( + modified => { + if (modified) { + this.hardRefresh(); + } + }, + dismissed => {} + ); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts index 793767b38f..b3e9a9c53e 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts @@ -18,6 +18,14 @@ export class RecordPaginationComponent implements OnInit { initDone = false; searchContext: CatalogSearchContext; + _recordTab: string; + @Input() set recordTab(tab: string) { + this._recordTab = tab; + } + get recordTab(): string { + return this._recordTab; + } + @Input() set recordId(id: number) { this.id = id; // Only apply new record data after the initial load @@ -38,41 +46,33 @@ export class RecordPaginationComponent implements OnInit { this.setIndex(); } + routeToRecord(id: number) { + let url = '/staff/catalog/record/' + id; + if (this.recordTab) { url += '/' + this.recordTab; } + const params = this.catUrl.toUrlParams(this.searchContext); + this.router.navigate([url], {queryParams: params}); + } + firstRecord(): void { - this.findRecordAtIndex(0).then(id => { - const params = this.catUrl.toUrlParams(this.searchContext); - this.router.navigate( - ['/staff/catalog/record/' + id], {queryParams: params}); - }); + this.findRecordAtIndex(0) + .then(id => this.routeToRecord(id)); } lastRecord(): void { - this.findRecordAtIndex( - this.searchContext.result.count - 1 - ).then(id => { - const params = this.catUrl.toUrlParams(this.searchContext); - this.router.navigate( - ['/staff/catalog/record/' + id], {queryParams: params}); - }); + this.findRecordAtIndex(this.searchContext.result.count - 1) + .then(id => this.routeToRecord(id)); } nextRecord(): void { - this.findRecordAtIndex(this.index + 1).then(id => { - const params = this.catUrl.toUrlParams(this.searchContext); - this.router.navigate( - ['/staff/catalog/record/' + id], {queryParams: params}); - }); + this.findRecordAtIndex(this.index + 1) + .then(id => this.routeToRecord(id)); } prevRecord(): void { - this.findRecordAtIndex(this.index - 1).then(id => { - const params = this.catUrl.toUrlParams(this.searchContext); - this.router.navigate( - ['/staff/catalog/record/' + id], {queryParams: params}); - }); + this.findRecordAtIndex(this.index - 1) + .then(id => this.routeToRecord(id)); } - // Returns the offset of the record within the search results as a whole. searchIndex(idx: number): number { return idx + this.searchContext.pager.offset; diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html index b583cf7e71..450887034d 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html @@ -3,7 +3,7 @@
- +
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts index f4f5d9719b..0a09cbdca7 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts @@ -45,7 +45,6 @@ export class CatalogResolver implements Resolve> { 'cat.holdings_show_empty', 'cat.marcedit.stack_subfields', 'cat.marcedit.flateditor', - 'eg.cat.record.summary.collapse', 'cat.holdings_show_copies', 'cat.holdings_show_vols' ]).then(settings => { diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html index d49de1bd7d..530e1080e2 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html @@ -8,12 +8,12 @@ @@ -36,7 +36,7 @@
-
  • +
  • Author:
    {{summary.display.author}}
    @@ -52,7 +52,7 @@
  • -
  • +
  • Bib Call #:
    {{summary.bibCallNumber}}
    diff --git a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts index 645b56cd78..954cb8bfe3 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.ts @@ -1,9 +1,8 @@ import {Component, OnInit, Input} from '@angular/core'; -import {NetService} from '@eg/core/net.service'; import {OrgService} from '@eg/core/org.service'; -import {PcrudService} from '@eg/core/pcrud.service'; -import {CatalogService} from '@eg/share/catalog/catalog.service'; -import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service'; +import {BibRecordService, BibRecordSummary + } from '@eg/share/catalog/bib-record.service'; +import {ServerStoreService} from '@eg/core/server-store.service'; @Component({ selector: 'eg-bib-summary', @@ -13,10 +12,16 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s export class BibSummaryComponent implements OnInit { initDone = false; - expandDisplay = true; - @Input() set expand(e: boolean) { - this.expandDisplay = e; + + // True / false if the display is vertically expanded + private _exp: boolean; + set expand(e: boolean) { + this._exp = e; + if (this.initDone) { + this.saveExpandState(); + } } + get expand(): boolean { return this._exp; } // If provided, the record will be fetched by the component. @Input() recordId: number; @@ -32,14 +37,12 @@ export class BibSummaryComponent implements OnInit { constructor( private bib: BibRecordService, - private cat: CatalogService, - private net: NetService, private org: OrgService, - private pcrud: PcrudService + private store: ServerStoreService ) {} ngOnInit() { - this.initDone = true; + if (this.summary) { this.summary.getBibCallNumber(); } else { @@ -47,6 +50,14 @@ export class BibSummaryComponent implements OnInit { this.loadSummary(); } } + + this.store.getItem('eg.cat.record.summary.collapse') + .then(value => this.expand = !value) + .then(() => this.initDone = true); + } + + saveExpandState() { + this.store.setItem('eg.cat.record.summary.collapse', !this.expand); } loadSummary(): void { diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html new file mode 100644 index 0000000000..4b3c1caab1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html @@ -0,0 +1,109 @@ + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts new file mode 100644 index 0000000000..e0ce7637b0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts @@ -0,0 +1,185 @@ +import {Component, OnInit, Input, ViewChild} from '@angular/core'; +import {NetService} from '@eg/core/net.service'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {OrgService} from '@eg/core/org.service'; +import {StringComponent} from '@eg/share/string/string.component'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; + +/** + * Dialog for managing copy alerts. + */ + +@Component({ + selector: 'eg-copy-alerts-dialog', + templateUrl: 'copy-alerts-dialog.component.html' +}) + +export class CopyAlertsDialogComponent + extends DialogComponent implements OnInit { + + _copyIds: number[]; + @Input() set copyIds(ids: number[]) { + this._copyIds = [].concat(ids); + } + get copyIds(): number[] { + return this._copyIds; + } + + _mode: string; // create | manage + @Input() set mode(m: string) { + this._mode = m; + } + get mode(): string { + return this._mode; + } + + // In 'create' mode, we may be adding notes to multiple copies. + copies: IdlObject[]; + // In 'manage' mode we only handle a single copy. + copy: IdlObject; + alertTypes: ComboboxEntry[]; + newAlert: IdlObject; + changesMade: boolean; + + @ViewChild('successMsg') private successMsg: StringComponent; + @ViewChild('errorMsg') private errorMsg: StringComponent; + @ViewChild('confirmDeleteDialog') + private confirmDeleteDialog: ConfirmDialogComponent; + + constructor( + private modal: NgbModal, // required for passing to parent + private toast: ToastService, + private net: NetService, + private idl: IdlService, + private pcrud: PcrudService, + private org: OrgService, + private auth: AuthService) { + super(modal); // required for subclassing + this.copyIds = []; + this.copies = []; + } + + ngOnInit() {} + + /** + * Fetch the item/record, then open the dialog. + * Dialog promise resolves with true/false indicating whether + * the mark-damanged action occured or was dismissed. + */ + async open(args: NgbModalOptions): Promise { + this.copy = null; + this.copies = []; + this.newAlert = this.idl.create('aca'); + this.newAlert.create_staff(this.auth.user().id()); + + if (this.copyIds.length === 0) { + return Promise.reject('copy ID required'); + } + + // In manage mode, we can only manage a single copy. + // But in create mode, we can add alerts to multiple copies. + + if (this.mode === 'manage') { + if (this.copyIds.length > 1) { + console.warn('Attempt to manage alerts for multiple copies.'); + this.copyIds = [this.copyIds[0]]; + } + } + + await this.getAlertTypes(); + await this.getCopies(); + if (this.mode === 'manage') { + await this.getCopyAlerts(); + } + return super.open(args); + } + + async getAlertTypes(): Promise { + if (this.alertTypes) { + return Promise.resolve(); + } + return this.pcrud.retrieveAll('ccat', + { active: true, + scope_org: this.org.ancestors(this.auth.user().ws_ou(), true) + }, {atomic: true} + ).toPromise().then(alerts => { + this.alertTypes = alerts.map(a => ({id: a.id(), label: a.name()})); + }); + } + + async getCopies(): Promise { + return this.pcrud.search('acp', {id: this.copyIds}, {}, {atomic: true}) + .toPromise().then(copies => { + this.copies = copies; + copies.forEach(c => c.copy_alerts([])); + if (this.mode === 'manage') { + this.copy = copies[0]; + } + }); + } + + // Copy alerts for the selected copies which have not been + // acknowledged by staff and are within org unit range of + // the alert type. + async getCopyAlerts(): Promise { + const copyIds = this.copies.map(c => c.id()); + const typeIds = this.alertTypes.map(a => a.id); + + return this.pcrud.search('aca', + {copy: copyIds, ack_time: null, alert_type: typeIds}, + {}, {atomic: true}) + .toPromise().then(alerts => { + alerts.forEach(a => { + const copy = this.copies.filter(c => c.id() === a.copy())[0]; + copy.copy_alerts().push(a); + }); + }); + } + + // Add the in-progress new note to all copies. + addNew() { + if (!this.newAlert.alert_type()) { return; } + + const alerts: IdlObject[] = []; + this.copies.forEach(c => { + const a = this.idl.clone(this.newAlert); + a.copy(c.id()); + alerts.push(a); + }); + + this.pcrud.create(alerts).toPromise().then( + newAlert => { + this.successMsg.current().then(msg => this.toast.success(msg)); + this.changesMade = true; + if (this.mode === 'create') { + // In create mode, we assume the user wants to create + // a single alert and be done with it. + this.close(this.changesMade); + } else { + // Otherwise, add the alert to the copy + this.copy.copy_alerts().push(newAlert); + } + }, + err => { + this.errorMsg.current().then(msg => this.toast.danger(msg)) + } + ); + } + + applyChanges() { + const alerts = this.copy.copy_alerts().filter(a => a.ischanged()); + if (alerts.length === 0) { return ;} + this.pcrud.update(alerts).toPromise().then( + ok => this.successMsg.current().then(msg => this.toast.success(msg)), + err => this.errorMsg.current().then(msg => this.toast.danger(msg)) + ) + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts index 382e9060d7..c931a2fc27 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts @@ -3,18 +3,24 @@ import {StaffCommonModule} from '@eg/staff/common.module'; import {HoldingsService} from './holdings.service'; import {MarkDamagedDialogComponent} from './mark-damaged-dialog.component'; import {MarkMissingDialogComponent} from './mark-missing-dialog.component'; +import {CopyAlertsDialogComponent} from './copy-alerts-dialog.component'; +import {ReplaceBarcodeDialogComponent} from './replace-barcode-dialog.component'; @NgModule({ declarations: [ MarkDamagedDialogComponent, - MarkMissingDialogComponent + MarkMissingDialogComponent, + CopyAlertsDialogComponent, + ReplaceBarcodeDialogComponent ], imports: [ StaffCommonModule ], exports: [ MarkDamagedDialogComponent, - MarkMissingDialogComponent + MarkMissingDialogComponent, + CopyAlertsDialogComponent, + ReplaceBarcodeDialogComponent ], providers: [ HoldingsService diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts index 4b28f70369..87b0ff1a9a 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts @@ -24,9 +24,10 @@ export class HoldingsService { // Open the holdings editor UI in a new browser window/tab. spawnAddHoldingsUi( - recordId: number, // Bib record ID - addToVols: number[] = [], // Add copies to existing volumes - volumeData: NewVolumeData[] = []) { // Creating new volumes + recordId: number, // Bib record ID + addToVols?: number[], // Add copies to / modify existing vols + volumeData?: NewVolumeData[], // Creating new volumes + hideCopies?: boolean) { // Hide the copy edit pane const raw: any[] = []; @@ -42,7 +43,7 @@ export class HoldingsService { record_id: recordId, raw: raw, hide_vols : false, - hide_copies : false + hide_copies : hideCopies ? true : false }).then(key => { if (!key) { console.error('Could not create holds cache key!'); diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html new file mode 100644 index 0000000000..562681bcd9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.html @@ -0,0 +1,50 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts new file mode 100644 index 0000000000..552922e9a0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/replace-barcode-dialog.component.ts @@ -0,0 +1,111 @@ +import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {EventService} from '@eg/core/event.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {AuthService} from '@eg/core/auth.service'; +import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; +import {DialogComponent} from '@eg/share/dialog/dialog.component'; +import {StringComponent} from '@eg/share/string/string.component'; + + +/** + * Dialog for marking items missing. + */ + +@Component({ + selector: 'eg-replace-barcode-dialog', + templateUrl: 'replace-barcode-dialog.component.html' +}) + +export class ReplaceBarcodeDialogComponent + extends DialogComponent implements OnInit { + + @Input() copyIds: number[]; + ids: number[]; // copy of list so we can pop() + + copy: IdlObject; + newBarcode: string; + barcodeExists: boolean; + + numSucceeded: number; + numFailed: number; + + @ViewChild('successMsg') + private successMsg: StringComponent; + + @ViewChild('errorMsg') + private errorMsg: StringComponent; + + constructor( + private modal: NgbModal, // required for passing to parent + private toast: ToastService, + private net: NetService, + private pcrud: PcrudService, + private evt: EventService, + private renderer: Renderer2, + private auth: AuthService) { + super(modal); // required for subclassing + } + + ngOnInit() {} + + async open(args: NgbModalOptions): Promise { + this.ids = [].concat(this.copyIds); + this.numSucceeded = 0; + this.numFailed = 0; + + await this.getNextCopy(); + setTimeout(() => + // Give the dialog a chance to render + this.renderer.selectRootElement('#new-barcode-input').focus() + ); + return super.open(args); + } + + async getNextCopy(): Promise { + + if (this.ids.length === 0) { + this.close(this.numSucceeded > 0); + return Promise.resolve(); + } + + this.newBarcode = ''; + + const id = this.ids.pop(); + + return this.pcrud.retrieve('acp', id) + .toPromise().then(c => this.copy = c); + } + + async replaceOneBarcode(): Promise { + this.barcodeExists = false; + + // First see if the barcode is in use + return this.pcrud.search('acp', {deleted: 'f', barcode: this.newBarcode}) + .toPromise().then(async (existing) => { + if (existing) { + this.barcodeExists = true; + return; + } + + this.copy.barcode(this.newBarcode); + this.pcrud.update(this.copy).toPromise().then( + async (ok) => { + this.numSucceeded++; + this.toast.success(await this.successMsg.current()); + this.getNextCopy(); + }, + async (err) => { + this.numFailed++; + console.error('Replace barcode failed: ', err); + this.toast.warning(await this.errorMsg.current()); + } + ) + }) + } +} + + + diff --git a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts index af27574528..a95ed8572f 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holds/grid.component.ts @@ -335,7 +335,7 @@ export class HoldsGridComponent implements OnInit { } this.markDamagedDialog.copyId = ids.pop(); - this.markDamagedDialog.open({size: 'lg'}).then( + return this.markDamagedDialog.open({size: 'lg'}).then( ok => { if (ok) { rowsModified = true; } return markNext(ids); @@ -397,3 +397,5 @@ export class HoldsGridComponent implements OnInit { } + + -- 2.43.2