LP#1402797 webstaff: add basic copy bucket management functionality
authorGalen Charlton <gmc@esilibrary.com>
Thu, 15 Jan 2015 22:12:49 +0000 (22:12 +0000)
committerBill Erickson <berickxx@gmail.com>
Wed, 25 Feb 2015 16:16:05 +0000 (11:16 -0500)
This adds an interface for managing copy buckets, including
adding and removing them, adding items to a pending list and to
copy buckets by barcode, and removing items from a bucket.

Signed-off-by: Galen Charlton <gmc@esilibrary.com>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
13 files changed:
Open-ILS/src/templates/staff/cat/bucket/copy/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_delete.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_info.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_grid_menu.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_load_shared.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/src/templates/staff/t_splash.tt2
Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js [new file with mode: 0644]

diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/index.tt2
new file mode 100644 (file)
index 0000000..9b08f10
--- /dev/null
@@ -0,0 +1,57 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Copy Buckets"); 
+  ctx.page_app = "egCatCopyBuckets";
+  ctx.page_ctrl = "CopyBucketCtrl";
+%]
+
+[% 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/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/bucket/copy/app.js"></script>
+[% END %]
+
+<!-- using native Bootstrap taps because of limitations
+with angular-ui tabsets. it always defaults to making the
+first tab active, so it can't be driven from the route
+https://github.com/angular-ui/bootstrap/issues/910 
+No JS is needed to drive the native tabs, since we're
+changing routes with each tab selection anyway.
+-->
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : tab == 'pending'}">
+    <a href="./cat/bucket/copy/pending/{{bucketSvc.currentBucket.id()}}">
+        [% l('Pending Copies') %]
+        <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'view'}">
+    <a href="./cat/bucket/copy/view/{{bucketSvc.currentBucket.id()}}">
+        [% l('Bucket View') %]
+        <span ng-cloak>({{bucketSvc.currentBucket.items().length}})</span>
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <!-- bucket info header -->
+    <div class="row">
+      <div class="col-md-6">
+        [% INCLUDE 'staff/cat/bucket/copy/t_bucket_info.tt2' %]
+      </div>
+    </div>
+
+    <!-- bucket not accessible warning -->
+    <div class="col-md-10 col-md-offset-1" ng-show="forbidden">
+      <div class="alert alert-warning">
+        [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %]
+      </div>
+    </div>
+
+    <div ng-view></div>
+  </div>
+</div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_create.tt2
new file mode 100644 (file)
index 0000000..e6bb3fe
--- /dev/null
@@ -0,0 +1,35 @@
+<!-- edit bucket dialog -->
+
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"/> 
+          [% l('Publicly Visible?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Create Bucket') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_delete.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_delete.tt2
new file mode 100644 (file)
index 0000000..0ca9887
--- /dev/null
@@ -0,0 +1,16 @@
+<div class="modal-dialog">
+  <div class="modal-content">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Confirm Bucket Delete') %]</h4>
+    </div>
+    <div class="modal-body">
+      <p>[% l('Delete bucket {{bucket().name()}}?') %]</p>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-primary" ng-click="ok()">[% l('Delete Bucket') %]</button>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</div> <!-- modal-dialog -->
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_edit.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_edit.tt2
new file mode 100644 (file)
index 0000000..288c577
--- /dev/null
@@ -0,0 +1,34 @@
+<!-- edit bucket dialog -->
+<form class="form-validated" novalidate ng-submit="ok(args)" name="form">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Edit Bucket') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-bucket-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-bucket-name" ng-model="args.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="edit-bucket-desc">[% l('Description') %]</label>
+        <input type="text" class="form-control" id="edit-bucket-desc"
+          ng-model="args.desc" placeholder="[% l('Description...') %]"/>
+      </div>
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.pub" type="checkbox"> 
+          [% l('Publicly Visible?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" 
+          ng-disabled="form.$invalid" value="[% l('Apply Changes') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()"
+          ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_info.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_info.tt2
new file mode 100644 (file)
index 0000000..877fcf6
--- /dev/null
@@ -0,0 +1,16 @@
+
+<div ng-show="bucket()">
+  <strong>[% l('Bucket: {{bucket().name()}}') %]</strong> 
+  <span>
+    <ng-pluralize count="bucketSvc.currentBucket.items().length"
+      when="{'one': '[% l("1 item") %]', 'other': '[% l("{} items") %]'}">
+    </ng-pluralize>
+  </span> 
+  <span> / [% l('Created {{bucket().create_time() | date}}') %]</span>
+  <span ng-show="bucket().description()"> / {{bucket().description()}}</span>
+</div>
+
+<div ng-show="!bucket()">
+  <strong>[% l('No Bucket Selected') %]</strong>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_selector.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_bucket_selector.tt2
new file mode 100644 (file)
index 0000000..37eef80
--- /dev/null
@@ -0,0 +1,27 @@
+<div class="btn-group text-left" dropdown>
+  <button type="button" class="btn btn-default dropdown-toggle">
+    [% l('Buckets') %]<span class="caret"></span>
+  </button>
+  <ul class="dropdown-menu">
+    <li>
+      <a href='' ng-click="openCreateBucketDialog()">[% l('New Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openEditBucketDialog()">[% l('Edit Bucket') %]</a>
+    </li>
+    <li ng-class="{disabled : !bucket()}">
+      <a href='' ng-click="openDeleteBucketDialog()">[% l('Delete Bucket') %]</a>
+    </li>
+    <li>
+      <a href='' ng-click="openSharedBucketDialog()">[% l('Load Shared Bucket') %]</a>
+    </li>
+    <li role="presentation" class="divider"></li>
+
+    <!-- list all of this user's buckets -->
+    <li ng-repeat="bkt in bucketSvc.allBuckets" 
+      ng-class="{disabled : bkt.id() == bucket().id()}">
+      <a href='' ng-click="loadBucket(bkt.id())">{{bkt.name()}}</a>
+    </li>
+  </ul>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_grid_menu.tt2
new file mode 100644 (file)
index 0000000..a2e2bde
--- /dev/null
@@ -0,0 +1,20 @@
+
+<!-- global grid menu displayed on every Bucket page -->
+<eg-grid-menu-item label="[% l('New Bucket') %]" 
+  handler="openCreateBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Edit Bucket') %]" 
+  handler="openEditBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Delete Bucket') %]" 
+  handler="openDeleteBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item label="[% l('Shared Bucket') %]" 
+  handler="openSharedBucketDialog"></eg-grid-menu-item>
+
+<eg-grid-menu-item divider="true"></eg-grid-menu-item>
+
+<eg-grid-menu-item ng-repeat="bkt in bucketSvc.allBuckets" 
+  label="{{bkt.name()}}" handler-data="bkt" 
+  handler="loadBucketFromMenu"></eg-grid-menu-item>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_load_shared.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_load_shared.tt2
new file mode 100644 (file)
index 0000000..9aab308
--- /dev/null
@@ -0,0 +1,25 @@
+<!-- load bucket by id ("shared") -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(args)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Load Shared Bucket Bucket by ID') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="load-bucket-id">[% l('Bucket ID') %]</label>
+        <!-- NOTE: type='number' / required -->
+        <input type="number" class="form-control" focus-me='focusMe' required
+          id="load-bucket-id" ng-model="args.id" placeholder="[% l('Bucket ID...') %]"/>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Load Bucket') %]"/>
+      <button class="btn btn-warning" 
+          ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_pending.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_pending.tt2
new file mode 100644 (file)
index 0000000..dcd0815
--- /dev/null
@@ -0,0 +1,52 @@
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="search()">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Scan Item') %]</span>
+        <input type="text" class="form-control" focus-me="focusMe"
+        ng-model="bucketSvc.barcodeString" placeholder="[% l('Barcode...') %]">
+      </div>
+    </form>
+  </div>
+</div>
+
+<br/>
+
+<eg-grid
+  ng-hide="forbidden"
+  features="-sort,-multisort,-display"
+  id-field="id"
+  idl-class="acp"
+  auto-fields="true"
+  grid-controls="gridControls"
+  items-provider="gridDataProvider"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.copy.pending">
+
+  [% INCLUDE 'staff/cat/bucket/copy/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Bucket') %]" 
+    handler="addToBucket"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Clear List') %]" 
+    handler="resetPendingList"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="call_number.record.id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"     path='barcode' visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/item/{{item['id']}}">
+      {{item['barcode']}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path="call_number.label" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Location') %]"    path="location.name" visible></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]"
+    path="call_number.record.simple_record.title" visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['call_number.record.id']}}">
+      {{item['call_number.record.simple_record.title']}}
+    </a>
+  </eg-grid-field>
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2 b/Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2
new file mode 100644 (file)
index 0000000..336c1b7
--- /dev/null
@@ -0,0 +1,33 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="-display"
+  id-field="id"
+  idl-class="acp"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.copy.view">
+
+  [% INCLUDE 'staff/cat/bucket/copy/t_grid_menu.tt2' %]
+
+  <eg-grid-action label="[% l('Remove Selected Copies') %]" 
+    handler="detachCopies"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="call_number.record.id" required hidden></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"     path='barcode' visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/item/{{item['id']}}">
+      {{item['barcode']}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path="call_number.label" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Location') %]"    path="location.name" visible></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]"
+    path="call_number.record.simple_record.title" visible>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item['call_number.record.id']}}">
+      {{item['call_number.record.simple_record.title']}}
+    </a>
+  </eg-grid-field>
+
+</eg-grid>
index e94b1ea..35988d4 100644 (file)
               [% l('Record Buckets') %]
             </a>
           </li>
+          <li>
+            <a href="./cat/bucket/copy/view" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('Copy Buckets') %]
+            </a>
+          </li>
           <li class="divider"></li>
           <li>
             <a href="./cat/catalog/retrieve_by_id" target="_self">
index d259698..2884ba6 100644 (file)
             <img src="/xul/server/skin/media/images/portal/bucket.png"/>
             <a target="_self" href="./cat/bucket/record/">[% l('Record Buckets') %]</a>
           </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/bucket.png"/>
+            <a target="_self" href="./cat/bucket/copy/">[% l('Copy Buckets') %]</a>
+          </div>
         </div>
       </div>
     </div>
diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/copy/app.js
new file mode 100644 (file)
index 0000000..061a23b
--- /dev/null
@@ -0,0 +1,453 @@
+/**
+ * Copy Buckets
+ *
+ * Known Issues
+ *
+ * add-all actions only add visible/fetched items.
+ * remove all from bucket UI leaves busted pagination 
+ *   -- apply a refresh after item removal?
+ * problems with bucket view fetching by record ID instead of bucket item:
+ *   -- dupe bibs always sort to the bottom
+ *   -- dupe bibs result in more records displayed per page than requested
+ *   -- item 'pos' ordering is not honored on initial load.
+ */
+
+angular.module('egCatCopyBuckets', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/cat/bucket/copy/pending/:id', {
+        templateUrl: './cat/bucket/copy/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/copy/pending', {
+        templateUrl: './cat/bucket/copy/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/copy/view/:id', {
+        templateUrl: './cat/bucket/copy/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/copy/view', {
+        templateUrl: './cat/bucket/copy/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/cat/bucket/copy/view'});
+})
+
+/**
+ * bucketSvc allows us to communicate between the pending
+ * and view controllers.  It also allows us to cache
+ * data for each so that data reloads are not needed on every 
+ * tab click (i.e. route persistence).
+ */
+.factory('bucketSvc', ['$q','egCore', function($q,  egCore) { 
+
+    var service = {
+        allBuckets : [], // un-fleshed user buckets
+        barcodeString : '', // last scanned barcode
+        barcodeRecords : [], // last scanned barcode results
+        currentBucket : null, // currently viewed bucket
+
+        // per-page list collections
+        pendingList : [],
+        viewList  : [],
+
+        // fetches all staff/copy buckets for the authenticated user
+        // this function may only be called after startup.
+        fetchUserBuckets : function(force) {
+            if (this.allBuckets.length && !force) return;
+            var self = this;
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.retrieve_by_class.authoritative',
+                egCore.auth.token(), egCore.auth.user().id(), 
+                'copy', 'staff_client'
+            ).then(function(buckets) { self.allBuckets = buckets });
+        },
+
+        createBucket : function(name, desc) {
+            var deferred = $q.defer();
+            var bucket = new egCore.idl.ccb();
+            bucket.owner(egCore.auth.user().id());
+            bucket.name(name);
+            bucket.description(desc || '');
+            bucket.btype('staff_client');
+
+            egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.create',
+                egCore.auth.token(), 'copy', bucket
+            ).then(function(resp) {
+                if (resp) {
+                    if (typeof resp == 'object') {
+                        console.error('bucket create error: ' + js2JSON(resp));
+                        deferred.reject();
+                    } else {
+                        deferred.resolve(resp);
+                    }
+                }
+            });
+
+            return deferred.promise;
+        },
+
+        // edit the current bucket.  since we edit the 
+        // local object, there's no need to re-fetch.
+        editBucket : function(args) {
+            var bucket = service.currentBucket;
+            bucket.name(args.name);
+            bucket.description(args.desc);
+            bucket.pub(args.pub);
+            return egCore.net.request(
+                'open-ils.actor',
+                'open-ils.actor.container.update',
+                egCore.auth.token(), 'copy', bucket
+            );
+        }
+    }
+
+    // returns 1 if full refresh is needed
+    // returns 2 if list refresh only is needed
+    service.bucketRefreshLevel = function(id) {
+        if (!service.currentBucket) return 1;
+        if (service.bucketNeedsRefresh) {
+            service.bucketNeedsRefresh = false;
+            service.currentBucket = null;
+            return 1;
+        }
+        if (service.currentBucket.id() != id) return 1;
+        return 2;
+    }
+
+    // returns a promise, resolved with bucket, rejected if bucket is
+    // not fetch-able
+    service.fetchBucket = function(id) {
+        var refresh = service.bucketRefreshLevel(id);
+        if (refresh == 2) return $q.when(service.currentBucket);
+
+        var deferred = $q.defer();
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.flesh.authoritative',
+            egCore.auth.token(), 'copy', id
+        ).then(function(bucket) {
+            var evt = egCore.evt.parse(bucket);
+            if (evt) {
+                console.debug(evt);
+                deferred.reject(evt);
+                return;
+            }
+            service.currentBucket = bucket;
+            deferred.resolve(bucket);
+        });
+
+        return deferred.promise;
+    }
+
+    // deletes a single container item from a bucket by container item ID.
+    // promise is rejected on failure
+    service.detachCopy = function(itemId) {
+        var deferred = $q.defer();
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.item.delete',
+            egCore.auth.token(), 'copy', itemId
+        ).then(function(resp) { 
+            var evt = egCore.evt.parse(resp);
+            if (evt) {
+                console.error(evt);
+                deferred.reject(evt);
+                return;
+            }
+            console.log('detached bucket item ' + itemId);
+            deferred.resolve(resp);
+        });
+
+        return deferred.promise;
+    }
+
+    // delete bucket by ID.
+    // resolved w/ response on successful delete,
+    // rejected otherwise.
+    service.deleteBucket = function(id) {
+        var deferred = $q.defer();
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.full_delete',
+            egCore.auth.token(), 'copy', id
+        ).then(function(resp) {
+            var evt = egCore.evt.parse(resp);
+            if (evt) {
+                console.error(evt);
+                deferred.reject(evt);
+                return;
+            }
+            deferred.resolve(resp);
+        });
+        return deferred.promise;
+    }
+
+    return service;
+}])
+
+/**
+ * Top-level controller.  
+ * Hosts functions needed by all controllers.
+ */
+.controller('CopyBucketCtrl',
+       ['$scope','$location','$q','$timeout','$modal',
+        '$window','egCore','bucketSvc',
+function($scope,  $location,  $q,  $timeout,  $modal,  
+         $window,  egCore,  bucketSvc) {
+
+    $scope.bucketSvc = bucketSvc;
+    $scope.bucket = function() { return bucketSvc.currentBucket }
+
+    // tabs: search, pending, view
+    $scope.setTab = function(tab) { 
+        $scope.tab = tab;
+
+        // for bucket selector; must be called after route resolve
+        bucketSvc.fetchUserBuckets(); 
+    };
+
+    $scope.loadBucketFromMenu = function(item, bucket) {
+        if (bucket) return $scope.loadBucket(bucket.id());
+    }
+
+    $scope.loadBucket = function(id) {
+        $location.path(
+            '/cat/bucket/copy/' + 
+                $scope.tab + '/' + encodeURIComponent(id));
+    }
+
+    $scope.addToBucket = function(recs) {
+        if (recs.length == 0) return;
+        bucketSvc.bucketNeedsRefresh = true;
+
+        angular.forEach(recs,
+            function(rec) {
+                var item = new egCore.idl.ccbi();
+                item.bucket(bucketSvc.currentBucket.id());
+                item.target_copy(rec.id);
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.container.item.create', 
+                    egCore.auth.token(), 'copy', item
+                ).then(function(resp) {
+
+                    // HACK: add the IDs of the added items so that the size
+                    // of the view list will grow (and update any UI looking at
+                    // the list size).  The data stored is inconsistent, but since
+                    // we are forcing a bucket refresh on the next rendering of 
+                    // the view pane, the list will be repaired.
+                    bucketSvc.currentBucket.items().push(resp);
+                });
+            }
+        );
+    }
+
+    $scope.openCreateBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/copy/t_bucket_create',
+            controller: 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }]
+        }).result.then(function (args) {
+            if (!args || !args.name) return;
+            bucketSvc.createBucket(args.name, args.desc).then(
+                function(id) {
+                    if (!id) return;
+                    bucketSvc.viewList = [];
+                    bucketSvc.allBuckets = []; // reset
+                    bucketSvc.currentBucket = null;
+                    $location.path(
+                        '/cat/bucket/copy/' + $scope.tab + '/' + id);
+                }
+            );
+        });
+    }
+
+    $scope.openEditBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/copy/t_bucket_edit',
+            controller: 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.args = {
+                    name : bucketSvc.currentBucket.name(),
+                    desc : bucketSvc.currentBucket.description(),
+                    pub : bucketSvc.currentBucket.pub() == 't'
+                };
+                $scope.ok = function(args) { 
+                    if (!args) return;
+                    $scope.actionPending = true;
+                    args.pub = args.pub ? 't' : 'f';
+                    // close the dialog after edit has completed
+                    bucketSvc.editBucket(args).then(
+                        function() { $modalInstance.close() });
+                }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }]
+        })
+    }
+
+
+    // opens the delete confirmation and deletes the current
+    // bucket if the user confirms.
+    $scope.openDeleteBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/copy/t_bucket_delete',
+            controller : 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.bucket = function() { return bucketSvc.currentBucket }
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function () {
+            bucketSvc.deleteBucket(bucketSvc.currentBucket.id())
+            .then(function() {
+                bucketSvc.allBuckets = [];
+                $location.path('/cat/bucket/copy/view');
+            });
+        });
+    }
+
+    // retrieves the requested bucket by ID
+    $scope.openSharedBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/copy/t_load_shared',
+            controller :
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.focusMe = true;
+                $scope.ok = function(args) {
+                    if (args && args.id) {
+                        $modalInstance.close(args.id)
+                    }
+                }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function(id) {
+            // RecordBucketCtrl $scope is not inherited by the
+            // modal, so we need to call loadBucket from the
+            // promise resolver.
+            $scope.loadBucket(id);
+        });
+    }
+
+}])
+
+.controller('PendingCtrl',
+       ['$scope','$routeParams','bucketSvc','egGridDataProvider', 'egCore',
+function($scope,  $routeParams,  bucketSvc , egGridDataProvider,   egCore) {
+    $scope.setTab('pending');
+
+    var query;
+    $scope.gridControls = {
+        setQuery : function(q) {
+            if (bucketSvc.pendingList.length)
+                return {id : bucketSvc.pendingList};
+            else
+            return null;
+        }
+    }
+
+    $scope.search = function() {
+        bucketSvc.barcodeRecords = [];
+
+        egCore.pcrud.search(
+            'acp',
+            {barcode : bucketSvc.barcodeString, deleted : 'f'},
+            {}
+        ).then(null, null, function(copy) {
+            bucketSvc.pendingList.push(copy.id());
+            $scope.gridControls.setQuery({id : bucketSvc.pendingList});
+        });
+    }
+
+    $scope.resetPendingList = function() {
+        bucketSvc.pendingList = [];
+        $scope.gridControls.setQuery({});
+    }
+    
+    if ($routeParams.id && 
+        (!bucketSvc.currentBucket || 
+            bucketSvc.currentBucket.id() != $routeParams.id)) {
+        // user has accessed this page cold with a bucket ID.
+        // fetch the bucket for display, then set the totalCount
+        // (also for display), but avoid fully fetching the bucket,
+        // since it's premature, in this UI.
+        bucketSvc.fetchBucket($routeParams.id);
+    }
+    $scope.gridControls.setQuery();
+}])
+
+.controller('ViewCtrl',
+       ['$scope','$q','$routeParams','bucketSvc',
+function($scope,  $q , $routeParams,  bucketSvc) {
+
+    $scope.setTab('view');
+    $scope.bucketId = $routeParams.id;
+
+    var query;
+    $scope.gridControls = {
+        setQuery : function(q) {
+            if (q) query = q;
+            return query;
+        }
+    };
+
+    function drawBucket() {
+        return bucketSvc.fetchBucket($scope.bucketId).then(
+            function(bucket) {
+                var ids = bucket.items().map(
+                    function(i){return i.target_copy()}
+                );
+                if (ids.length) {
+                    $scope.gridControls.setQuery({id : ids});
+                } else {
+                    $scope.gridControls.setQuery({});
+                }
+            }
+        );
+    }
+
+    $scope.detachCopies = function(copies) {
+        var promises = [];
+        angular.forEach(copies, function(rec) {
+            var item = bucketSvc.currentBucket.items().filter(
+                function(i) {
+                    return (i.target_copy() == rec.id)
+                }
+            );
+            if (item.length)
+                promises.push(bucketSvc.detachCopy(item[0].id()));
+        });
+
+        bucketSvc.bucketNeedsRefresh = true;
+        return $q.all(promises).then(drawBucket);
+    }
+
+    // fetch the bucket;  on error show the not-allowed message
+    if ($scope.bucketId) 
+        drawBucket()['catch'](function() { $scope.forbidden = true });
+}])