LP#1879983: AngularJS staff interface for curbside pickup
authorGalen Charlton <gmc@equinoxinitiative.org>
Tue, 26 May 2020 21:17:48 +0000 (17:17 -0400)
committerGalen Charlton <gmc@equinoxinitiative.org>
Tue, 15 Sep 2020 20:20:41 +0000 (16:20 -0400)
This adds a new AngularJS page for curbside appointment management. The
page has several tabs:

* To Be Staged appointments. This displays upcoming appointments; from
  here, staff can mark appointments as "staged". Depending on the
  library's curbside workflow, that may mean that the staff member
  places the items in a bag or on a delivery table.

  This tab also allows a staff member to claim (or unclaim)
  responsibility for staging items for an appointment.

* Staged and Ready. This displays staged appointments; from here, staff
  can mark that the patron has arrived, check out the items and mark
  the appointment delivered, or un-stage the appointment.
* Patron Is Outside: from here, staff can check out the items and
  mark the appointment delivered.
* Delivered Today: This displays appointments that were marked as
  delivered.
* Schedule Pickup: This allows staff members to create and modify
  curbside appointments on behalf of a patron.

AngularJS was chosen for this interface to permit backporting the
feature to older versions of Evergreen without having to deal with
variations in the version of Angular that is supported in past
releases. It also better meshes with the patron and circulation staff
interfaces that have not yet been rewritten in Angular.

The curbside pickup page only handles appointments at the workstation
library of the current staff user, as it assumes that the curbside
process is not centralized.

In addition to Galen Charlton, significant contributions to this
patch were made by Mike Rylander.

Sponsored-by: PaILS
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Michele Morgan <mmorgan@noblenet.org>
17 files changed:
Open-ILS/src/templates/staff/circ/curbside/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/web/js/ui/default/staff/circ/curbside/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/navbar.js

