lp1849212: Course Admin Page and OPAC improvements
authorKyle Huckins <khuckins@catalyte.io>
Fri, 6 Dec 2019 07:17:53 +0000 (07:17 +0000)
committerGalen Charlton <gmc@equinoxinitiative.org>
Mon, 14 Sep 2020 22:16:31 +0000 (18:16 -0400)
- 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.
- Add bannerStyle and bannerIcon parameters to
eg-staff-banner to improve eg-staff-banner
accessability
- Improve UX of Course Page
- Properly disable inputs and buttons when course
is already archived.
- Change links in Angular Catalog to navigate the user to the
Admin Course Page.
- Prevent users from associating duplicate items to a
course.
- Remove unnecessary artifact from course page html
- Implement Course column to view Course Numbers of classes associated
with individual copies.
- Add a record column to course_module_course_materials
to improve efficiency of API and service methods.
- Add previous circ_modifier adjustments to upgrade script
- Provide Associated Course names and numbers on
OPAC Catalog search results.
- Fixed typo for User Role field, now matcheing DB column, usr_role
- Added boolean "is_public" to differentiate between a role that
should be viewable by anyone on the OPAC.
- Perl Module and Course Page displaying name and course
number of course, as well as names/roles of publicly viewable
members, course details, and a table of all materials associated
with the course.
- URL listed as eg/opac/course/[COURSE_ID]
- Ensure Shelving Location input checkbox
ticks when selecting a value.
- Ensure temporary value inputs are not
cleared when entering a new item.
- Fully select contents of Barcode input
field when selected.
- Clear bBarcode input upon hitting enter.
- Add a new tab for managing Users associated with
the course, in preparation for the User Dialog code.
- Remove excess whitespace on OPAC course page.

Signed-off-by: Kyle Huckins <khuckins@catalyte.io>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Michele Morgan <mmorgan@noblenet.org>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
27 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-associate-material.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/course-reserves/course-associate-material.component.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/catalog/record/copies.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
Open-ILS/src/eg2/src/app/staff/share/course.service.ts
Open-ILS/src/eg2/src/app/staff/share/staff-banner.component.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Course.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Record.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm
Open-ILS/src/sql/Pg/040.schema.asset.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.course-materials-module.sql
Open-ILS/src/templates/opac/course.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/css/style.css.tt2
Open-ILS/src/templates/opac/parts/course/body.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/record/copy_table.tt2
Open-ILS/src/templates/opac/parts/result/table.tt2

index 3bd715d..b96a83d 100644 (file)
@@ -3143,7 +3143,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             <field reporter:label="ID" name="id" reporter:datatype="id" />
             <field reporter:label="Course" name="course" reporter:datatype="link" />
             <field reporter:label="User" name="usr" reporter:datatype="link" />
-            <field reporter:label="User Role" name="user_role" reporter:datatype="text" />
+            <field reporter:label="User Role" name="usr_role" reporter:datatype="text" />
+            <field reporter:label="Is Public Viewable?" name="is_public" reporter:datatype="boolean" />
         </fields>
         <links>
             <link field="course" reltype="has_a" key="id" map="" class="acmc" />
@@ -3169,6 +3170,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             <field reporter:label="ID" name="id" reporter:datatype="id" />
             <field reporter:label="Course" name="course" reporter:datatype="link" />
             <field reporter:label="Item" name="item" reporter:datatype="link" />
+            <field reporter:label="Record" name="record" reporter:datatype="link" />
             <field reporter:label="Item Relationship" name="relationship" reporter:datatype="text" />
             <field reporter:label="Original Status" name="original_status" reporter:datatype="link" />
             <field reporter:label="Original Circ Modifier" name="original_circ_modifier" reporter:datatype="link" />
@@ -3178,6 +3180,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
         <links>
             <link field="course" reltype="has_a" key="id" map="" class="acmc" />
             <link field="item" reltype="has_a" key="id" map="" class="acp" />
+            <link field="record" reltype="has_a" key="id" map="" class="bre" />
             <link field="original_callnumber" reltype="has_a" key="id" map="" class="acn" />
             <link field="original_status" reltype="has_a" key="id" map="" class="ccs" />
             <link field="original_circ_modifier" reltype="has_a" key="code" map="" class="ccm" />
index 488eea7..65dfb71 100644 (file)
@@ -19,7 +19,9 @@
           <div class="input-group-prepend">
             <span class="input-group-text" i18n>Barcode</span>
           </div>
-          <input type="text" [(ngModel)]="barcodeInput" />
+          <input type="text" [(ngModel)]="barcodeInput" 
+            (click)="$event.target.select()"
+            (keyup.enter)="associateItem(barcodeInput, relationshipInput)" />
         </div>
       </div>
       <div class="col-md-5">
             </div>
           </div>
           <eg-item-location-select permFilter="MANAGE_RESERVES"
