Course Admin Page
authorKyle Huckins <khuckins@catalyte.io>
Fri, 6 Dec 2019 07:17:53 +0000 (07:17 +0000)
committerJane Sandberg <sandbej@linnbenton.edu>
Sun, 19 Jul 2020 16:27:19 +0000 (09:27 -0700)
- Double-clicking on a course in the Course Reserves List will
up a dedicated admin page for an individual course, featuring
tabs for Editing and managing Course Materials.

Signed-off-by: Kyle Huckins <khuckins@catalyte.io>
 Changes to be committed:
modified:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-list.component.html
modified:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-list.component.ts
new file:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.html
new file:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.ts
modified:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-reserves.module.ts
modified:   Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/routing.module.ts
modified:   Open-ILS/src/eg2/src/app/staff/share/course.service.ts

Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-list.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-list.component.ts
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-reserves.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/routing.module.ts
Open-ILS/src/eg2/src/app/staff/share/course.service.ts

index d31f440..2dd917f 100644 (file)
@@ -3,8 +3,8 @@
 
 <eg-string #successString i18n-text text="{{table_name}} Update Succeeded"></eg-string>
 <eg-string #createString i18n-text text="{{table_name}} Was Created Successfully"></eg-string>
-<eg-string #deleteFailedString i18n-text text="Delete of {{table_name}} failed or was not allowed"></eg-string>
-<eg-string #deleteSucailedString i18n-text text="Delete of {{table_name}} failed or was not allowed"></eg-string>
+<eg-string #deleteFailedString i18n-text text="Deletion of {{table_name}} failed or was not allowed"></eg-string>
+<eg-string #deleteSucailedString i18n-text text="Deletion of {{table_name}} was successful"></eg-string>
 <eg-string #archiveFailedString i18n-text text="Archival of {{table_name}} failed or was not allowed"></eg-string>
 <eg-string #archiveSuccessString i18n-text text="Archival of {{table_name}} succeeded"></eg-string>
 <eg-string #flairTooltip i18n-text text="Limited Editing"></eg-string>
index 53875f3..91c4b28 100644 (file)
@@ -1,4 +1,5 @@
 import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import { Router, ActivatedRoute }    from '@angular/router';
 import {IdlObject} from '@eg/core/idl.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {AuthService} from '@eg/core/auth.service';