diff --git a/Open-ILS/src/templates/staff/circ/curbside/index.tt2 b/Open-ILS/src/templates/staff/circ/curbside/index.tt2
new file mode 100644 (file)
index 0000000..5aee3bc
--- /dev/null
@@ -0,0 +1,56 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Curbside Pickup"); 
+  ctx.page_app = "egCurbsideApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/directives/staged_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/directives/arrived_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/directives/delivered_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+    s.CONFIRM_TAKE_OVER_STAGING_TITLE = "[% l('Take Over Claim for Staging Curbside Pickup Appointment') %]";
+    s.CONFIRM_TAKE_OVER_STAGING_BODY = "[% l('Take over staging pickup appointment [_1] from staff user [_2]?', '{{slot_id}}','{{other_staff}}') %]";
+    s.SUCCESS_CANCEL_APPOINTMENT = "[% l('Canceled curbside appointment {{slot_id}}') %]";
+    s.SUCCESS_CURBSIDE_CLAIM_STAGING = "[% l('Released claim on curbside appointment {{slot_id}} for staging') %]";
+    s.NOTFOUND_CURBSIDE_CLAIM_STAGING = "[% l('Could not find curbside appointment {{slot_id}} to release claim for staging') %]";
+    s.FAILED_CURBSIDE_CLAIM_STAGING = "[% l('Failed to release claim on curbside appointment {{slot_id}} for staging: {{evt_code}}') %]";
+    s.SUCCESS_CURBSIDE_UNCLAIM_STAGING = "[% l('Released claim on curbside appointment {{slot_id}} for staging') %]";
+    s.NOTFOUND_CURBSIDE_UNCLAIM_STAGING = "[% l('Could not find curbside appointment {{slot_id}} to release claim for staging') %]";
+    s.FAILED_CURBSIDE_UNCLAIM_STAGING = "[% l('Failed to release claim on curbside appointment {{slot_id}} for staging: {{evt_code}}') %]";
+    s.SUCCESS_CURBSIDE_MARK_STAGED = "[% l('Marked curbside appointment {{slot_id}} as staged') %]";
+    s.NOTFOUND_CURBSIDE_MARK_STAGED = "[% l('Could not find curbside appointment {{slot_id}} to mark as staged') %]";
+    s.FAILED_CURBSIDE_MARK_STAGED = "[% l('Failed to mark curbside appointment {{slot_id}} as staged: {{evt_code}}') %]";
+    s.SUCCESS_CURBSIDE_MARK_ARRIVED = "[% l('Marked curbside appointment {{slot_id}} as patron arrived') %]";
+    s.NOTFOUND_CURBSIDE_MARK_ARRIVED = "[% l('Could not find curbside appointment {{slot_id}} to mark as patron arrived') %]";
+    s.FAILED_CURBSIDE_MARK_ARRIVED = "[% l('Failed to mark curbside appointment {{slot_id}} as patron arrived: {{evt_code}}') %]";
+    s.SUCCESS_CURBSIDE_MARK_UNSTAGED = "[% l('Marked curbside appointment {{slot_id}} back to to-be-staged') %]";
+    s.NOTFOUND_CURBSIDE_MARK_UNSTAGED = "[% l('Could not find curbside appointment {{slot_id}} to mark as to-be-staged') %]";
+    s.FAILED_CURBSIDE_MARK_UNSTAGED = "[% l('Failed to mark curbside appointment {{slot_id}} as to-be-staged: {{evt_code}}') %]";
+    s.SUCCESS_CURBSIDE_MARK_DELIVERED = "[% l('Marked curbside appointment {{slot_id}} as delivered') %]";
+    s.NOTFOUND_CURBSIDE_MARK_DELIVERED = "[% l('Could not find curbside appointment {{slot_id}} to mark as delivered') %]";
+    s.FAILED_CURBSIDE_MARK_DELIVERED = "[% l('Failed to mark curbside appointment {{slot_id}} as delivered: {{evt_code}}') %]";
+    s.FAILED_CURBSIDE_CHECKOUT = "[% l('Failed to check out an item as part of curbside appointment {{slot_id}}: {{evt_code}}') %]";
+    s.CONFIRM_CANCEL_TITLE = "[% l('Cancel Curbside Pickup Appointment') %]";
+    s.CONFIRM_CANCEL_BODY = "[% l('Cancel curbside pickup appointment [_1]?', '{{slot_id}}') %]";
+    s.SUCCESS_CANCEL_APPOINTMENT = "[% l('Canceled curbside appointment {{slot_id}}') %]";
+    s.FAILED_CANCEL_APPOINTMENT = "[% l('Failed to cancel curbside appointment {{slot_id}} ({{evt_code}})') %]";
+    s.SUCCESS_SAVE_APPOINTMENT = "[% l('Saved curbside appointment {{slot_id}}') %]";
+    s.FAILED_SAVE_APPOINTMENT = "[% l('Failed to save changes to curbside appointment ({{evt_code}})') %]";
+    s.FAILED_SAVE_APPOINTMENT_TOO_MANY = "[% l('Time slot is full; please choose another.') %]";
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_arrived_manager.tt2
new file mode 100644 (file)
index 0000000..0690e73
--- /dev/null
@@ -0,0 +1,44 @@
+<div>
+  <div ng-style="{visibility : refreshNeeded ? 'visible' : 'hidden'}" class="alert alert-warning">
+    [% l('Updates to the list of appointments whose patron has arrived are available. Please refresh.') %]
+  </div>
+  <eg-grid
+    id-field="slot_id"
+    features="-sort,-multisort,-picker,-multiselect"
+    items-provider="gridDataProvider"
+    grid-controls="gridControls"
+    dateformat="{{$root.egDateAndTimeFormat}}">
+
+    <eg-grid-menu-item handler="refresh_arrived" standalone="true"
+        label="[% l('Refresh List')%]"></eg-grid-menu-item>
+
+    <eg-grid-field label="[% l('Pickup Date/Time') %]" path="slot.slot" datatype="timestamp"></eg-grid-field>
+    <eg-grid-field label="[% l('Patron') %]" path="slot.patron" compiled handlers="gridCellHandlers">
+      <a href="./circ/patron/{{item.slot.patron().id()}}/holds" target="_blank">
+        {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}}
+        <span class="glyphicon glyphicon-new-window"></span>
+      </a>
+      <br>
+      <span ng-show="item.slot.notes()">
+        <strong>[% l('Notes:') %]</strong> {{item.slot.notes()}}
+      </span>
+      <div class="alert alert-warning" ng-show="col.handlers.patronIsBlocked(item['slot'].patron())">
+        [% l('Patron is blocked from checkouts.') %]
+      </div>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Appointment ID') %]" path="slot.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Items for Pickup') %]" path="holds" compiled>
+      <div class="alert alert-danger" ng-show="!item['slot'].staged()">
+        [% l('Items are not yet staged!') %]
+      </div>
+      <eg-curbside-holds-list holds="item.holds" bib-data="item.bib_data_by_hold" slot="item.slot"></eg-curbside-holds-list>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Action') %]" handlers="gridCellHandlers" compiled>
+      <button class="btn btn-sm btn-primary"
+        ng-disabled="col.handlers.wasHandled(item['slot_id']) || col.handlers.patronIsBlocked(item['slot'].patron())"
+        ng-click="col.handlers.mark_delivered(item['slot_id'])">
+        [% l('Check Out Items And Mark As Delivered') %]
+      </button>
+    </eg-grid-field>
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_delivered_manager.tt2
new file mode 100644 (file)
index 0000000..f6014da
--- /dev/null
@@ -0,0 +1,31 @@
+<div>
+  <div ng-style="{visibility : refreshNeeded ? 'visible' : 'hidden'}" class="alert alert-warning">
+    [% l('Updates to the list of appointments whose items were delivered are available. Please refresh.') %]
+  </div>
+  <eg-grid
+    id-field="id"
+    features="-sort,-multisort,-picker,-multiselect"
+    items-provider="gridDataProvider"
+    grid-controls="gridControls"
+    dateformat="{{$root.egDateAndTimeFormat}}">
+
+    <eg-grid-menu-item handler="refresh_delivered" standalone="true"
+        label="[% l('Refresh List')%]"></eg-grid-menu-item>
+
+    <eg-grid-field label="[% l('Delivery Date/Time') %]" path="slot.delivered" datatype="timestamp"></eg-grid-field>
+    <eg-grid-field label="[% l('Patron') %]" path="slot.patron" compiled>
+      <a href="./circ/patron/{{item.slot.patron().id()}}/items_out" target="_blank">
+        {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}}
+        <span class="glyphicon glyphicon-new-window"></span>
+      </a>
+      <br>
+      <span ng-show="item.slot.notes()">
+        <strong>[% l('Notes:') %]</strong> {{item.notes()}}
+      </span>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Appointment ID') %]" path="slot.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Items Checked Out') %]" path="holds" compiled>
+      <eg-curbside-holds-list holds="item.holds" bib-data="item.bib_data_by_hold" slot="item.slot"></eg-curbside-holds-list>
+    </eg-grid-field>
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_holds_list.tt2
new file mode 100644 (file)
index 0000000..d9c0682
--- /dev/null
@@ -0,0 +1,12 @@
+<ul style="white-space: normal;">
+  <li ng-repeat="hold in holds">
+    {{bibData[hold.id()].title()}} / {{bibData[hold.id()].author()}}<br>
+    <a href="./cat/item/{{hold.current_copy().id()}}" target="_blank">
+      {{hold.current_copy().barcode()}}
+      <span class="glyphicon glyphicon-new-window"></span>
+    </a>
+    <div ng-if="slot.staged() && slot.staged() < hold.shelf_time()" class="alert alert-warning">
+      [% l('Check item; came in after appointment was staged.') %]
+    </div>
+  </li>
+</ul>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_main.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_main.tt2
new file mode 100644 (file)
index 0000000..afc56ec
--- /dev/null
@@ -0,0 +1,39 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Curbside Pickup') %]</span>
+  </div>
+</div>
+
+<div class="row col-md-12 pad-vert">
+  <div class="col-md-12">
+    <uib-tabset active="active_tab"> 
+      <!-- note that non-numeric index values must be enclosed in single-quotes,
+           otherwise selecting the active table won't work cleanly -->
+      <uib-tab index="'to-be-staged'" heading="[% l('To Be Staged') %]">
+        <div class="container-fluid">
+           <eg-curbside-to-be-staged-manager ng-if="active_tab === 'to-be-staged'"></eg-curbside-to-be-staged-manager>
+        </div>
+      </uib-tab>
+      <uib-tab index="'staged'" heading="[% l('Staged And Ready') %]">
+        <div class="container-fluid">
+           <eg-curbside-staged-manager ng-if="active_tab === 'staged'"></eg-curbside-staged-manager>
+        </div>
+      </uib-tab>
+      <uib-tab index="'arrived'" heading="[% l('Patron Is Outside') %]">
+        <div class="container-fluid">
+           <eg-curbside-arrived-manager ng-if="active_tab === 'arrived'"></eg-curbside-arrived-manager>
+        </div>
+      </uib-tab>
+      <uib-tab index="'delivered'" heading="[% l('Delivered Today') %]">
+        <div class="container-fluid">
+           <eg-curbside-delivered-manager ng-if="active_tab === 'delivered'"></eg-curbside-delivered-manager>
+        </div>
+      </uib-tab>
+      <uib-tab index="'schedule'" heading="[% l('Schedule Pickup') %]">
+        <div class="container-fluid">
+           <eg-curbside-schedule-pickup ng-if="active_tab === 'schedule'"></eg-curbside-schedule-pickup>
+        </div>
+      </uib-tab>
+    </uib-tabset>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_schedule_pickup.tt2
new file mode 100644 (file)
index 0000000..a0cb2b3
--- /dev/null
@@ -0,0 +1,91 @@
+<div class="row">
+  <form ng-submit="submitBarcode(args)" role="form" class="form-inline" name="patronLookup">
+    <div class="input-group">
+
+      <label class="input-group-addon" 
+        for="patron-curbside-barcode" >[% l('Patron Barcode') %]</label>
+
+      <input select-me="selectMe" class="form-control"
+        ng-model="args.barcode" 
+        placeholder="[% l('Patron Barcode') %]"
+        id="patron-curbside-barcode" type="text"/> 
+
+    </div>
+    <input class="btn btn-primary" type="submit" value="[% l('Submit') %]"/>
+    <button ng-click="patron_search()" class="btn btn-success">[% l('Patron Search') %]</button>
+    <button ng-click="clear()" class="btn btn-default">[% l('Clear') %]</button>
+  </form>
+</div>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+<div class="alert alert-warning" ng-show="optInRestricted">
+  [% l("This patron's record is not viewable at your library.") %]
+</div>
+
+<span ng-if="user_id">
+
+  <div class="row">
+    [% l('Patron: [_1] [_2], [_3] [_4] [_5]',
+          '{{patron().pref_prefix() || patron().prefix()}}',
+          '{{patron().pref_family_name() || patron().family_name()}}',
+          '{{patron().pref_first_given_name() || patron().first_given_name()}}',
+          '{{patron().pref_second_given_name() || patron().second_given_name()}}',
+          '{{patron().pref_suffix() || patron().suffix()}}')
+    %]
+  </div>
+  <div class="row">
+    [% l('Patron has [_1] ready holds at this location.', '{{readyHolds}}') %]
+  </div>
+
+  <div class="row">
+    <button ng-disabled="openAppointments.length > 0" ng-click="startNewAppointment()" class="btn btn-success">[% l('Make New Appointment') %]</button>
+  </div>
+  <br>
+  <div class="form-inline" ng-repeat="appt in openAppointments">
+    <ng-form name="forms['curbside' + appt.id]">
+      <div class="row">
+        <div class="col-md-1">
+          <label for="appointment-id">[% l('Appointment') %]</label>
+          <div id="appointment-id">{{appt.id}}</div>
+        </div>
+        <div class="col-md-2">
+          <label for="appointment-day">[% l('Date') %]</label>
+          <eg-date-input id="appointment-day" hide-time-picker ng-model="appt.slot_date"
+                         required min-date="minDate">
+          </eg-date-input>
+        </div>
+        <div class="col-md-2">
+          <label for="appointment-time">[% l('Time') %]</label>
+          <select class="form-control" id="appointment-time" ng-model="appt.slot_time"
+                  name="slot_time" style="display: block;"
+                  required ng-focus="refreshAvailableTimes(appt)">
+            <option value=""></option>
+            <option ng-repeat="t in appt.available_times track by t.time" value="{{t.time}}"
+                    ng-disabled="t.available === 0 && appt.original_slot_time !== t.time">
+              [% l('[_1] (Available: [_2])', '{{t.time_fmt}}', '{{t.available}}') %]
+          </option>
+          </select>
+        </div>
+        <div class="col-md-2">
+          <label for="appointment-notes">[% l('Notes') %]</label>
+          <input class="form-control" type="text" id="appointment-notes" ng-model="appt.notes" style="display: block;"></input>
+        </div>
+        <div class="col-md-2">
+          <label for="appointment-actions">[% l('Actions') %]</label>
+          <div id="appointment-actions">
+            <button ng-click="updateAppointment(appt)" ng-disabled="!forms['curbside' + appt.id].$valid" class="btn btn-primary">[% l('Save') %]</button>
+            <button ng-click="cancelAppointment(appt.id)" ng-disabled="!appt.id" class="btn btn-danger">[% l('Cancel Appointment') %]</button>
+          </div>
+        </div>
+      </div>
+      <div class="row pad-vert">
+        <div ng-if="appt.is_past" class="col-md-offset-1 col-md-4 alert alert-warning">
+          [% l('Appointment is in the past and may need to be rescheduled.') %]
+        </div>
+      </div>
+    </ng-form>
+  </div>
+</span>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_staged_manager.tt2
new file mode 100644 (file)
index 0000000..1615553
--- /dev/null
@@ -0,0 +1,63 @@
+<div>
+  <div ng-style="{visibility : refreshNeeded ? 'visible' : 'hidden'}" class="alert alert-warning">
+    [% l('Updates to the list of staged and ready appointments are available. Please refresh.') %]
+  </div>
+  <eg-grid
+    id-field="slot_id"
+    features="-sort,-multisort,-picker,-multiselect"
+    items-provider="gridDataProvider"
+    grid-controls="gridControls"
+    dateformat="{{$root.egDateAndTimeFormat}}">
+
+    <eg-grid-menu-item handler="refresh_staged" standalone="true"
+        label="[% l('Refresh List')%]"></eg-grid-menu-item>
+
+    <eg-grid-field label="[% l('Pickup Date/Time') %]" path="slot.slot" datatype="timestamp"></eg-grid-field>
+    <eg-grid-field label="[% l('Patron') %]" path="slot.patron" compiled handlers="gridCellHandlers">
+      <a href="./circ/patron/{{item.slot.patron().id()}}/holds" target="_blank">
+        {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}}
+        <span class="glyphicon glyphicon-new-window"></span>
+      </a>
+      <br>
+      <span ng-show="item.slot.notes()">
+        <strong>[% l('Notes:') %]</strong> {{item.slot.notes()}}
+      </span>
+      <div class="alert alert-warning" ng-show="col.handlers.patronIsBlocked(item['slot'].patron())">
+        [% l('Patron is blocked from checkouts.') %]
+      </div>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Appointment ID') %]" path="slot.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Items for Pickup') %]" path="holds" compiled>
+      <eg-curbside-holds-list holds="item.holds" bib-data="item.bib_data_by_hold" slot="item.slot"></eg-curbside-holds-list>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Action') %]" handlers="gridCellHandlers" compiled>
+      <div class="row">
+        <div class="col-xs-12">
+          <button class="btn btn-sm btn-primary"
+            ng-disabled="col.handlers.wasHandled(item['slot_id']) || col.handlers.patronIsBlocked(item['slot'].patron())"
+            ng-click="col.handlers.mark_arrived(item['slot_id'])">
+            [% l('Mark As Patron Arrived') %]
+          </button>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-xs-12">
+          <button class="btn btn-sm btn-success"
+            ng-disabled="col.handlers.wasHandled(item['slot_id']) || col.handlers.patronIsBlocked(item['slot'].patron())"
+            ng-click="col.handlers.mark_delivered(item['slot_id'])">
+            [% l('Check Out Items And Mark As Delivered') %]
+          </button>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-xs-12">
+          <button class="btn btn-sm btn-warning"
+            ng-disabled="col.handlers.wasHandled(item['slot_id'])"
+            ng-click="col.handlers.mark_unstaged(item['slot_id'])">
+            [% l('Set Back to To Be Staged') %]
+          </button>
+        </div>
+      </div>
+    </eg-grid-field>
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2 b/Open-ILS/src/templates/staff/circ/curbside/t_to_be_staged_manager.tt2
new file mode 100644 (file)
index 0000000..096c5b7
--- /dev/null
@@ -0,0 +1,57 @@
+<div>
+  <div ng-style="{visibility : refreshNeeded ? 'visible' : 'hidden'}" class="alert alert-warning">
+    [% l('Updates to the curbside appointment list are available. Please refresh.') %]
+  </div>
+  <eg-grid
+    id-field="slot_id"
+    features="-sort,-multisort,-picker,-multiselect"
+    items-provider="gridDataProvider"
+    grid-controls="gridControls"
+    dateformat="{{$root.egDateAndTimeFormat}}">
+
+    <eg-grid-menu-item handler="refresh_staging" standalone="true"
+        label="[% l('Refresh List')%]"></eg-grid-menu-item>
+
+    <eg-grid-field label="[% l('Pickup Date/Time') %]" path="slot.slot" datatype="timestamp"></eg-grid-field>
+    <eg-grid-field label="[% l('Patron') %]" path="slot.patron" compiled handlers="gridCellHandlers">
+      <a href="./circ/patron/{{item.slot.patron().id()}}/holds" target="_blank">
+        {{item.slot.patron().family_name()}} / {{item.slot.patron().card().barcode()}}
+        <span class="glyphicon glyphicon-new-window"></span>
+      </a>
+      <br>
+      <span ng-show="item.slot.notes()">
+        <strong>[% l('Notes:') %]</strong> {{item.slot.notes()}}
+      </span>
+      <div class="alert alert-warning" ng-show="col.handlers.patronIsBlocked(item['slot'].patron())">
+        [% l('Patron is blocked from checkouts.') %]
+      </div>
+      <div class="alert alert-danger" ng-show="item['slot'].arrival()">
+        [% l('Patron has already arrived!') %]
+      </div>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Appointment ID') %]" path="slot.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Items for Pickup') %]" path="holds" compiled>
+      <eg-curbside-holds-list holds="item.holds" bib-data="item.bib_data_by_hold" slot="item.slot"></eg-curbside-holds-list>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Staging Staff') %]" path="slot.stage_staff" handlers="gridCellHandlers" compiled>
+      {{item.slot.stage_staff().usrname()}}
+      <button class="btn btn-sm btn-default"
+        ng-show="col.handlers.canClaimStaging(item)"
+        ng-click="col.handlers.claim_staging(item)">
+        [% l('Claim') %]
+      </button>
+      <button class="btn btn-sm btn-default"
+        ng-show="col.handlers.canUnclaimStaging(item)"
+        ng-click="col.handlers.unclaim_staging(item)">
+        [% l('Release Claim') %]
+      </button>
+    </eg-grid-field>
+    <eg-grid-field label="[% l('Action') %]" handlers="gridCellHandlers" compiled>
+      <button class="btn btn-sm btn-primary"
+        ng-disabled="col.handlers.wasHandled(item['slot_id']) || col.handlers.patronIsBlocked(item['slot'].patron())"
+        ng-click="col.handlers.mark_staged(item['slot_id'])">
+        [% l('Mark As Staged And Ready') %]
+      </button>
+    </eg-grid-field>
+  </eg-grid>
+</div>
index ac25e56..d8287d8 100644 (file)
               <span>[% l('Offline Circulation') %]</span>
             </a>
           </li>