-            [(ngModel)]="tempLocation" (oninput)="isModifyingLocation = true">
+            [(ngModel)]="tempLocation" (oninput)="isModifyingLocation = true"
+            (valueChange)="isModifyingLocation = true">
           </eg-item-location-select>
           <div class="input-group-append">
             <div class="input-group-text">
index a6c1971..404451a 100644 (file)
@@ -100,12 +100,12 @@ export class CourseAssociateMaterialComponent extends DialogComponent {
                 tempStatus: this.tempStatus,
                 currentCourse: this.currentCourse
             }
+            this.barcodeInput = null;
             
-            this.pcrud.search('acp', {barcode: barcode}, {
+            this.pcrud.search('acp', {barcode: args.barcode}, {
                 flesh: 3, flesh_fields: {acp: ['call_number']}
             }).subscribe(item => {
                 let associatedMaterial = this.courseSvc.associateMaterials(item, args);
-                console.log(associatedMaterial);
                 associatedMaterial.material.then(res => {
                     item = associatedMaterial.item;
                     let new_cn = item.call_number().label();
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..0361526
--- /dev/null
@@ -0,0 +1,272 @@
+<eg-staff-banner
+  bannerText=" {{currentCourse.course_number()}}: {{currentCourse.name()}}"
+  i18n-bannerText class="mb-3" *ngIf="currentCourse"
+  [bannerStyle]="currentCourse.is_archived() == 't' ? 'alert-secondary' : null"
+  [bannerIcon]="currentCourse.is_archived() == 't' ? 'lock' : null">
+</eg-staff-banner>
+<ngb-tabset class="mb-3">
+
+  <!-- Edit Tab -->
+  <ngb-tab title="Edit Course" i18n-title id="edit">
+    <ng-template ngbTabContent>
+      <div class="row">
+        <div class="col-lg-3 mt-3">
+          <button class="p-2 mb-3 btn btn-danger btn-lg"
+            (click)="archiveCourse()" [disabled]="currentCourse && currentCourse.is_archived() == 't'">
+            <i class="material-icons align-middle"
+              *ngIf="currentCourse && currentCourse.is_archived() == 't'">block</i>
+            <span class="align-middle">Archive Course</span>
+          </button>
+        </div>
+        <div class="col-lg-6 mt-3">
+          <eg-fm-record-editor displayMode="inline"
+            hiddenFieldsList="id,is_archived"
+            idlClass="acmc"
+            [preloadLinkedValues]="true"
+            [record]="currentCourse">
+          </eg-fm-record-editor>
+        </div>
+      </div>
+    </ng-template>
+  </ngb-tab>
+
+  <!-- Materials 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()" 
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  (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"
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  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" 
+                [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                (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"
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  (input)="isModifyingCallNumber = true" class="flex-grow-1" />
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingCallNumber"
+                      [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                      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"
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  [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"
+                      [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                      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"
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  [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"
+                      [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                      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" 
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  [(ngModel)]="tempLocation" (valueChange)="isModifyingLocation = true">
+                </eg-item-location-select>
+                <div class="input-group-append">
+                  <div class="input-group-text">
+                    <input type="checkbox" [(ngModel)]="isModifyingLocation"
+                      [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                      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>
+
+  <!-- Users Tab -->
+  <ngb-tab title="Course Users" i18n-title id="courseUsers">
+    <ng-template ngbTabContent>
+      <div class="row mt-3">
+        <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>Patron Barcode</span>
+                </div>
+                <input type="text" class="flex-grow-1" [(ngModel)]="userBarcode"
+                  (click)="$event.target.select()" 
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'" />
+                  <!--(keyup.enter)="associateUser(userBarcode)"-->
+              </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>Role</span>
+                </div>
+                <input type="text" [(ngModel)]="userRoleInput"
+                  [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                  placeholder-i18n placeholder="e.g. Student, TA, Instructor..."
+                  class="flex-grow-1" />
+              </div>
+            </div>
+          </div>
+          <div class="row mt-3">
+            <div class="col-lg-12 text-right">
+              <!--(click)="associateUser(userBarcode)"-->
+              <button class="btn btn-primary"
+                [disabled]="currentCourse && currentCourse.is_archived() == 't'"
+                i18n [disabled]="!userBarcode">
+                Add User
+              </button>
+            </div>
+          </div>
+        </div>
+        <div class="col-lg-8 mt-3">
+          <!-- eg-grid -->
+        </div>
+      </div>
+    </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..3765496
--- /dev/null
@@ -0,0 +1,183 @@
+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;
+
+    // Users Tab
+    @Input() userBarcode: String;
+    @Input() userRoleInput: String;
+    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('t');
+            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 ab44c6c..21b8d9f 100644 (file)
 </ng-template>
 
 <ng-template #courseTemplate let-copy="row">
-  <div *ngFor="let course of copy._courses">{{course.course_number()}}</div>
+  <div *ngFor="let course of copy._courses">
+    <a routerLink="/staff/admin/local/asset/course_list/{{course.id()}}">
+      {{course.course_number()}}
+    </a>
+  </div>
 </ng-template>
 
 <div class='eg-copies w-100 mt-3'>
index 85d75a2..4600a35 100644 (file)
               </ng-container>
               <ng-container *ngIf="has_course">
                 <div i18n>Associated Courses:
-                  {{courseNames.join(', ')}}</div>
+                  <span *ngFor="let course of courses; let isLast=last">
+                    <a routerLink="/staff/admin/local/asset/course_list/{{course.id()}}">
+                      {{course.name()}} ({{course.course_number()}})
+                    </a>{{isLast ? '' : ', '}}
+                  </span>
+                </div>
               </ng-container>
             </div>
           </div>
index 94204c0..8eea366 100644 (file)
@@ -31,7 +31,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
     isRecordSelected: boolean;
     basketSub: Subscription;
     has_course: boolean;
-    courseNames: any[] = [];
+    courses: any[] = [];
 
     constructor(
         private router: Router,
@@ -66,8 +66,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
             if (res) {
                 this.course.fetchCopiesInCourseFromRecord(recordId).then(course_list => {
                     Object.keys(course_list).forEach(key => {
-                        this.courseNames.push(course_list[key].name() +
-                          "(" + course_list[key].course_number() + ")");
+                        this.courses.push(course_list[key]);
                     });
                     this.has_course = true;
                 });
index 0dea4ba..a8e3953 100644 (file)
             <li class="list-group-item" *ngFor="let course of courses">
               <div class="d-flex">
                 <div class="flex-1 font-weight-bold" i18n>Course Name:</div>
-                <div class="flex-3">{{course.name()}}</div>
+                <div class="flex-3">
+                  <a routerLink="/staff/admin/local/asset/course_list/{{course.id()}}">
+                    {{course.name()}}
+                  </a>
+                </div>
                 <div class="flex-1 font-weight-bold" i18n>Course Number:</div>
                 <div class="flex-1">{{course.course_number()}}</div>
                 <div class="flex-1 font-weight-bold" i18n>Section Number:</div>
index 0856cfd..edeeed3 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,9 +112,11 @@ 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());
+        if (item.call_number() && item.call_number().record()) {
+            material.record(item.call_number().record());
+        }
         material.course(args.currentCourse.id());
         if (args.relationship) material.relationship(args.relationship);
 
@@ -124,7 +157,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 +221,11 @@ export class CourseService {
                         resolve(item);
                     });
                 } else {
-                    return this.pcrud.update(item);
+                    this.pcrud.update(item).subscribe(rse => {
+                        resolve(item);
+                    }, err => {
+                        reject(item);
+                    });
                 }
             });
         });
index a45e276..c49c495 100644 (file)
@@ -3,15 +3,19 @@ import {Component, OnInit, Input} from '@angular/core';
 @Component({
   selector: 'eg-staff-banner',
   template: `
-    <div class="lead alert alert-primary text-center pt-1 pb-1">
+    <div class="lead alert alert-primary text-center pt-1 pb-1"
+      [ngClass]="bannerStyle ? bannerStyle : 'alert-primary'">
       <eg-title i18n-prefix [prefix]="bannerText"></eg-title>
-       <span>{{bannerText}}</span>
+      <i class="material-icons align-middle text-left" *ngIf="bannerIcon">{{bannerIcon}}</i>
+       <span class="align-middle">{{bannerText}}</span>
     </div>
     `
 })
 
 export class StaffBannerComponent {
     @Input() public bannerText: string;
+    @Input() public bannerIcon: string;
+    @Input() public bannerStyle: string;
 }
 
 
index bb4412b..0a7c80f 100644 (file)
@@ -1044,6 +1044,156 @@ sub delete_copy_note {
     $e->commit;
     return 1;
 }
+__PACKAGE__->register_method(
+    method          => 'fetch_course_materials',
+    autoritative    => 1,
+    api_name        => 'open-ils.circ.course_materials.retrieve',
+    signature       => q/
+        Returns an array of course materials.
+        @params args     : Supplied object to filter search.
+    /);
+
+__PACKAGE__->register_method(
+    method          => 'fetch_course_materials',
+    autoritative    => 1,
+    api_name        => 'open-ils.circ.course_materials.retrieve.fleshed',
+    signature       => q/
+        Returns an array of course materials, each fleshed out with information
+        from the item and the course_material object.
+        @params args     : Supplied object to filter search.
+    /);
+
+__PACKAGE__->register_method(
+    method          => 'fetch_courses',
+    autoritative    => 1,
+    api_name        => 'open-ils.circ.courses.retrieve',
+    signature       => q/
+        Returns an array of course materials.
+        @params course_id: The id of the course we want to retrieve
+    /);
+
+sub fetch_course_materials {
+    my ($self, $conn, $args) = @_;
+    my $e = new_editor();
+    my $materials = {};
+    my %items;
+
+    $materials->{list} = $e->search_asset_course_module_course_materials($args);
+    return $materials->{list} unless ($self->api_name =~ /\.fleshed/);
+
+    # If we want it fleshed out...
+    for my $course_material (@{$materials->{list}}) {
+        my $material = {};
+        $material->{id} = $course_material->id;
+        $material->{relationship} = $course_material->relationship;
+        $material->{record} = $course_material->record;
+        my $copy = $e->retrieve_asset_copy([
+            $course_material->item, {
+                flesh => 3, flesh_fields => {
+                    'acp' => ['call_number'],
+                    'acn' => ['record']
+                }
+            }
+        ]);
+
+        $material->{item_data} = $copy;
+        $material->{volume_data} = $copy->call_number;
+        $material->{record_data} = $copy->call_number->record;
+        $items{$course_material->item} = $material;
+    }
+
+    my $targets = ();
+    for my $item (values %items) {
+        my $final_item = {};
+        my $mvr = $U->record_to_mvr($item->{record_data});
+        $final_item->{id} = $item->{id};
+        $final_item->{relationship} = $item->{relationship};
+        $final_item->{record} = $item->{record};
+        $final_item->{barcode} = $item->{item_data}->barcode;
+        $final_item->{circ_lib} = $item->{item_data}->circ_lib;
+        $final_item->{title} = $mvr->title;
+        $final_item->{call_number} = $item->{volume_data}->label;
+        $final_item->{location} = $e->retrieve_asset_copy_location(
+            $item->{item_data}->location
+        );
+        $final_item->{status} = $e->retrieve_config_copy_status(
+            $item->{item_data}->status
+        );
+
+        push @$targets, $final_item;
+    }
+
+    return $targets;
+}
+
+sub fetch_courses {
+    my ($self, $conn, @course_ids) = @_;
+    my $e = new_editor();
+
+    return unless @course_ids;
+    my $targets = ();
+    foreach my $course_id (@course_ids) {
+        my $target = $e->retrieve_asset_course_module_course($course_id);
+        push @$targets, $target;
+    }
+
+    return $targets;
+}
+
+__PACKAGE__->register_method(
+    method          => 'fetch_course_users',
+    autoritative    => 1,
+    api_name        => 'open-ils.circ.course_users.retrieve',
+    signature       => q/
+        Returns an array of course users.
+        @params course_id: The id of the course we want to retrieve from
+    /);
+__PACKAGE__->register_method(
+    method          => 'fetch_course_users',
+    autoritative    => 1,
+    api_name        => 'open-ils.circ.course_users.retrieve.staff',
+    signature       => q/
+        Returns an array of course users.
+        @params course_id: The id of the course we want to retrieve from
+    /);
+
+sub fetch_course_users {
+    my ($self, $conn, $course_id) = @_;
+    my $e = new_editor();
+    my $filter = {};
+    my $users = {};
+    my %patrons;
+
+    $filter->{course} = $course_id;
+    $filter->{is_public} = 't'
+        unless ($self->api_name =~ /\.staff/) and $e->allowed('MANAGE_RESERVES');
+    
+    
+    $users->{list} =  $e->search_asset_course_module_course_users($filter);
+    for my $course_user (@{$users->{list}}) {
+        my $patron = {};
+        $patron->{id} = $course_user->id;
+        $patron->{usr_role} = $course_user->usr_role;
+        $patron->{patron_data} = $e->retrieve_actor_user($course_user->usr);
+        $patrons{$course_user->usr} = $patron;
+    }
+
+    my $targets = ();
+    for my $user (values %patrons) {
+        my $final_user = {};
+        $final_user->{id} = $user->{id};
+        $final_user->{usr_role} = $user->{usr_role};
+        $final_user->{first_given_name} = $user->{patron_data}->first_given_name;
+        $final_user->{family_name} = $user->{patron_data}->family_name;
+        $final_user->{pref_first_given_name} = $user->{patron_data}->pref_first_given_name;
+        $final_user->{pref_family_name} = $user->{patron_data}->pref_family_name;
+        
+        push @$targets, $final_user;
+    }
+
+    return $targets;
+
+}
 
 __PACKAGE__->register_method(
     method      => 'fetch_copy_tags',
index c214c1a..2a9548f 100644 (file)
@@ -24,6 +24,7 @@ use OpenILS::WWW::EGCatLoader::Browse;
 use OpenILS::WWW::EGCatLoader::Library;
 use OpenILS::WWW::EGCatLoader::Search;
 use OpenILS::WWW::EGCatLoader::Record;
+use OpenILS::WWW::EGCatLoader::Course;
 use OpenILS::WWW::EGCatLoader::Container;
 use OpenILS::WWW::EGCatLoader::SMS;
 use OpenILS::WWW::EGCatLoader::Register;
@@ -154,6 +155,7 @@ sub load {
     return $self->load_record if $path =~ m|opac/record/\d|;
     return $self->load_cnbrowse if $path =~ m|opac/cnbrowse|;
     return $self->load_browse if $path =~ m|opac/browse|;
+    return $self->load_course if $path =~ m|opac/course|;
 
     return $self->load_mylist_add if $path =~ m|opac/mylist/add|;
     return $self->load_mylist_delete if $path =~ m|opac/mylist/delete|;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Course.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Course.pm
new file mode 100644 (file)
index 0000000..bd340ea
--- /dev/null
@@ -0,0 +1,42 @@
+package OpenILS::WWW::EGCatLoader;
+use strict; use warnings;
+use Apache2::Const -compile => qw(OK DECLINED FORBIDDEN HTTP_GONE HTTP_INTERNAL_SERVER_ERROR REDIRECT HTTP_BAD_REQUEST HTTP_NOT_FOUND);
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Application::AppUtils;
+use Net::HTTP::NB;
+use IO::Select;
+my $U = 'OpenILS::Application::AppUtils';
+
+sub load_course {
+    my $self = shift;
+    my $ctx = $self->ctx;
+
+    $ctx->{page} = 'course';
+    $ctx->{readonly} = $self->cgi->param('readonly');
+
+    my $course_id = $ctx->{page_args}->[0];
+
+    return Apache2::Const::HTTP_BAD_REQUEST
+        unless $course_id and $course_id =~ /^\d+$/;
+
+    $ctx->{course} = $U->simplereq(
+        'open-ils.circ',
+        'open-ils.circ.courses.retrieve',
+        [$course_id]
+    )->[0];
+    
+    $ctx->{instructors} = $U->simplereq(
+        'open-ils.circ',
+        'open-ils.circ.course_users.retrieve',
+        $course_id
+    );
+
+    $ctx->{course_materials} = $U->simplereq(
+        'open-ils.circ',
+        'open-ils.circ.course_materials.retrieve.fleshed',
+        {course => $course_id}
+    );
+    return Apache2::Const::OK;
+}
\ No newline at end of file
index 5032716..ea30359 100644 (file)
@@ -102,8 +102,13 @@ sub load_record {
 
     $ctx->{copies} = $copy_rec->gather(1);
 
+    my $course_module_opt_in = 0;
+    if ($ctx->{get_org_setting}->($org, "circ.course_materials_opt_in")) {
+        $course_module_opt_in = 1;
+    }
+
     # Add public copy notes to each copy - and while we're in there, grab peer bib records
-    # and copy tags
+    # and copy tags. Oh and if we're working with course materials, those too.
     my %cached_bibs = ();
     foreach my $copy (@{$ctx->{copies}}) {
         $copy->{notes} = $U->simplereq(
@@ -111,6 +116,23 @@ sub load_record {
             'open-ils.circ.copy_note.retrieve.all',
             {itemid => $copy->{id}, pub => 1 }
         );
+        if ($course_module_opt_in) {
+            $copy->{course_materials} = $U->simplereq(
+                'open-ils.circ',
+                'open-ils.circ.course_materials.retrieve',
+                {item => $copy->{id}}
+            );
+            my %course_ids;
+            for my $material (@{$copy->{course_materials}}) {
+                $course_ids{$material->course} = 1;
+            }
+
+            $copy->{courses} = $U->simplereq(
+                'open-ils.circ',
+                'open-ils.circ.courses.retrieve',
+                keys %course_ids
+            );
+        }
         $self->timelog("past copy note retrieval call");
         my $meth = 'open-ils.circ.copy_tags.retrieve';
         $meth .= ".staff" if $ctx->{is_staff};
index 459e308..4170b39 100644 (file)
@@ -568,10 +568,32 @@ sub load_rresults {
         }
     }
 
+    my $course_module_opt_in = 0;
+    if ($ctx->{get_org_setting}->($self->_get_search_lib, "circ.course_materials_opt_in")) {
+        $course_module_opt_in = 1;
+    }
+
     for my $rec (@{$ctx->{records}}) {
         my ($res_rec) = grep { $_->[0] == $rec->{$id_key} } @{$results->{ids}};
         $rec->{badges} = [split(',', $res_rec->[1])] if $res_rec->[1];
         $rec->{popularity} = $res_rec->[2];
+        if ($course_module_opt_in) {
+            $rec->{course_materials} = $U->simplereq(
+                'open-ils.circ',
+                'open-ils.circ.course_materials.retrieve',
+                {record => $rec->{id}}
+            );
+            my %course_ids;
+            for my $material (@{$rec->{course_materials}}) {
+                $course_ids{$material->course} = 1;
+            }
+
+            $rec->{courses} = $U->simplereq(
+                'open-ils.circ',
+                'open-ils.circ.courses.retrieve',
+                keys %course_ids
+            );
+        }
         if ($tag_circs) {
             # index 3 (5 for MR) in the per-record result array is a boolean which
             # indicates whether the record in question is in the users
@@ -580,6 +602,7 @@ sub load_rresults {
             $rec->{user_circulated} = 1 if $res_rec->[$index];
         }
     }
+    
 
     $ctx->{search_facets} = $facets;
 
@@ -898,4 +921,4 @@ sub staff_save_search {
     return ($cache_key, $list);
 }
 
-1;
+1;
\ No newline at end of file
index 4fd2f01..7534094 100644 (file)
@@ -1126,10 +1126,12 @@ CREATE TABLE asset.course_module_course_materials (
     course          INT NOT NULL REFERENCES asset.course_module_course (id),
     item            INT NOT NULL REFERENCES asset.copy (id),
     relationship    TEXT,
+    record          INT REFERENCES biblio.record_entry (id),
     original_location      INT REFERENCES asset.copy_location,
     original_status        INT REFERENCES config.copy_status,
     original_circ_modifier TEXT, --REFERENCES config.circ_modifier
-    original_callnumber    INT REFERENCES asset.call_number
+    original_callnumber    INT REFERENCES asset.call_number,
+    unique (course, item)
 );
 
 CREATE TABLE asset.course_module_non_cat_course_materials (
index eb4807e..6b0031e 100644 (file)
@@ -23,10 +23,12 @@ CREATE TABLE asset.course_module_course_materials (
     course          INT NOT NULL REFERENCES asset.course_module_course (id),
     item            INT NOT NULL REFERENCES asset.copy (id),
     relationship    TEXT,
+    record          INT REFERENCES biblio.record_entry (id),
     original_location        INT REFERENCES asset.copy_location,
     original_status          INT REFERENCES config.copy_status,
-    original_circ_modifier   TEXT REFERENCES config.circ_modifier,
-    original_callnumber      INT REFERENCES asset.call_number
+    original_circ_modifier   TEXT, --REFERENCES config.circ_modifier,
+    original_callnumber      INT REFERENCES asset.call_number,
+    unique (course, item)
 );
 
 CREATE TABLE asset.course_module_non_cat_course_materials (
diff --git a/Open-ILS/src/templates/opac/course.tt2 b/Open-ILS/src/templates/opac/course.tt2
new file mode 100644 (file)
index 0000000..e53fc06
--- /dev/null
@@ -0,0 +1,16 @@
+[%- PROCESS "opac/parts/header.tt2";
+    WRAPPER "opac/parts/base.tt2";
+    INCLUDE "opac/parts/topnav.tt2";
+    ctx.page_title = l("Course Details: [_1] - [_2]", ctx.course.name, ctx.course.course_number);
+-%]
+    [%- INCLUDE "opac/parts/searchbar.tt2" %]
+    <h2 class="sr-only">[% l('Course Details') %]</h2>
+    <br class="clear-both" />
+    <div id="content-wrapper" class="content-wrapper-record-page">
+        <div id="main-content">
+            [% INCLUDE "opac/parts/course/body.tt2" %]
+            <div class="common-full-pad"></div>
+        </div>
+        <br class="clear-both" />
+    </div>
+[%- END %]
index 6fe12ea..f998b7f 100644 (file)
@@ -672,7 +672,8 @@ div.format_icon {
     padding-top: 1.5em;
 }
 
-#rdetails_status td {
+#rdetails_status td,
+#course_material_table td {
     [% IF rtl == 't' -%]
     padding: 7px 13px 3px 0px;
     [%- ELSE %]
@@ -685,7 +686,8 @@ div.format_icon {
     white-space: normal;
 }
 
-#rdetails_status thead th {
+#rdetails_status thead th,
+#course_material_table thead th {
     [% IF rtl == 't' -%]
     padding: 13px 13px 13px 0px;
     text-align: right;
@@ -698,7 +700,8 @@ div.format_icon {
     font-weight: bold;
 }
 
-#rdetails_status tbody td {
+#rdetails_status tbody td,
+#course_material_table tbody td {
     [% IF rtl == 't' -%]
     padding-right: 13px;
     text-align: right;
@@ -707,7 +710,8 @@ div.format_icon {
     text-align: left;
     [%- END %]
 }
-#rdetails_status tbody td.copy_note {
+#rdetails_status tbody td.copy_note
+#course_material_table tbody {
     color: [% css_colors.primary %];
     text-wrap:normal;
     white-space:pre-wrap !important;
@@ -1875,7 +1879,7 @@ a.dash-link:hover { text-decoration: underline !important; }
     vertical-align: top;
     [% END -%]
 }
-.rdetail-author-div {
+.rdetail-author-div, .course-instructor-div {
     padding-bottom: 10px;
     display: inline-block;
 }
@@ -2654,7 +2658,7 @@ a.preflib_change {
     display: none;
 }
 
-.rdetail_authors_div {
+.rdetail_authors_div, .course_instructors_div {
     margin-bottom: 1em;
 }
 
@@ -2996,14 +3000,17 @@ a.preflib_change {
     #main-content {
         margin: 0 1px;
     }
-    #rdetails_status thead {
+    #rdetails_status thead,
+    #course_material_table thead {
         display: none;
     }
-    #rdetails_status tr {
+    #rdetails_status tr,
+    #course_material_table tr {
        display: block;
        margin-top: 3px;
     }
-    #rdetails_status td {
+    #rdetails_status td,
+    #course_material_table td {
        display: block;
        padding: 1px;
     }
@@ -3444,3 +3451,13 @@ label[for*=expert_]
 .carousel .glide__arrow--left {
     left: -5em;
 }
+.archived_course {
+    color: [% css_colors.text_badnews %];
+    font-weight: bold;
+}
+.course_details {
+    padding-top: 1em;
+}
+.course_details_div {
+    padding-bottom: 1em;
+}
\ No newline at end of file
diff --git a/Open-ILS/src/templates/opac/parts/course/body.tt2 b/Open-ILS/src/templates/opac/parts/course/body.tt2
new file mode 100644 (file)
index 0000000..d48ce11
--- /dev/null
@@ -0,0 +1,101 @@
+<div id='canvas_main' class='canvas'>
+  <div class="course_details">
+    <div>
+      <h1>
+        [% l(ctx.course.name) %] ([% ctx.course.course_number %])
+        [% IF ctx.course.is_archived == 't' %]
+        <span class="archived_course">
+          This course is inactive.
+        </span>
+        [% END %]
+      </h1>
+      [% IF ctx.instructors.size %]
+      <div class="course_instructors_div">
+        <strong>Course Instructors:</strong>
+        [%- FOR instructor IN ctx.instructors;
+          instructorString = '';
+          IF instructor.pref_family_name;
+            instructorString = instructorString _ instructor.pref_family_name _ ', ';
+          ELSE;
+            instructorString = instructorString _ instructor.family_name _ ', ';
+          END;
+          IF instructor.pref_first_given_name;
+            instructorString = instructorString _ instructor.pref_first_given_name;
+          ELSE;
+            instructorString = instructorString _ instructor.first_given_name;
+          END;
+          instructorString = instructorString _ ' (' _ l(instructor.usr_role) _ ')'; %]
+          <span class="course-instructor-div">[% instructorString %].</span>
+        [% END %]
+      </div>
+      [% END %]
+
+      <div class="course_details_div">
+        <h2>[% l('Course Details') %]</h2>
+        <div>
+          <span><strong>[% l('Course Title') %]: </strong></span>
+          <span>[% ctx.course.name %]</span>
+        </div>
+        <div>
+          <span><strong>[% l('Course Number') %]: </strong></span>
+          <span>[% ctx.course.course_number %]</span>
+        <div>
+        </div>
+          <span><strong>[% l('Section Number') %]: </strong></span>
+          <span>[% ctx.course.section_number %]</span>
+        </div>
+        <div>
+          [%
+            owning_lib = ctx.get_aou(ctx.course.owning_lib);
+            lib_url = ctx.get_org_setting(owning_lib.id, 'lib.info_url');
+            prefer_external_url = ctx.get_org_setting(owning_lib.id, 'lib.prefer_external_url');
+            UNLESS lib_url && prefer_external_url;
+              lib_url = mkurl(ctx.opac_root _ '/library/' _ owning_lib.shortname, {}, 1);
+             END;
+          %]
+          <span><strong>[% l('Owning Library') %]: </strong></span>
+          <span><a href="[% lib_url %]">[% owning_lib.name %]</a></span>
+        </div>
+      </div>
+
+      [%- UNLESS ctx.course.is_archived == 't' %]
+      <div>
+        <span><h2>[% l('Course Materials') %]</h2></span>
+        <table class="table_no_border_space table_no_cell_pad table_no_border" width="100%" id="course_material_table">
+          <thead>
+            <tr>
+              <th scope="col">Location</th>
+              <th scope="col">Call Number</th>
+              <th scope="col">Title</th>
+              <th scope="col">Barcode</th>
+              <th scope="col">Relationship</th>
+              <th scope="col">Status</th>
+              <th scope="col">Shelving Location</th>
+            </tr>
+          </thead>
+          <tbody>
+            [% FOREACH copy_info IN ctx.course_materials %]
+              <tr>
+                <td>[%- INCLUDE "opac/parts/library_name_link.tt2"; -%]
+                    <link property="businessFunction" href="http://purl.org/goodrelations/v1#LeaseOut">
+                    <meta property="price" content="0.00">
+                </td>
+                <td>[% copy_info.call_number %]</td>
+                <td>
+                  <a href="[% mkurl(ctx.opac_root _ '/record/' _ copy_info.record) %]">
+                    [% copy_info.title %]
+                  </a>
+                </td>
+                <td>[% copy_info.barcode %]</td>
+                <td>[% copy_info.relationship %]</td>
+                <td>[% copy_info.status.name %]</td>
+                <td>[% copy_info.location.name %]</td>
+              </tr>
+            [% END %]
+          </tbody>
+        </table>
+      </div>
+      [% END %]
+    </div>
+  </div>
+</div>
index 9d126f6..f09e37b 100644 (file)
@@ -54,6 +54,9 @@ IF has_copies or ctx.foreign_copies;
             [%- END %]
             <th scope='col'>[% l("Status") %]</th>
             <th scope='col'>[% l("Due Date") %]</th>
+            [%- IF ctx.get_org_setting(ctx.aou_tree.id, 'circ.course_materials_opt_in') == 1 %]
+            <th scope='col'>[% l("Courses") %]</th>
+            [%- END %]
         </tr>
     </thead>
     <tbody class="copy_details_table">
@@ -75,6 +78,10 @@ IF has_copies or ctx.foreign_copies;
     <td>[% bib.target_copy.location.name | html %]</td>
     <td>[% bib.target_copy.status.name | html %]</td>
     <td>[% date.format(ctx.parse_datetime(copy_info.due_date, copy_info.circ_circ_lib),DATE_FORMAT) %]</td>
+    [%- IF ctx.get_org_setting(CGI.param('loc') 
+        OR ctx.aou_tree.id, 'circ.course_materials_opt_in') == 1 %]
+    <td></td>
+    [%- END %]
 </tr>
    [%- END; # FOREACH peer
 END; # FOREACH bib
@@ -223,6 +230,11 @@ END; # FOREACH bib
                 ELSE;
                     '-';
                 END %]</td>
+            [%- IF ctx.get_org_setting(ctx.aou_tree.id, 'circ.course_materials_opt_in') == 1 %]
+            <td>[%- FOREACH course IN copy_info.courses %]
+                <div>[% course.course_number %]</div>
+            [% END %]</td>
+            [% END %]
         </tr>
 
         [% IF copy_info.notes; %]
index de5e443..237b8d4 100644 (file)
@@ -405,6 +405,17 @@ END;
                                                     [% UNLESS rec.mmr_id;
                                                             PROCESS "opac/parts/result/copy_counts.tt2";
                                                         END; %]
+                                                    [%- IF ctx.get_org_setting(ctx.search_ou, 'circ.course_materials_opt_in') == 1 %]
+                                                        [%- courseStrings = [] %]
+                                                        [% FOREACH course IN rec.courses %]
+                                                            [% courseString = course.name _ ' (' _ course.course_number _ ')' %]
+                                                            [% courseStrings.push(courseString); %]
+                                                        [% END %]
+                                                        [% IF courseStrings.size > 0 %]
+                                                            <span><strong>[% l('Associated Courses: ') %]</strong></span>
+                                                            <span>[% courseStrings.join(', ') %]</span>
+                                                        [% END %]
+                                                    [% END %]
                                                     [% IF rec.user_circulated %]
                                                     <div class="result_item_circulated">
                                                         <img src="[% ctx.media_prefix %]/images/green_check.png[% ctx.cache_key %]" alt="[% l('Checked Out Before') %]"/>