@@ -47,11 +48,17 @@ export class CourseListComponent implements OnInit {
         private net: NetService,
         private org: OrgService,
         private pcrud: PcrudService,
-        private toast: ToastService,
+        private route: ActivatedRoute,
+        private router: Router,
+        private toast: ToastService
     ){}
 
     ngOnInit() {
         this.getSource();
+        this.grid.onRowActivate.subscribe((course:IdlObject) => {
+            let idToEdit = course.id();
+            this.navigateToCoursePage(idToEdit);
+        })
     }
 
     /**
@@ -76,6 +83,10 @@ export class CourseListComponent implements OnInit {
         };
     }
 
+    navigateToCoursePage(id: any) {
+        this.router.navigate(["/staff/admin/local/asset/course_list/" + id]);
+    }
+
     showEditDialog(standingPenalty: IdlObject): Promise<any> {
         this.editDialog.mode = 'update';
         this.editDialog.recordId = standingPenalty['id']();
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.html
new file mode 100644 (file)
index 0000000..1d4cf90
--- /dev/null
@@ -0,0 +1,198 @@
+<eg-staff-banner bannerText=" {{currentCourse.course_number()}}: {{currentCourse.name()}}" 
+  i18n-bannerText class="mb-3" *ngIf="currentCourse">
+</eg-staff-banner>
+<ngb-tabset #surveyTabs [activeId]="surveyTab" class="mb-3">
+  <ngb-tab title="Edit Course" i18n-title id="edit">
+    <ng-template ngbTabContent>
+      <div class="col-lg-6 offset-lg-3 mt-3">
+        <div style="text-align: center;">
+          <button class="p-2 mb-3 btn btn-danger btn-lg" 
+            (click)="archiveCourse()">
+              Archive Course
+          </button>
+        </div>
+        <eg-fm-record-editor displayMode="inline" 
+          hiddenFieldsList="id,is_archived"
+          idlClass="acmc" 
+          [preloadLinkedValues]="true"
+          [record]="currentCourse">
+        </eg-fm-record-editor>
+      </div>
+    </ng-template>
+  </ngb-tab>
+  <ngb-tab title="Course Materials" i18n-title id="courseMaterials">
+    <ng-template ngbTabContent>
+      <div class="row mt-3">
+        <!-- Input Sidebar -->
+        <div class="col-lg-4 mt-3">
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Barcode</span>
+                </div>
+                <input type="text" class="flex-grow-1" [(ngModel)]="barcodeInput"
+                  (click)="$event.target.select()" 
+                  (keyup.enter)="associateItem(barcodeInput, relationshipInput)" />
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <span class="input-group-text" i18n>Relationship</span>
+                </div>
+                <input type="text" [(ngModel)]="relationshipInput" 
+                  placeholder-i18n placeholder="e.g. Required"
+                  class="flex-grow-1" />
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right">
+              <button class="btn btn-primary" (click)="associateItem(barcodeInput, 
+                relationshipInput)" i18n [disabled]="!barcodeInput">Add Material</button>
+            </div>
+          </div>
+          <div class="row justify-content-center mt-3">
+            <div class="col">
+              <h5 i18n>The following fields will be applied to the material 
+                    added, and reverted once the course is no longer associated 
+                    with the material.</h5>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <div class="input-group-text">
+                    <span i18n>Call Number</span>
+                  </div>
+                </div>
+                <input type="text" [(ngModel)]="tempCallNumber"
+                  (input)="isModifyingCallNumber = true" class="flex-grow-1" />
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingCallNumber"
+                      aria-label="Checkbox for setting a temporary Call Number" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <div class="input-group-text">
+                    <span i18n>Circulation Modifier</span>
+                  </div>
+                </div>
+                <eg-combobox i18n-placeholder placeholder="Circulation Modifier..."
+                  idlClass="ccm" idlField="name" [displayTemplate]="idlClassLabel"
+                  [asyncSupportsEmptyTermClick]="true" class="flex-grow-1" 
+                  (onChange)="tempCircMod = $event.id; isModifyingCircMod = true">
+                </eg-combobox>
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingCircMod"
+                      aria-label="Checkbox for setting a temporary Circulation Modifier" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <div class="input-group-text">
+                    <span i18n>Item Status</span>
+                  </div>
+                </div>
+                <eg-combobox i18n-placeholder placeholder="Item Status..."
+                  idlClass="ccs" idlField="name" [displayTemplate]="idlClassLabel"
+                  [asyncSupportsEmptyTermClick]="true" class="flex-grow-1" 
+                  (onChange)="tempStatus = $event.id; isModifyingStatus = true">
+                </eg-combobox>
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingStatus"
+                      aria-label="Checkbox for setting a temporary Item Status" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 d-flex">
+              <div class="input-group">
+                <div class="input-group-prepend">
+                  <div class="input-group-text">
+                    <span i18n>Shelving Location</span>
+                  </div>
+                </div>
+                <eg-item-location-select permFilter="MANAGE_RESERVES" class="flex-grow-1" 
+                  [(ngModel)]="tempLocation" (valueChange)="isModifyingLocation = true">
+                </eg-item-location-select>
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingLocation"
+                      aria-label="Checkbox for setting a temporary Shelving Location" />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- End Input Sidebar -->
+        <div class="col-lg-8 mt-3">
+          <eg-grid #materialsGrid [dataSource]="materialsDataSource">
+            <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+            </eg-grid-toolbar-action>
+
+            <eg-grid-column path="id" [index]=true [hidden]="true" label="ID" i18n-label></eg-grid-column>
+            <eg-grid-column label="Barcode" i18n-label name="barcode" [cellTemplate]="barcodeCellTemplate"></eg-grid-column>
+            <eg-grid-column label="Title" i18n-label name="title" [cellTemplate]="titleCellTemplate"></eg-grid-column>
+            <eg-grid-column path="call_number.label" label="Call Number" i18n-label></eg-grid-column>
+            <eg-grid-column path="call_number.prefix.label" [hidden]="true" label="Call Number Prefix" i18n-label hidden></eg-grid-column>
+            <eg-grid-column path="call_number.suffix.label" [hidden]="true" label="Call Number Suffix" i18n-label hidden></eg-grid-column>
+            <eg-grid-column path="circ_modifier" [hidden]="true" label="Circulation Modifier" i18n-label></eg-grid-column>
+            <eg-grid-column path="circ_lib.shortname" label="Circulation Library" i18n-label></eg-grid-column>
+            <eg-grid-column path="location.name" [hidden]="true" label="Shelving Location" i18n-label></eg-grid-column>
+            <eg-grid-column path="status.name" [hidden]="true" label="Copy Status" i18n-label></eg-grid-column>
+            <eg-grid-column path="_relationship" label="Relationship" i18n-label></eg-grid-column>
+          </eg-grid>
+        </div>
+      </div>
+      <ng-template #barcodeCellTemplate let-entry="row">
+        <span>
+          <a class="pl-1"
+            href="/eg/staff/cat/item/{{entry.id()}}">
+            {{entry.barcode()}}
+          </a>
+        </span>
+      </ng-template>
+      <ng-template #titleCellTemplate let-entry="row">
+        <span>
+          <a class="pl-1"
+            href="/eg/staff/cat/catalog/record/{{entry.call_number().record()}}">
+            {{entry._title}}
+          </a>
+        </span>
+      </ng-template>
+      <ng-template #idlClassLabel let-r="result" i18n>
+        {{r.label}}
+      </ng-template>
+    </ng-template>
+  </ngb-tab>
+</ngb-tabset>
+
+<eg-string #archiveFailedString i18n-text text="Archival of Course failed or was not allowed"></eg-string>
+<eg-string #archiveSuccessString i18n-text text="Archival of Course succeeded"></eg-string>
+<eg-string #materialDeleteFailedString i18n-text text="Disassociation of Course Material failed or was not allowed"></eg-string>
+<eg-string #materialDeleteSuccessString i18n-text text="Disassociation of Course Material succeeded"></eg-string>
+<eg-string #materialAddSuccessString i18n-text text="Association of Course Material succeeded"></eg-string>
+<eg-string #materialAddFailedString i18n-text text="Association of Course Material failed or was not allowed"></eg-string>
+<eg-string #MaterialAddDifferentLibraryString i18n-text text="Material exists at a different library"></eg-string>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-page.component.ts
new file mode 100644 (file)
index 0000000..f5ed823
--- /dev/null
@@ -0,0 +1,180 @@
+import {Component, Input, ViewChild, OnInit, TemplateRef} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {Observable, Observer, of} from 'rxjs';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {AuthService} from '@eg/core/auth.service';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {OrgService} from '@eg/core/org.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {Pager} from '@eg/share/util/pager';
+import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StaffBannerComponent} from '@eg/staff/share/staff-banner.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {CourseService} from '@eg/staff/share/course.service';
+
+@Component({
+    selector: 'eg-course-page',
+    templateUrl: './course-page.component.html'
+})
+
+export class CoursePageComponent implements OnInit {
+
+    currentCourse: IdlObject;
+    courseId: any;
+    
+    // Edit Tab
+    @ViewChild('archiveFailedString', { static: true })
+        archiveFailedString: StringComponent;
+    @ViewChild('archiveSuccessString', { static: true })
+        archiveSuccessString: StringComponent;
+
+    // Materials Tab
+    materials: any[] = [];
+    @ViewChild('materialsGrid', {static: true}) materialsGrid: GridComponent;
+    @ViewChild('materialDeleteFailedString', { static: true })
+        materialDeleteFailedString: StringComponent;
+    @ViewChild('materialDeleteSuccessString', { static: true })
+        materialDeleteSuccessString: StringComponent;
+    @ViewChild('materialAddSuccessString', { static: true })
+        materialAddSuccessString: StringComponent;
+    @ViewChild('materialAddFailedString', { static: true })
+        materialAddFailedString: StringComponent;
+    @ViewChild('materialAddDifferentLibraryString', { static: true })
+        materialAddDifferentLibraryString: StringComponent;
+    materialsDataSource: GridDataSource;
+    @Input() barcodeInput: String;
+    @Input() relationshipInput: String;
+    @Input() tempCallNumber: String;
+    @Input() tempStatus: Number;
+    @Input() tempLocation: Number;
+    @Input() tempCircMod: String;
+    @Input() isModifyingStatus: Boolean;
+    @Input() isModifyingCircMod: Boolean;
+    @Input() isModifyingCallNumber: Boolean;
+    @Input() isModifyingLocation: Boolean;
+
+    constructor(
+        private auth: AuthService,
+        private course: CourseService,
+        private event: EventService,
+        private idl: IdlService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private route: ActivatedRoute,
+        private toast: ToastService
+    ) {
+        this.materialsDataSource = new GridDataSource();
+    }
+
+    ngOnInit() {
+        this.courseId = parseInt(this.route.snapshot.paramMap.get('id'));
+        this.course.getCourses([this.courseId]).then(course => {
+            this.currentCourse = course[0];
+        });
+        this.materialsDataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.loadMaterialsGrid(pager);
+        }
+    }
+
+    // Edit Tab
+    archiveCourse() {
+        this.course.disassociateMaterials(this.currentCourse).then(res => {
+            this.currentCourse.is_archived(true);
+            this.pcrud.update(this.currentCourse).subscribe(val => {
+                console.debug('archived: ' + val);
+                this.archiveSuccessString.current()
+                    .then(str => this.toast.success(str));
+            }, err => {
+                this.archiveFailedString.current()
+                    .then(str => this.toast.danger(str));
+            });
+        });
+    }
+
+    // Materials Tab
+    loadMaterialsGrid(pager: Pager): Observable<any> {
+        return new Observable<any>(observer => {
+            this.course.getMaterials(this.courseId).then(materials => {
+                materials.forEach(material => {
+                    this.course.fleshMaterial(material.item(), material.relationship()).then(fleshed_material => {
+                        this.materialsDataSource.data.push(fleshed_material);
+                    });
+                });
+            });
+            observer.complete();
+        });
+    }
+    
+    associateItem(barcode, relationship) {
+        if (barcode) {
+            let args = {
+                barcode: barcode,
+                relationship: relationship,
+                isModifyingCallNumber: this.isModifyingCallNumber,
+                isModifyingCircMod: this.isModifyingCircMod,
+                isModifyingLocation: this.isModifyingLocation,
+                isModifyingStatus: this.isModifyingStatus,
+                tempCircMod: this.tempCircMod,
+                tempLocation: this.tempLocation,
+                tempStatus: this.tempStatus,
+                currentCourse: this.currentCourse
+            }
+            this.barcodeInput = null;
+
+            this.pcrud.search('acp', {barcode: args.barcode}, {
+                flesh: 3, flesh_fields: {acp: ['call_number']}
+            }).subscribe(item => {
+                let associatedMaterial = this.course.associateMaterials(item, args);
+                associatedMaterial.material.then(res => {
+                    item = associatedMaterial.item;
+                    let new_cn = item.call_number().label();
+                    if (this.tempCallNumber) new_cn = this.tempCallNumber;
+                    this.course.updateItem(item, this.currentCourse.owning_lib(),
+                        new_cn, args.isModifyingCallNumber
+                    ).then(resp => {
+                        this.course.fleshMaterial(item.id(), args.relationship).then(fleshed_material => {
+                            this.materialsDataSource.data.push(fleshed_material);
+                        });
+                        if (item.circ_lib() != this.currentCourse.owning_lib()) {
+                            this.materialAddDifferentLibraryString.current()
+                            .then(str => this.toast.warning(str));
+                        } else {
+                            this.materialAddSuccessString.current()
+                            .then(str => this.toast.success(str));
+                        }
+                    });
+                }, err => {
+                    this.materialAddFailedString.current()
+                    .then(str => this.toast.danger(str));
+                });
+            });
+        }
+    }
+
+    deleteSelected(items) {
+        let item_ids = [];
+        items.forEach(item => {
+            this.materialsDataSource.data.splice(this.materialsDataSource.data.indexOf(item, 0), 1);
+            item_ids.push(item.id())
+        });
+        this.pcrud.search('acmcm', {course: this.courseId, item: item_ids}).subscribe(material => {
+            material.isdeleted(true);
+            this.pcrud.autoApply(material).subscribe(
+                val => {
+                    this.course.resetItemFields(material, this.currentCourse.owning_lib());
+                    console.debug('deleted: ' + val);
+                    this.materialDeleteSuccessString.current().then(str => this.toast.success(str));
+                },
+                err => {
+                    this.materialDeleteFailedString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            );
+        });
+    }
+}
\ No newline at end of file
index 0ca63cf..180faca 100644 (file)
@@ -3,6 +3,7 @@ import {TreeModule} from '@eg/share/tree/tree.module';
 import {StaffCommonModule} from '@eg/staff/common.module';
 import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {CourseListComponent} from './course-list.component';
+import {CoursePageComponent} from './course-page.component';
 import {CourseAssociateMaterialComponent} from './course-associate-material.component';
 import {CourseReservesRoutingModule} from './routing.module';
 import {ItemLocationSelectModule} from '@eg/share/item-location-select/item-location-select.module';
@@ -10,6 +11,7 @@ import {ItemLocationSelectModule} from '@eg/share/item-location-select/item-loca
 @NgModule({
   declarations: [
     CourseListComponent,
+    CoursePageComponent,
     CourseAssociateMaterialComponent
   ],
   imports: [
index 84e74ff..7c735dd 100644 (file)
@@ -1,8 +1,12 @@
 import {NgModule} from '@angular/core';
 import {RouterModule, Routes} from '@angular/router';
 import {CourseListComponent} from './course-list.component';
+import {CoursePageComponent} from './course-page.component';
 
 const routes: Routes = [{
+    path: ':id',
+    component: CoursePageComponent
+}, {
     path: '',
     component: CourseListComponent
 }];
index 0856cfd..9421300 100644 (file)
@@ -35,6 +35,37 @@ export class CourseService {
         }
     }
 
+    getMaterials(course_ids?: Number[]): Promise<IdlObject[]> {
+        if (!course_ids) {
+            return this.pcrud.retrieveAll('acmcm',
+                {}, {atomic: true}).toPromise();
+        } else {
+            return this.pcrud.search('acmcm', {course: course_ids},
+                {}, {atomic: true}).toPromise();
+        }
+    }
+
+    fleshMaterial(itemId, relationship?): Promise<any> {
+        return new Promise((resolve, reject) => {
+            let item = this.idl.create('acp');
+            this.net.request(
+                'open-ils.circ',
+                'open-ils.circ.copy_details.retrieve',
+                this.auth.token(), itemId
+            ).subscribe(res => {
+                if (res && res.copy) {
+                    item = res.copy;
+                    item.call_number(res.volume);
+                    item._title = res.mvr.title();
+                    item.circ_lib(this.org.get(item.circ_lib()));
+                    if (relationship) item._relationship = relationship;
+                }
+            }, err => {
+                reject(err);
+            }, () => resolve(item));
+        });
+    }
+
     getCoursesFromMaterial(copy_id): Promise<any> {
         let id_list = [];
         return new Promise((resolve, reject) => {
@@ -45,7 +76,7 @@ export class CourseService {
                     id_list.push(materials.course());
                 }
             }, err => {
-                console.log(err);
+                console.debug(err);
                 reject(err);
             }, () => {
                 if (id_list.length) {
@@ -81,7 +112,6 @@ export class CourseService {
 
     // Creating a new acmcm Entry
     associateMaterials(item, args) {
-        console.log("entering associateMaterials")
         let material = this.idl.create('acmcm');
         material.item(item.id());
         material.course(args.currentCourse.id());
@@ -124,7 +154,6 @@ export class CourseService {
                 material.isdeleted(true);
                 this.resetItemFields(material, course_library_hash[material.course()]);
                 this.pcrud.autoApply(material).subscribe(res => {
-                    console.log(res);
                 }, err => {
                     reject(err);
                 }, () => {
@@ -189,7 +218,11 @@ export class CourseService {
                         resolve(item);
                     });
                 } else {
-                    return this.pcrud.update(item);
+                    this.pcrud.update(item).subscribe(rse => {
+                        resolve(item);
+                    }, err => {
+                        reject(item);
+                    });
                 }
             });
         });