+          <li ng-if="enableCurbside" class="divider"></li>
+          <li ng-if="enableCurbside">
+            <a href="./circ/curbside/index" target="_self" ng-class="{disabled : curbsideDisabled()}">
+              <span class="glyphicon glyphicon-road"></span>
+              <span>[% l('Curbside Pickup') %]</span>
+            </a>
+          </li>
         </ul>
       </li><!-- circ -->
 
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/app.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/app.js
new file mode 100644 (file)
index 0000000..1ec634b
--- /dev/null
@@ -0,0 +1,59 @@
+angular.module('egCurbsideApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod','ngToast','egCurbsideMod','egCurbsideAppDep']);
+angular.module('egCurbsideAppDep', ['egPatronSearchMod','egUserMod']);
+
+angular.module('egCurbsideApp')
+.config(['ngToastProvider', function(ngToastProvider) {
+  ngToastProvider.configure({
+    verticalPosition: 'bottom',
+    animation: 'fade'
+  });
+}])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|mailto|blob):/); // grid export
+
+    var resolver = {delay : ['egCore', function(egCore) {
+        egCore.env.classLoaders.aous = function() {
+            return egCore.org.settings([
+                'circ.do_not_tally_claims_returned',
+                'circ.tally_lost',
+            ]).then(function(settings) {
+                // local settings are cached within egOrg.  Caching them
+                // again in egEnv just simplifies the syntax for access.
+                egCore.env.aous = settings;
+            });
+        };
+        egCore.env.loadClasses.push('aous');
+
+        return egCore.startup.go()
+    }]};
+
+    $routeProvider.when('/circ/curbside/index', {
+        templateUrl: './circ/curbside/t_main',
+        controller: 'CurbsideCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/curbside/:active_tab', {
+        templateUrl: './circ/curbside/t_main',
+        controller: 'CurbsideCtrl',
+        resolve : resolver
+    });
+
+    // default page
+    $routeProvider.otherwise({redirectTo : '/circ/curbside/index'});
+})
+    
+.controller('CurbsideCtrl',
+       ['$scope','$routeParams','$location','egCurbsideCoreSvc',
+function($scope , $routeParams , $location , egCurbsideCoreSvc ) {
+    $scope.active_tab = $routeParams.active_tab ?  $routeParams.active_tab : 'to-be-staged';
+
+    $scope.$watch('active_tab', function(newVal, oldVal) {
+        if (oldVal != newVal) {
+            var new_path = '/circ/curbside/' + $scope.active_tab;
+            $location.path(new_path);
+        }
+    });
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/arrived_manager.js
new file mode 100644 (file)
index 0000000..6205aea
--- /dev/null
@@ -0,0 +1,122 @@
+angular.module('egCurbsideAppDep')
+
+.directive('egCurbsideArrivedManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: { },
+        templateUrl: './circ/curbside/t_arrived_manager',
+        controller:
+       ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider','egProgressDialog',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval',
+function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , egProgressDialog ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) {
+
+    $scope.gridControls = {};
+
+    $scope.wasHandled = {};
+    $scope.refreshNeeded = false;
+
+    latestTime = undefined;
+    var checkRefresh = undefined;
+    function startRefreshCheck() {
+        if (!angular.isDefined(checkRefresh)) {
+            checkRefresh = $interval(function() {
+                egCurbsideCoreSvc.get_latest_arrived().then(function(latest) {
+                    if (angular.isDefined(latest)) {
+                        if (angular.isDefined(latestTime) && latestTime != latest) {
+                            $scope.refreshNeeded = true;
+                            stopRefreshCheck();
+                        }
+                        latestTime = latest;
+                    }
+                });
+            }, 15000);
+        }
+    }
+    function stopRefreshCheck() {
+        if (angular.isDefined(checkRefresh)) {
+            $interval.cancel(checkRefresh);
+            checkRefresh = undefined;
+        }
+    }
+    this.$onInit = function() {
+        startRefreshCheck();
+    }
+    this.$onDestroy = function() {
+        stopRefreshCheck();
+    }
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            $scope.wasHandled = {};
+            $scope.refreshNeeded = false;
+            startRefreshCheck();
+            return egCurbsideCoreSvc.get_arrived(offset, count);
+        }
+    });
+
+    $scope.refresh_arrived = function() {
+        $scope.gridControls.refresh();
+    }
+
+    $scope.gridCellHandlers = { };
+    $scope.gridCellHandlers.mark_delivered = function(id) {
+        var events_to_handle_later = [];
+        egProgressDialog.open();
+        egCurbsideCoreSvc.mark_delivered(id).then(function(resp) {
+            egProgressDialog.close();
+
+            events_to_handle_later.pop(); // last element is resp, our param
+            if (events_to_handle_later.length) { // this means we got at least one CO attempt
+
+                var bad_event;
+                angular.forEach(events_to_handle_later, function (evt) {
+                    if (bad_event) return; // already warned staff, leave
+                    if (angular.isArray(evt)) evt = evt[0]; // we only need to look at the first event from each CO response
+
+                    evt = egCore.evt.parse(evt);
+                    if (!bad_event && evt && evt.textcode != 'SUCCESS') { // at least one non-success event, show the first event.
+                        bad_event = evt;
+                        ngToast.danger(egCore.strings.$replace(
+                            egCore.strings.FAILED_CURBSIDE_CHECKOUT,
+                            { slot_id : id, evt_code : bad_event.code }
+                        ));
+                    }
+                });
+            }
+
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_MARK_DELIVERED,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            } 
+
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_MARK_DELIVERED,
+                    { slot_id : id }
+                ));
+                return;
+            }
+
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_MARK_DELIVERED,
+                { slot_id : id }
+            ));
+            $scope.wasHandled[id] = true;
+            $timeout(function() { $scope.refresh_arrived() }, 500);
+        },null, function (resp) {
+            events_to_handle_later.push(resp);
+        });
+    }
+    $scope.gridCellHandlers.wasHandled = function(id) {
+        return $scope.wasHandled[id];
+    }
+    $scope.gridCellHandlers.patronIsBlocked = function(usr) {
+        return egCurbsideCoreSvc.patron_blocked(usr);
+    }
+
+}]}});
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/delivered_manager.js
new file mode 100644 (file)
index 0000000..447725b
--- /dev/null
@@ -0,0 +1,63 @@
+angular.module('egCurbsideAppDep')
+
+.directive('egCurbsideDeliveredManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: { },
+        templateUrl: './circ/curbside/t_delivered_manager',
+        controller:
+       ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval',
+function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) {
+
+    $scope.gridControls = {};
+
+    $scope.refreshNeeded = false;
+
+    latestTime = undefined;
+    var checkRefresh = undefined;
+    function startRefreshCheck() {
+        if (!angular.isDefined(checkRefresh)) {
+            checkRefresh = $interval(function() {
+                egCurbsideCoreSvc.get_latest_delivered().then(function(latest) {
+                    if (angular.isDefined(latest)) {
+                        if (angular.isDefined(latestTime) && latestTime != latest) {
+                            $scope.refreshNeeded = true;
+                            stopRefreshCheck();
+                        }
+                        latestTime = latest;
+                    }
+                });
+            }, 15000);
+        }
+    }
+    function stopRefreshCheck() {
+        if (angular.isDefined(checkRefresh)) {
+            $interval.cancel(checkRefresh);
+            checkRefresh = undefined;
+        }
+    }
+    this.$onInit = function() {
+        startRefreshCheck();
+    }
+    this.$onDestroy = function() {
+        stopRefreshCheck();
+    }
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            $scope.refreshNeeded = false;
+            startRefreshCheck();
+            return egCurbsideCoreSvc.get_delivered(offset, count);
+        }
+    });
+
+    $scope.refresh_delivered = function() {
+        $scope.gridControls.refresh();
+    }
+
+    $scope.gridCellHandlers = { };
+
+}]}});
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/schedule_pickup.js
new file mode 100644 (file)
index 0000000..6d8ae65
--- /dev/null
@@ -0,0 +1,396 @@
+angular.module('egCurbsideAppDep')
+
+.directive('egCurbsideSchedulePickup', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: { },
+        templateUrl: './circ/curbside/t_schedule_pickup',
+        controller:
+       ['$scope','$q','egCurbsideCoreSvc','egCore','patronSvc',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast',
+function($scope , $q , egCurbsideCoreSvc , egCore , patronSvc ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast) {
+
+    $scope.clear = function() {
+        $scope.user_id = undefined;
+        $scope.args = {};
+        $scope.readyHolds = 0;
+        $scope.openAppointments = [];
+        $scope.forms = [];
+    }
+    $scope.clear();
+
+    patron_search_dialog = function() {
+        return $uibModal.open({
+            templateUrl: './share/t_patron_selector',
+            backdrop: 'static',
+            size: 'lg',
+            animation: true,
+            controller:
+                   ['$scope','$uibModalInstance','$controller',
+            function($scope , $uibModalInstance , $controller) {
+                angular.extend(this, $controller('BasePatronSearchCtrl', {$scope : $scope}));
+                $scope.clearForm();
+                $scope.need_one_selected = function() {
+                    var items = $scope.gridControls.selectedItems();
+                    return (items.length == 1) ? false : true
+                }
+                $scope.ok = function() {
+                    var items = $scope.gridControls.selectedItems();
+                    if (items.length == 1) {
+                        $uibModalInstance.close(items[0].card().barcode());
+                    } else {
+                        $uibModalInstance.close()
+                    }
+                }
+                $scope.cancel = function($event) {
+                    $uibModalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        });
+    }
+
+    $scope.patron_search = function() {
+        patron_search_dialog().result.then(function(barcode) {
+            $scope.args.barcode = barcode;
+        });
+    }
+
+    // this is blatantly copied from the patron app; if the AngularJS
+    // code had a longer life-expectancy, this would have been moved
+    // to a service.
+    $scope.submitBarcode = function(args) {
+        $scope.bcNotFound = null;
+        $scope.optInRestricted = false;
+        if (!args.barcode) return;
+        args.barcode = args.barcode.replace(/\s/g,'');
+        // blur so next time it's set to true it will re-apply select()
+        $scope.selectMe = false;
+
+        var user_id;
+
+        // given a scanned barcode, this function finds any matching users
+        // and handles multiple matches due to barcode completion
+        function handleBarcodeCompletion(scanned_barcode) {
+            var deferred = $q.defer();
+
+            egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.get_barcodes',
+                egCore.auth.token(), egCore.auth.user().ws_ou(), 
+                'actor', scanned_barcode)
+
+            .then(function(resp) { // get_barcodes
+
+                if (evt = egCore.evt.parse(resp)) {
+                    alert(evt); // FIXME
+                    deferred.reject();
+                    return;
+                }
+
+                if (!resp || !resp[0]) {
+                    $scope.bcNotFound = args.barcode;
+                    $scope.selectMe = true;
+                    egCore.audio.play('warning.patron.not_found');
+                    deferred.reject();
+                    return;
+                }
+
+                if (resp.length == 1) {
+                    // exactly one matching barcode: return it
+                    deferred.resolve();
+                    user_id = resp[0].id;
+                } else {
+                    // multiple matching barcodes: let the user pick one 
+                    var barcode_map = {};
+                    var matches = [];
+                    var promises = [];
+                    var selected_barcode;
+                    angular.forEach(resp, function(match) {
+                        promises.push(
+                            egUser.get(match.id, {useFields : ['home_ou']}).then(function(user) {
+                                barcode_map[match.barcode] = user.id();
+                                matches.push( {
+                                    barcode: match.barcode,
+                                    title: user.first_given_name() + ' ' + user.family_name(),
+                                    org_name: user.home_ou().name(),
+                                    org_shortname: user.home_ou().shortname()
+                                });
+                            })
+                        );
+                    });
+                    return $q.all(promises)
+                    .then(function() {
+                        $uibModal.open({
+                            templateUrl: './circ/share/t_barcode_choice_dialog',
+                            controller:
+                                ['$scope', '$uibModalInstance',
+                                function($scope, $uibModalInstance) {
+                                $scope.matches = matches;
+                                $scope.ok = function(barcode) {
+                                    $uibModalInstance.close();
+                                    selected_barcode = barcode;
+                                }
+                                $scope.cancel = function() {$uibModalInstance.dismiss()}
+                            }],
+                        }).result.then(function() {
+                            deferred.resolve();
+                            user_id = barcode_map[selected_barcode];
+                        });
+                    });
+                }
+            });
+            return deferred.promise;
+        }
+
+        // call our function to lookup matching users for the scanned barcode
+        handleBarcodeCompletion(args.barcode).then(function() {
+
+            // see if an opt-in request is needed
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.user.org_unit_opt_in.check',
+                egCore.auth.token(), user_id
+            ).then(function(optInResp) { // opt_in_check
+
+                if (evt = egCore.evt.parse(optInResp)) {
+                    alert(evt); // FIXME
+                    return;
+                }
+
+                if (optInResp == 2) {
+                    // opt-in disallowed at this location by patron's home library
+                    $scope.optInRestricted = true;
+                    $scope.selectMe = true;
+                    egCore.audio.play('warning.patron.opt_in_restricted');
+                    return;
+                }
+            
+                if (optInResp == 1) {
+                    // opt-in handled or not needed
+                    return loadPatron(user_id);
+                }
+
+                // opt-in needed, show the opt-in dialog
+                egUser.get(user_id, {useFields : []})
+
+                .then(function(user) { // retrieve user
+                    var org = egCore.org.get(user.home_ou());
+                    egConfirmDialog.open(
+                        egCore.strings.OPT_IN_DIALOG_TITLE,
+                        egCore.strings.OPT_IN_DIALOG,
+                        {   family_name : user.family_name(),
+                            first_given_name : user.first_given_name(),
+                            org_name : org.name(),
+                            org_shortname : org.shortname(),
+                            ok : function() { createOptIn(user.id()) },
+                            cancel : function() {}
+                        }
+                    );
+                })
+            })
+        })
+    }
+
+    function countReadyHolds(user_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.patron.ready_holds_at_lib.count',
+            egCore.auth.token(),
+            user_id
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return 0;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    function fetchOpenAppointments(user_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.open_user_appointments_at_lib.atomic',
+            egCore.auth.token(),
+            user_id
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return 0;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    function mungeAvailableTimes(hash, times) {
+        var existing_present = false;
+        if (angular.isDefined(hash.slot_time) && hash.slot_time !== null) {
+            hash.original_slot_time = hash.slot_time;
+        }
+        hash.available_times = times.map(function(t) {
+            if (angular.isDefined(hash.slot_time) && hash.slot_time !== null && hash.slot_time === t[0]) {
+                existing_present = true;
+            }
+            return {
+                time: t[0],
+                available: t[1],
+                time_fmt: moment(t[0], [moment.ISO_8601, 'HH:mm:ss']).format('LT')
+            };
+        });
+        if (angular.isDefined(hash.slot_time) && hash.slot_time !== null && !existing_present) {
+            hash.available_times.unshift({
+                time: hash.slot_time,
+                available: 0,
+                time_fmt: moment(hash.slot_time, [moment.ISO_8601, 'HH:mm:ss']).format('LT')
+            });
+        }
+    }
+
+    function mungeOneAppointment(c, isNew) {
+        var hash = egCore.idl.toHash(c);
+        if (hash.slot === null) {
+            // coerce to today for the purpose of the
+            // form if no slot time has been set yet
+            hash.slot = new Date().toISOString();
+            hash.slot_time = null;
+        } else {
+            if (!isNew) {
+                hash.slot_time = hash.slot.substring(11, 19);
+            }
+        }
+        hash.slot_date = new Date(hash.slot);
+        if (!isNew) {
+            hash.is_past = (hash.slot_date < new Date());
+        }
+        hash.available_times = [];
+        egCore.net.request (
+            'open-ils.curbside',
+            'open-ils.curbside.times_for_date.atomic',
+            egCore.auth.token(),
+            hash.slot.substring(0, 10),
+        ).then(function(times) {
+            mungeAvailableTimes(hash, times);
+        });
+        return hash;
+    }
+
+    function mungeAppointmentList(list) {
+        $scope.openAppointments = list.map(function(c) {
+            var hash = mungeOneAppointment(c);
+            return hash;
+        });
+    }
+
+    function loadPatron(user_id) {
+        $scope.user_id = user_id;
+        patronSvc.getPrimary(user_id);
+        countReadyHolds(user_id).then(function(ct) { $scope.readyHolds = ct });        
+        fetchOpenAppointments(user_id).then(function(list) {
+            mungeAppointmentList(list);
+        });
+    }
+
+
+    $scope.minDate = new Date();
+    $scope.refreshAvailableTimes = function(hash) {
+        var dateStr = (new Date(hash.slot_date)).toISOString().substring(0, 10);
+        egCore.net.request (
+            'open-ils.curbside',
+            'open-ils.curbside.times_for_date.atomic',
+            egCore.auth.token(),
+            dateStr,
+        ).then(function(times) {
+            mungeAvailableTimes(hash, times);
+        });
+    }
+
+    $scope.startNewAppointment = function() {
+        var slot = new egCore.idl.acsp();
+        slot.slot = new Date().toISOString();
+        slot.patron = $scope.user_id;
+        slot.org = egCore.auth.user().ws_ou();
+        $scope.openAppointments = [ mungeOneAppointment(slot, true) ];
+    }
+
+    $scope.updateAppointment = function(appt) {
+        var op = angular.isDefined(appt.id) ? 'update' : 'create';
+        egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.' + op + '_appointment',
+            egCore.auth.token(),
+            $scope.user_id,
+            (new Date(appt.slot_date)).toISOString().substring(0, 10),
+            appt.slot_time,
+            egCore.auth.user().ws_ou(),
+            appt.notes
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                if (evt.textcode === 'CURBSIDE_MAX_FOR_TIME') {
+                    ngToast.danger(egCore.strings.$replace(
+                        egCore.strings.FAILED_SAVE_APPOINTMENT_TOO_MANY,
+                        { evt_code : evt.code }
+                    ));
+                } else {
+                    ngToast.danger(egCore.strings.$replace(
+                        egCore.strings.FAILED_SAVE_APPOINTMENT,
+                        { evt_code : evt.code }
+                    ));
+                }
+            } else {
+                ngToast.success(egCore.strings.$replace(
+                    egCore.strings.SUCCESS_SAVE_APPOINTMENT,
+                    { slot_id : resp.id() }
+                ));
+            }
+            fetchOpenAppointments($scope.user_id).then(function(list) {
+                mungeAppointmentList(list);
+            });
+        });
+    }
+
+    function doCancel(id) {
+        egCore.net.request (
+            'open-ils.curbside',
+            'open-ils.curbside.delete_appointment',
+            egCore.auth.token(),
+            id
+        ).then(function(resp) {
+            if (!angular.isDefined(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CANCEL_APPOINTMENT,
+                    { slot_id : id, evt_code : 'NO_SUCH_APPOINTMENT' }
+                ));
+            } else if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CANCEL_APPOINTMENT,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+            } else {
+                ngToast.success(egCore.strings.$replace(
+                    egCore.strings.SUCCESS_CANCEL_APPOINTMENT,
+                    { slot_id : id }
+                ));
+            }
+            fetchOpenAppointments($scope.user_id).then(function(list) {
+                mungeAppointmentList(list);
+            });
+        });
+    }
+    $scope.cancelAppointment = function(id) {
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_CANCEL_TITLE,
+            egCore.strings.CONFIRM_CANCEL_BODY,
+            {   slot_id : id,
+                ok : function() { doCancel(id) },
+                cancel : function() {}
+            }
+        );
+    }
+
+    $scope.patron = function() {
+        return patronSvc.current;
+    }
+
+}]}});
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/staged_manager.js
new file mode 100644 (file)
index 0000000..2f0050c
--- /dev/null
@@ -0,0 +1,145 @@
+angular.module('egCurbsideAppDep')
+
+.directive('egCurbsideStagedManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: { },
+        templateUrl: './circ/curbside/t_staged_manager',
+        controller:
+       ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider','egProgressDialog',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval',
+function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider , egProgressDialog ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) {
+
+    $scope.gridControls = {};
+
+    $scope.wasHandled = {};
+    $scope.refreshNeeded = false;
+
+    latestTime = undefined;
+    var checkRefresh = undefined;
+    function startRefreshCheck() {
+        if (!angular.isDefined(checkRefresh)) {
+            checkRefresh = $interval(function() {
+                egCurbsideCoreSvc.get_latest_staged().then(function(latest) {
+                    if (angular.isDefined(latest)) {
+                        if (angular.isDefined(latestTime) && latestTime != latest) {
+                            $scope.refreshNeeded = true;
+                            stopRefreshCheck();
+                        }
+                        latestTime = latest;
+                    }
+                });
+            }, 15000);
+        }
+    }
+    function stopRefreshCheck() {
+        if (angular.isDefined(checkRefresh)) {
+            $interval.cancel(checkRefresh);
+            checkRefresh = undefined;
+        }
+    }
+    this.$onInit = function() {
+        startRefreshCheck();
+    }
+    this.$onDestroy = function() {
+        stopRefreshCheck();
+    }
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            $scope.wasHandled = {};
+            $scope.refreshNeeded = false;
+            startRefreshCheck();
+            return egCurbsideCoreSvc.get_staged(offset, count);
+        }
+    });
+
+    $scope.refresh_staged = function() {
+        $scope.gridControls.refresh();
+    }
+
+    $scope.gridCellHandlers = { };
+    $scope.gridCellHandlers.mark_arrived = function(id) {
+        egCurbsideCoreSvc.mark_arrived(id).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_MARK_ARRIVED,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            } 
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_MARK_ARRIVED,
+                    { slot_id : id }
+                ));
+                return;
+            }
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_MARK_ARRIVED,
+                { slot_id : id }
+            ));
+            $scope.wasHandled[id] = true;
+            $timeout(function() { $scope.refresh_staged() }, 500);
+        });
+    }
+    $scope.gridCellHandlers.mark_unstaged = function(id) {
+        egCurbsideCoreSvc.mark_unstaged(id).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_MARK_UNSTAGED,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            } 
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_MARK_UNSTAGED,
+                    { slot_id : id }
+                ));
+                return;
+            }
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_MARK_UNSTAGED,
+                { slot_id : id }
+            ));
+            $scope.wasHandled[id] = true;
+            $timeout(function() { $scope.refresh_staged() }, 500);
+        });
+    }
+    $scope.gridCellHandlers.mark_delivered = function(id) {
+        egProgressDialog.open();
+        egCurbsideCoreSvc.mark_delivered(id).then(function(resp) {
+            egProgressDialog.close();
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_MARK_DELIVERED,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            }
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_MARK_DELIVERED,
+                    { slot_id : id }
+                ));
+                return;
+            }
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_MARK_DELIVERED,
+                { slot_id : id }
+            ));
+            $scope.wasHandled[id] = true;
+            $timeout(function() { $scope.refresh_staged() }, 500);
+        });
+    }
+    $scope.gridCellHandlers.wasHandled = function(id) {
+        return $scope.wasHandled[id];
+    }
+    $scope.gridCellHandlers.patronIsBlocked = function(usr) {
+        return egCurbsideCoreSvc.patron_blocked(usr);
+    }
+
+}]}});
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/directives/to_be_staged_manager.js
new file mode 100644 (file)
index 0000000..993ecac
--- /dev/null
@@ -0,0 +1,192 @@
+angular.module('egCurbsideAppDep')
+
+.directive('egCurbsideToBeStagedManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: { },
+        templateUrl: './circ/curbside/t_to_be_staged_manager',
+        controller:
+       ['$scope','$q','egCurbsideCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast','$interval',
+function($scope , $q , egCurbsideCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast , $interval) {
+
+    $scope.gridControls = {};
+
+    $scope.wasHandled = {};
+    $scope.refreshNeeded = false;
+
+    latestTime = undefined;
+    var checkRefresh = undefined;
+    function startRefreshCheck() {
+        if (!angular.isDefined(checkRefresh)) {
+            checkRefresh = $interval(function() {
+                egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) {
+                    if (angular.isDefined(latest)) {
+                        if (angular.isDefined(latestTime) && latestTime != latest) {
+                            $scope.refreshNeeded = true;
+                            stopRefreshCheck();
+                        }
+                        latestTime = latest;
+                    }
+                });
+            }, 5000);
+        }
+    }
+    function stopRefreshCheck() {
+        if (angular.isDefined(checkRefresh)) {
+            $interval.cancel(checkRefresh);
+            checkRefresh = undefined;
+        }
+    }
+    this.$onInit = function() {
+        startRefreshCheck();
+    }
+    this.$onDestroy = function() {
+        stopRefreshCheck();
+    }
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            $scope.wasHandled = {};
+            $scope.refreshNeeded = false;
+            startRefreshCheck();
+            return egCurbsideCoreSvc.get_to_be_staged(offset, count);
+        }
+    });
+
+    $scope.refresh_staging = function() {
+        $scope.gridControls.refresh();
+    }
+
+    $scope.gridCellHandlers = { };
+    $scope.gridCellHandlers.mark_staged = function(id) {
+        egCurbsideCoreSvc.mark_staged(id).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_MARK_STAGED,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            } 
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_MARK_STAGED,
+                    { slot_id : id }
+                ));
+                return;
+            }
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_MARK_STAGED,
+                { slot_id : id }
+            ));
+            $scope.wasHandled[id] = true;
+            $timeout(function() { $scope.refresh_staging() }, 500);
+        });
+    }
+    $scope.gridCellHandlers.wasHandled = function(id) {
+        return $scope.wasHandled[id];
+    }
+    $scope.gridCellHandlers.patronIsBlocked = function(usr) {
+        return egCurbsideCoreSvc.patron_blocked(usr);
+    }
+    $scope.gridCellHandlers.canClaimStaging = function(item) {
+        if ($scope.wasHandled[item.slot_id]) return false;
+        if (!item.slot.stage_staff()) return true;
+        if (item.slot.stage_staff().id() == egCore.auth.user().id()) return false;
+        return true;
+    }
+    $scope.gridCellHandlers.canUnclaimStaging = function(item) {
+        if ($scope.wasHandled[item.slot_id]) return false;
+        if (!item.slot.stage_staff()) return false;
+        if (item.slot.stage_staff().id() == egCore.auth.user().id()) return true;
+        return false;
+    }
+    $scope.gridCellHandlers.claim_staging = function(item) {
+        console.debug('claim');
+    }
+    doClaimStaging = function(item) {
+        var id = item.slot_id;
+        egCurbsideCoreSvc.claim_staging(id).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_CLAIM_STAGING,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            }
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_CLAIM_STAGING,
+                    { slot_id : id }
+                ));
+                return;
+            }
+
+            item.slot = resp;
+
+            // attempt to avoid a spurious refresh prompt
+            egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) {
+                if (angular.isDefined(latest)) {
+                    latestTime = latest
+                }
+            });
+
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_CLAIM_STAGING,
+                { slot_id : id }
+            ));
+        });
+    }
+    $scope.gridCellHandlers.claim_staging = function(item) {
+        if (item.slot.stage_staff() &&
+            item.slot.stage_staff().id() !== egCore.auth.user().id()) {
+            egConfirmDialog.open(
+                egCore.strings.CONFIRM_TAKE_OVER_STAGING_TITLE,
+                egCore.strings.CONFIRM_TAKE_OVER_STAGING_BODY,
+                {   slot_id : item.slot_id,
+                    other_staff : item.slot.stage_staff().usrname(),
+                    ok : function() { doClaimStaging(item) },
+                    cancel : function() {}
+                }
+            );
+        } else {
+            doClaimStaging(item);
+        }
+    }
+    $scope.gridCellHandlers.unclaim_staging = function(item) {
+        var id = item.slot_id;
+        egCurbsideCoreSvc.unclaim_staging(id).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                ngToast.danger(egCore.strings.$replace(
+                    egCore.strings.FAILED_CURBSIDE_UNCLAIM_STAGING,
+                    { slot_id : id, evt_code : evt.code }
+                ));
+                return;
+            }
+            if (!angular.isDefined(resp)) {
+                ngToast.warning(egCore.strings.$replace(
+                    egCore.strings.NOTFOUND_CURBSIDE_UNCLAIM_STAGING,
+                    { slot_id : id }
+                ));
+                return;
+            }
+
+            item.slot = resp;
+
+            // attempt to avoid a spurious refresh prompt
+            egCurbsideCoreSvc.get_latest_to_be_staged().then(function(latest) {
+                if (angular.isDefined(latest)) {
+                    latestTime = latest
+                }
+            });
+
+            ngToast.success(egCore.strings.$replace(
+                egCore.strings.SUCCESS_CURBSIDE_UNCLAIM_STAGING,
+                { slot_id : id }
+            ));
+        });
+    }
+
+}]}});
diff --git a/Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js b/Open-ILS/web/js/ui/default/staff/circ/curbside/services/core.js
new file mode 100644 (file)
index 0000000..e2b1e6c
--- /dev/null
@@ -0,0 +1,192 @@
+angular.module('egCurbsideMod', ['egCoreMod'])
+.factory('egCurbsideCoreSvc',
+       ['egCore','orderByFilter','$q','$filter','$uibModal','ngToast','egConfirmDialog',
+function(egCore , orderByFilter , $q , $filter , $uibModal , ngToast , egConfirmDialog) {
+    var service = { };
+
+    service.get_to_be_staged = function(offset, count) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_to_be_staged',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou(),
+            count, // yep, count first
+            offset
+        );
+    };
+    service.get_latest_to_be_staged = function() {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_to_be_staged.latest',
+            egCore.auth.token()
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return undefined;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    service.get_staged = function(offset, count) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_staged',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou(),
+            count, // yep, count first
+            offset
+        );
+    };
+    service.get_latest_staged = function() {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_staged.latest',
+            egCore.auth.token()
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return undefined;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    service.get_arrived = function(offset, count) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_arrived',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou(),
+            count, // yep, count first
+            offset
+        );
+    };
+    service.get_latest_arrived = function() {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_arrived.latest',
+            egCore.auth.token()
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return undefined;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    service.get_delivered = function(offset, count) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_delivered',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou(),
+            count, // yep, count first
+            offset
+        );
+    };
+    service.get_latest_delivered = function() {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.fetch_delivered.latest',
+            egCore.auth.token()
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                return undefined;
+            } else {
+                return resp;
+            }
+        });
+    }
+
+    service.mark_staged = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.mark_staged',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+    service.mark_unstaged = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.mark_unstaged',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+    service.mark_arrived = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.mark_arrived',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+    service.mark_delivered = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.mark_delivered',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+
+    service.claim_staging = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.claim_staging',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+    service.unclaim_staging = function(slot_id) {
+        return egCore.net.request(
+            'open-ils.curbside',
+            'open-ils.curbside.unclaim_staging',
+            egCore.auth.token(),
+            slot_id
+        );
+    }
+
+    service.patron_blocked = function(usr) {
+        if (usr.barred() == 't' ||
+            usr.active() == 'f') {
+            return true;
+        }
+        var expire = Date.parse(usr.expire_date());
+        if (expire < new Date()) {
+            return true;
+        }
+        var blocked_by_penalty = false;
+        angular.forEach(usr.standing_penalties(), function(penalty) {
+            if (blocked_by_penalty) return;
+            if (penalty.stop_date()) return;
+            if (!penalty.standing_penalty().block_list()) return;
+            if (penalty.standing_penalty().block_list().match(/CIRC/))
+                blocked_by_penalty = true;
+        });
+        return blocked_by_penalty;
+    }
+
+    return service;
+}])
+
+.directive('egCurbsideHoldsList', function() {
+    return {
+        restrict : 'E',
+        transclude: true,
+        templateUrl : './circ/curbside/t_holds_list',
+        scope : {
+            slot : '=',
+            holds : '=',
+            bibData : '='
+        },
+        controller : [
+                    '$scope','egCore',
+            function($scope , egCore) {
+            }
+        ]
+    }
+});
index 06ba380..58e2d3a 100644 (file)
@@ -122,7 +122,8 @@ angular.module('egCoreMod')
 
                             egCore.org.settings([
                                 'ui.staff.max_recent_patrons',
-                                'ui.staff.angular_catalog.enabled'
+                                'ui.staff.angular_catalog.enabled',
+                                'circ.curbside'
                             ]).then(function(s) {
                                 var val = s['ui.staff.max_recent_patrons'];
                                 $scope.showRecentPatron = val > 0;
@@ -130,6 +131,8 @@ angular.module('egCoreMod')
 
                                 $scope.showAngularCatalog = 
                                     s['ui.staff.angular_catalog.enabled'];
+                                $scope.enableCurbside = 
+                                    s['circ.curbside'];
                             }).then(function() {
                                 // need to defer initialization of hotkeys to this point
                                 // as it depends on various settings.