--- /dev/null
+
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title">
+ <span i18n>Heading Details</span>
+ </h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close" (click)="close()">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <table id='info-table'>
+ <tbody>
+
+ <tr>
+ <td class='label' i18n>Previous Heading</td>
+ <td><span class='text-danger'>{{heading.prev_entry_value()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Previous Indexed As</td>
+ <td><span>{{heading.prev_field_label()}}</span></td>
+ <td class='label' i18n>Previous TCN</td>
+ <td><span>{{heading.prev_bib_record()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Previous Authority Tag</td>
+ <td><span>{{heading.prev_auth_tag()}}</span></td>
+ <td class='label' i18n>Previous Browse Entry ID</td>
+ <td><span>{{heading.prev_entry()}}</span></td>
+ </tr>
+
+ <tr><td colspan='4'><hr/></td></tr>
+
+ <tr>
+ <td class='label' i18n>New Heading</td>
+ <td><span class='text-danger'>{{heading.entry_value()}}</span>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Heading Date</td>
+ <td><span>{{heading.heading_date() | date:'short'}}</span></td>
+ <td class='label' i18n>Indexed As</td>
+ <td><span>{{heading.field_label()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>From TCN</td>
+ <td><span>{{heading.bib_record()}}</span></td>
+ <td class='label' i18n>Browse Entry ID</td>
+ <td><span>{{heading.entry()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Heading Create Date</td>
+ <td><span>{{heading.entry_create_date() | date:'short'}}</span></td>
+ <td class='label' i18n>Bib Record Edit Date</td>
+ <td><span>{{heading.bib_edit_date() | date:'short'}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Bib Record Cataloging Date</td>
+ <td><span>{{heading.bib_cataloging_date() | date:'short'}}</span></td>
+ <td class='label' i18n>Bib Record Create Date</td>
+ <td><span>{{heading.bib_create_date() | date:'short'}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Bib Record Editor Username</td>
+ <td><span>{{heading.bib_editor_usrname()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>From 1XX</td>
+ <td colspan='3'><span>{{heading.bib_marc_1xx()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>From 245</td>
+ <td colspan='3'><span>{{heading.bib_marc_245()}}</span></td>
+ </tr>
+
+ <tr><td colspan='4'><hr/></td></tr>
+
+ <tr>
+ <td class='label' i18n>Next Heading</td>
+ <td><span class='text-danger'>{{heading.next_entry_value()}}</span>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Next Indexed As</td>
+ <td><span>{{heading.next_field_label()}}</span></td>
+ <td class='label' i18n>Next TCN</td>
+ <td><span>{{heading.next_bib_record()}}</span></td>
+ </tr>
+
+ <tr>
+ <td class='label' i18n>Next Authority Tag</td>
+ <td><span>{{heading.next_auth_tag()}}</span></td>
+ <td class='label' i18n>Next Browse Entry ID</td>
+ <td><span>{{heading.next_entry()}}</span></td>
+ </tr>
+
+ </tbody>
+ </table>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-warning" (click)="close()" i18n>Close</button>
+ </div>
+</ng-template>
+
--- /dev/null
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+
+@Component({
+ selector: 'eg-heading-detail-dialog',
+ templateUrl: 'heading-detail.component.html',
+ styles: [
+ `td { padding:3px 8px 3px 8px; font-size: 98% }`,
+ `.label {
+ font-weight: bold;
+ white-space : nowrap;
+ }`
+
+ ]
+})
+export class HeadingDetailComponent extends DialogComponent {
+
+ heading: IdlObject;
+
+ constructor(
+ private modal: NgbModal // required for passing to parent
+ ) {
+ super(modal); // required for subclassing
+ }
+}
+
--- /dev/null
+<eg-staff-banner bannerText="New Headings" i18n-bannerText></eg-staff-banner>
+
+<eg-heading-detail-dialog #detailDialog></eg-heading-detail-dialog>
+
+<ng-template #headingTemplate let-axis="axis" let-heading="heading">
+ <div class="card-body">
+ <table>
+ <tbody>
+ <tr>
+ <td class="pr-2 label" i18n>Previous:</td>
+ <td>
+ <span i18n>
+ <span *ngIf="heading.prev_bib_record()">B: </span>
+ <span *ngIf="!heading.prev_bib_record()">A: </span>
+ {{heading.prev_entry_value()}}
+ </span>
+ <ng-container *ngIf="heading.prev_auth_tag()">
+ <span class="pl-2" i18n>(From {{heading.prev_auth_tag()}})</span>
+ </ng-container>
+ </td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>New:</td>
+ <td>
+ <a target="_blank" routerLink="/staff/catalog/browse"
+ [queryParams]="{browseTerm: heading.entry_value(), browseClass: axis}">
+ {{heading.entry_value()}}
+ </a>
+ <a href="javascript:;" class="pl-3" (click)="openDetailDialog(heading)">
+ (details)
+ </a>
+ </td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>Next:</td>
+ <td>
+ <span i18n>
+ <span *ngIf="heading.next_bib_record()">B: </span>
+ <span *ngIf="!heading.next_bib_record()">A: </span>
+ {{heading.next_entry_value()}}
+ </span>
+ <ng-container *ngIf="heading.next_auth_tag()">
+ <span class="pl-2" i18n>(From {{heading.next_auth_tag()}})</span>
+ </ng-container>
+ </td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>From TCN:</td>
+ <td>
+ <a target="_blank" routerLink="/staff/catalog/record/{{heading.bib_record()}}">
+ {{heading.bib_record()}}
+ </a>
+ <span class="pl-2 label" i18n>Indexed As:</span>
+ <span>{{heading.field_label()}}</span>
+ </td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>Edited By:</td>
+ <td>
+ <span>{{heading.bib_editor_usrname()}}</span>
+ <span class="pl-2 label" i18n>Heading Date:</span>
+ <span>{{heading.heading_date() | date:'shortDate'}}</span>
+ </td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>From 1XX:</td>
+ <td>{{heading.bib_marc_1xx()}}</td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>From 245:</td>
+ <td>{{heading.bib_marc_245()}}</td>
+ </tr>
+ <tr>
+ <td class="pr-2 label" i18n>Format:</td>
+ <td>{{mattypeLabel(heading.mattype())}}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</ng-template>
+
+<div class="row d-flex">
+ <div class="flex-1">
+
+ <div class="row form-inline border border-primary p-2 pr-3 mr-3 sticky-top-with-nav bg-white">
+ <label class="mr-2" i18n>Start Date: </label>
+ <eg-date-select #startDateSelect [(ngModel)]="startDate"></eg-date-select>
+
+ <label class="ml-2 mr-2" i18n>End Date: </label>
+ <eg-date-select #endDateSelect [(ngModel)]="endDate"></eg-date-select>
+
+ <button class="ml-2 btn btn-outline-dark" (click)="getHeadings(true)" i18n>Go</button>
+
+ <span class="border-left border-dark pl-3 ml-3">
+ <button class="btn btn-outline-dark" (click)="prevPage()"
+ [disabled]="pager.isFirstPage()" i18n>Previous Page</button>
+ </span>
+
+ <label class="ml-3 mr-3" i18n>Page {{pager.currentPage()}}</label>
+
+ <button class="btn btn-outline-dark" (click)="nextPage()"
+ [disabled]="pager.isLastPage()" i18n>Next Page</button>
+ </div>
+
+ <div class="row mt-3" *ngIf="loading">
+ <div class="col-lg-6 offset-lg-3">
+ <eg-progress-inline></eg-progress-inline>
+ </div>
+ </div>
+
+ <div class="row mt-3"
+ *ngIf="!loading && hasLoaded && headings.length === 0">
+ <div class="col-lg-6 offset-lg-3">
+ <div class="w-100 alert alert-info" i18n>No headings found</div>
+ </div>
+ </div>
+
+ <div class="mt-3 pb-3" *ngIf="!loading">
+
+ <ng-container *ngFor="let heading of headingsByAxisForPage('author'); let idx = index">
+ <div class="card tight-card">
+ <div *ngIf="idx === 0" class="card-header font-weight-bold bg-info" i18n>New Author Headings</div>
+ <ng-container
+ *ngTemplateOutlet="headingTemplate;context:{heading:heading,axis:'author'}">
+ </ng-container>
+ </div>
+ </ng-container>
+
+ <ng-container *ngFor="let heading of headingsByAxisForPage('subject'); let idx = index">
+ <div class="card tight-card">
+ <div *ngIf="idx === 0" class="card-header font-weight-bold bg-info" i18n>New Subject Headings</div>
+ <ng-container
+ *ngTemplateOutlet="headingTemplate;context:{heading:heading,axis:'subject'}">
+ </ng-container>
+ </div>
+ </ng-container>
+
+ <ng-container *ngFor="let heading of headingsByAxisForPage('series'); let idx = index">
+ <div class="card tight-card">
+ <div *ngIf="idx === 0" class="card-header font-weight-bold bg-info" i18n>New Series Headings</div>
+ <ng-container
+ *ngTemplateOutlet="headingTemplate;context:{heading:heading,axis:'series'}">
+ </ng-container>
+ </div>
+ </ng-container>
+
+ </div>
+ </div>
+
+ <div>
+ <div class="sticky-top-with-nav bg-white">
+ <div class="mt-2 p-2 border border-info rounded">
+ <h3 i18n>Exclude These Editors</h3>
+ <div class="w-100 justify-content-right mattype-list">
+ <eg-combobox #usrCbox [entries]="usrCboxEntries" required="true"
+ (onChange)="usrChanged($event)"
+ [selectedId]="usrId" [asyncDataSource]="usrCboxSource">
+ </eg-combobox>
+ </div>
+ <div class="w-100 justify-content-right mattype-list">
+ <div class="mt-1" *ngFor="let entry of excludeUsers">
+ <button class="btn-sm btn-danger" (click)="removeExcludedUser(entry.id)">X</button>
+ <span class="ml-2">{{entry.label}}</span>
+ </div>
+ </div>
+ </div>
+ <div class="mt-2 p-2 border border-info rounded">
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox"
+ [(ngModel)]="exclude001ODN" id="exclude-001-odn"/>
+ <label class="form-check-label" for="exclude-001-odn" i18n>
+ Exclude 001 ODN Records
+ </label>
+ </div>
+ </div>
+ <div class="mt-2 p-2 border border-info rounded">
+ <h3 i18n>Exclude These Formats</h3>
+ <div class="w-100 justify-content-right mattype-list">
+ <ng-container *ngFor="let mat of mattypes">
+ <div class="form-check">
+ <input class="form-check-input" type="checkbox"
+ [(ngModel)]="selectedMattypes[mat.code()]" id="mattype-{{mat.code()}}"/>
+ <label class="form-check-label" for="mattype-{{mat.code()}}">
+ {{mat.value()}}</label>
+ </div>
+ </ng-container>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {Observable, empty} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
+import {IdlObject} from '@eg/core/idl.service';
+import {Pager} from '@eg/share/util/pager';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {HeadingDetailComponent} from './heading-detail.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {DateUtil} from '@eg/share/util/date';
+
+/* New Headings Report */
+
+// Force a minimum start date for new headings to avoid reporting on
+// (practically) all headings, which occurs when the start date preceeds
+// or includes the SQL deployment date, which stamps a create_date on
+// every heading to NOW(). Such queries cause heavy load and eventually
+// time out anyway.
+// NOTE: using English dates instead of ISO dates since English
+// dates tell Date.parse() to use the local time zone instead of UTC.
+const MIN_START_DATE = new Date(Date.parse('December 5, 2016 00:00:00'));
+
+// Grab and cache this many, plus the first entry of the would-be next
+// batch to test whether a next batch exists.
+const PRE_FETCH_COUNT = 201;
+
+@Component({
+ templateUrl: 'new-headings.component.html',
+ styles: [
+ `.card {border-bottom: 2px dashed grey}`,
+ `.tight-card:nth-child(odd) { background-color: rgb(23,162,184,0.2); }`,
+ `.tight-card { font-size: 98%; }`,
+ `.mattype-list { font-size: 98%; }`,
+ `.label { white-space : nowrap; }`,
+ `.card-body {font-family: 'Lucida Console', Monaco, monospace; font-size: 94%}`
+ ]
+})
+export class NewHeadingsComponent implements OnInit {
+
+ pager: Pager;
+ startDate: Date;
+ endDate: Date;
+ mattypes: IdlObject[] = [];
+ selectedMattypes: any = {};
+ loading = false;
+ hasLoaded = false; // true after first search
+ headings = [];
+ usrId: number;
+ usrCboxSource: (term: string) => Observable<ComboboxEntry>;
+ usrCboxEntries: ComboboxEntry[];
+ excludeUsers: ComboboxEntry[] = [];
+ exclude001ODN = false;
+
+ @ViewChild('usrCbox') usrCbox: ComboboxComponent;
+ @ViewChild('startDateSelect') startDateSelect: DateSelectComponent;
+ @ViewChild('endDateSelect') endDateSelect: DateSelectComponent;
+ @ViewChild('detailDialog') detailDialog: HeadingDetailComponent;
+
+ constructor(
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService
+ ) {
+ this.pager = new Pager();
+ this.pager.limit = 50;
+ }
+
+
+ ngOnInit() {
+
+ this.usrCboxSource = term => {
+ if (term.length < 2) { return empty(); }
+
+ const filter: any = {deleted: 'f', active: 't'};
+ filter.usrname = {'ilike': `%${term}%`};
+
+ return this.pcrud.search('au', filter, {
+ order_by: {au: 'usrname'},
+ limit: 50 // Avoid huge lists
+ }
+ ).pipe(map(user => {
+ return {id: user.id(), label: user.usrname()};
+ }));
+ };
+
+ // seed the report with yesterday's date.
+ const d = new Date();
+ d.setDate(d.getDate() - 1);
+ this.startDate = d;
+ this.endDate = new Date(d);
+
+ this.pcrud.search('ccvm',
+ {ctype: 'mattype'}, {order_by: {ccvm: 'value'}})
+ .subscribe(mt => this.mattypes.push(mt));
+ }
+
+ usrChanged(entry: ComboboxEntry) {
+ if (entry && entry.id) {
+ // TODO avoid double entries
+ this.excludeUsers.push(entry);
+ this.usrCbox.selectedId = null;
+ }
+ }
+
+ removeExcludedUser(id: number) {
+ let users: ComboboxEntry[] = [];
+ this.excludeUsers.forEach(e => {
+ if (e.id !== id) {
+ users.push(e);
+ }
+ });
+ this.excludeUsers = users;
+ }
+
+ openDetailDialog(heading: IdlObject) {
+
+ this.detailDialog.heading = heading;
+ this.detailDialog.open({size: 'lg'});
+ }
+
+ headingsByAxisForPage(axis: string): IdlObject[] {
+ const subset = this.headings.slice(
+ this.pager.offset, this.pager.offset + this.pager.limit);
+
+ return subset.filter(heading => heading.browse_axis() === axis);
+ }
+
+ getHeadings(isNew?: boolean) {
+ this.loading = true;
+
+ if (isNew) {
+ this.headings = [];
+ this.pager.reset();
+ }
+
+ let counter = 0;
+ this.pcrud.search('rcbed', this.compileQueryFilter(), {
+ limit: PRE_FETCH_COUNT,
+ offset: this.pager.offset
+ }).subscribe(
+ heading => {
+ counter++;
+ this.headings.push(heading);
+ },
+ err => {},
+ () => {
+ this.loading = false;
+ this.hasLoaded = true;
+
+ console.debug(`Fetched ${counter} headings`);
+
+ if (counter < PRE_FETCH_COUNT) {
+ this.pager.resultCount = this.headings.length;
+
+ } else {
+ // Drop the final heading since it's the first
+ // heading for the next batch.
+ this.headings.length--; // neato
+ }
+ }
+ );
+ }
+
+ mattypeLabel(code: string): string {
+ const mattype = this.mattypes.filter(mt => mt.code() === code)[0];
+ if (mattype) { return mattype.value(); }
+ return '';
+ }
+
+ compileQueryFilter(): any {
+
+ if (!this.startDate || this.startDate < MIN_START_DATE) {
+ console.log('Selected start date ' + this.startDate + ' is too early. '
+ + 'Using min start date ' + MIN_START_DATE + ' instead');
+
+ // clone the date since it will get clobbered by getYMD.
+ this.startDate = MIN_START_DATE;
+ }
+
+ const startDate = this.startDateSelect.currentAsYmd();
+ let endDate;
+
+ if (this.endDate) {
+ // the end date has to be extended by one day, since the between
+ // query cuts off at midnight (0 hour) on the end date, which
+ // would miss all headings for that date created after hour 0.
+ // note: setDate() will rollover to the next month when needed.
+ endDate = new Date(this.endDate);
+ endDate.setDate(endDate.getDate() + 1);
+ endDate = DateUtil.localYmdFromDate(endDate);
+ }
+
+ const filter: any = {};
+
+ if (startDate && endDate) {
+ // use -and instead of BETWEEN so that endDate is not inclusive.
+ filter['-and'] = [
+ {heading_date : {'>=' : startDate}},
+ {heading_date : {'<' : endDate}}
+ ];
+ } else if (startDate) {
+ filter.heading_date = {'>=' : startDate};
+ } else {
+ filter.heading_date = {'<' : endDate};
+ }
+
+ const matTypes = Object.keys(this.selectedMattypes)
+ .filter(m => this.selectedMattypes[m] === true);
+
+ if (matTypes.length > 0) {
+ filter.mattype = {'not in': matTypes};
+ }
+
+ if (this.excludeUsers.length > 0) {
+ filter.bib_editor = {'not in': this.excludeUsers.map(e => e.id)};
+ }
+
+ if (this.exclude001ODN) {
+ filter.bib_marc_001 = {'!~': '^ODN'};
+ }
+
+ return filter;
+ }
+
+ prevPage() {
+ // Previous pages will always be cached.
+ this.pager.decrement();
+ }
+
+ nextPage() {
+ this.pager.increment();
+ if (!this.headings[this.pager.offset] && this.pager.resultCount === null) {
+ this.getHeadings();
+ }
+ }
+}
+
+
}, {
path: 'manage/:id/:tab',
component: ManageAuthorityComponent
+ }, {
+ path: 'new-headings',
+ component: NewHeadingsComponent
}];
@NgModule({