--- /dev/null
+/**
+ * TODO:
+ * Instead of making pcrud calls, followed by per-item server calls,
+ * consider a server-side API for all of this stuff for speed, etc.
+ *
+ * Consolidate the various item structures into one common,
+ * authoritative structure for display and print templates.
+ */
+
+
+angular.module('ffMain',
+['ngRoute', 'egNetMod', 'egAuthMod', 'egStartupMod',
+ 'egUserMod', 'egUiMod', 'egFlattenerMod', 'egPCRUDMod', 'egOrgMod'])
+
+.config(function($routeProvider, $locationProvider) {
+
+ // The route-specified controller will not get instantiated
+ // until the promise returned by this function is resolved
+ var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+ // record management UI
+ $routeProvider.when('/fulfillment/records', {
+ templateUrl: './fulfillment/records',
+ controller: 'ILLCtrl',
+ resolve : resolver
+ });
+
+ // Default to ILL management tabs
+ $routeProvider.when('/fulfillment/status/:barcode', {
+ templateUrl: './fulfillment/ill',
+ controller: 'ILLCtrl',
+ resolve : resolver
+ });
+
+
+ // Default to ILL management tabs
+ $routeProvider.otherwise({
+ templateUrl: './fulfillment/ill',
+ controller: 'ILLCtrl',
+ resolve : resolver
+ });
+
+ $locationProvider.html5Mode(true);
+})
+
+/**
+ * Data shared across FF controllers.
+ */
+.factory('ffService',
+['$rootScope', 'egOrg', 'egAuth',
+function($rootScope, egOrg, egAuth) {
+ return {
+ orgList : function() {
+ // we want all orgs
+ return egOrg.list();
+ },
+
+ // currently selected org unit
+ currentOrg : function(id) {
+ if (id) {
+ this.org = id;
+ } else if (!this.org) {
+ this.org = egAuth.user().ws_ou();
+ }
+ return egOrg.get(this.org);
+ },
+
+ /** returns list of IDs for all org units within the
+ * full path of currentOrg. Useful for pcrud queries.
+ */
+ relatedOrgs : function(id) {
+ return egOrg.fullPath(
+ this.currentOrg()).map(function(o) {return o.id()});
+ }
+ }
+}])
+
+/**
+ * Top-level page controller. Handles global components, like the org
+ * selector.
+ */
+.controller('FFMainCtrl', ['$scope', '$route', 'egStartup', 'ffService', 'egAuth',
+function ($scope, $route, egStartup, ffService, egAuth) {
+
+ // run after startup so we can guarantee access to org units
+ egStartup.go().then(function() {
+ $scope.ffService = ffService;
+ });
+
+ // change the selected org unit and re-draw the page
+ $scope.selectOrg = function(id) {
+ ffService.currentOrg(id);
+ $route.reload();
+ }
+
+ $scope.logout = function() {
+ egAuth.logout();
+ return true;
+ };
+
+}])
+
+
+/**
+ * Main ILL controller.
+ * Maintains the table data / attributes.
+ * Performs actions.
+ */
+.controller('ILLCtrl',
+['$scope', '$q', '$compile', '$timeout', '$rootScope', '$location',
+ '$route', '$routeParams', 'egNet', 'egAuth', 'ffService', 'egOrg',
+function ($scope, $q, $compile, $timeout, $rootScope, $location,
+ $route, $routeParams, egNet, egAuth, ffService, egOrg) {
+
+ // tabs
+ var mytab = $location.path().match(/\/fulfillment\/([^\/]+)/)[1];
+ $scope['tab_' + mytab] = true;
+
+ // so our child controllers can access our route info
+ $scope.illRouteParams = $routeParams;
+
+ // This bit of scope is used directly by all child scopes.
+ // Inherited scopes use shallow copies, hence the nested object.
+ $scope.itemList = {
+ items : [],
+ selected : {},
+ limit : 10, // TODO UI
+ offset : 0, // TODO UI
+ filter_borrwer : true,
+ filterLender : false,
+
+ toggleFilters : function(lender) {
+ $scope.itemList.filterBorrower = !lender;
+ $scope.itemList.filterLender = lender;
+ },
+
+ // select all rows in the list. if any are
+ // already selected, de-select all.
+ selectAll : function() {
+ var action = true;
+ angular.forEach($scope.itemList.selected, function(val) {
+ if (val) action = false;
+ });
+ angular.forEach($scope.itemList.items, function(item) {
+ if (action) {
+ $scope.itemList.selected[item.index] = action;
+ } else {
+ delete $scope.itemList.selected[item.index];
+ }
+ });
+ },
+
+ addItem : function(item) {
+ // TODO: id version
+ item.index = $scope.itemList.items.length;
+ $scope.itemList.items.push(item);
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.item.transaction.disposition',
+ egAuth.token(),
+ ffService.currentOrg().id(),
+ item.barcode
+ ).then(function(items) {
+ if (items[0]) {
+ $scope.itemList.flattenItem(item, items[0]);
+ } else {
+ $scope.itemList.items.pop().not_found = true;
+ }
+ });
+ },
+
+ // given an item disposition blob, flatten it for display
+ flattenItem : function(item, item_data) {
+ /*
+ * TODO: most of this is unnecessary, since we can access
+ * fields directly in the template. Consider pairing
+ * this down to fields that need munging only
+ */
+ var copy = item_data.copy;
+ var transit = item_data.transit;
+ var circ = item_data.circ;
+ var hold = item_data.hold;
+ if (hold) {
+ if (!transit && hold.transit()) {
+ transit = item_data.hold.transit();
+ }
+ } else if (transit && transit.hold_transit_copy()) {
+ hold = transit.hold_transit_copy().hold();
+ }
+
+ item.copy = item_data.copy;
+ item.item_barcode = copy.barcode();
+ item.item_barcode_enc = encodeURIComponent(copy.barcode());
+ item.source_lib = egOrg.get(copy.source_lib()).shortname();
+ item.circ_lib = egOrg.get(copy.circ_lib()).shortname();
+ item.title = copy.call_number().record().simple_record().title();
+ item.author = copy.call_number().record().simple_record().author();
+ item.call_number = copy.call_number().label();
+ item.bib_id = copy.call_number().record().id();
+ item.remote_bib_id = copy.call_number().record().remote_id();
+ item.next_action = item_data.next_action;
+ item.can_cancel_hold = (item_data.can_cancel_hold == 1);
+ item.can_retarget_hold = (item_data.can_retarget_hold == 1);
+
+ switch(item_data.next_action) {
+ // capture lender copy for hold
+ case 'ill-home-capture' :
+ item.needs_capture = true;
+ break;
+ // receive item at borrower
+ case 'ill-foreign-receive':
+ // receive lender copy back home
+ case 'transit-home-receive':
+ // transit item for cancelled hold back home (or next hold)
+ case 'transit-foreign-return':
+ item.needs_receive = true;
+ break;
+ // complete borrower circ, transit item back home
+ case 'ill-foreign-checkin':
+ item.needs_checkin = true;
+ break;
+ // check out item to borrowing patron
+ case 'ill-foreign-checkout':
+ item.needs_checkout = true;
+ break;
+ }
+
+ item.status_str = copy.status().name();
+ if (copy.status().id() == 8 /* holds shelf */ &&
+ hold &&
+ hold.transit() &&
+ hold.transit().dest_recv_time()) {
+ item.status_str += ' @ ' + egOrg.get(hold.transit().dest()).shortname();
+ } else if (copy.status().id() == 1 /* checked out */ && circ) {
+ item.status_str += ' @ ' + egOrg.get(circ.circ_lib()).shortname();
+ }
+
+ item.copy_status_warning = (copy.status().holdable() == 'f');
+
+ if (transit) {
+ item.transit = transit;
+ item.transit_source = transit.source().shortname();
+ item.transit_dest = transit.dest().shortname();
+ item.transit_time = transit.source_send_time();
+ item.transit_recv_time = transit.dest_recv_time();
+ item.open_transit = !Boolean(transit.dest_recv_time());
+ }
+
+ if (circ) {
+ item.circ = circ;
+ item.due_date = circ.due_date();
+ item.circ_circ_lib = egOrg.get(circ.circ_lib()).shortname();
+ item.circ_xact_start = circ.xact_start();
+ item.circ_stop_fines = circ.stop_fines();
+ item.circ_usr = circ.usr().card() ?
+ circ.usr().card().barcode() : circ.usr().usrname();
+ item.can_mark_lost = (item.circ && item.copy.status().id() == 1); // checked out
+ }
+
+ if (hold) {
+ item.hold = hold;
+ item.hold_request_usr = hold.usr().card() ?
+ hold.usr().card().barcode() : hold.usr().usrname();
+ item.hold_request_lib = egOrg.get(hold.request_lib()).shortname();
+ item.hold_pickup_lib = egOrg.get(hold.pickup_lib()).shortname();
+ item.hold_request_time = hold.request_time();
+ item.hold_capture_time = hold.capture_time();
+ if (hold.cancel_time()) {
+ item.hold_cancel_time = hold.cancel_time();
+ if (hold.cancel_cause()) {
+ item.hold_cancel_cause = hold.cancel_cause().label();
+ }
+ }
+ }
+
+ // TODO: another unnecessary layer of data munging,
+ // this time to fit the print templates. Can be unified.
+ item.barcode = item.item_barcode;
+ item.status = item.status_str;
+ item.item_circ_lib = item.circ_lib;
+ }
+ };
+
+ /* Actions
+ * Performed on flattened items (see above)
+ */
+ $scope.actions = {
+
+ checkin : function(item) {
+ $scope.action_pending = true;
+ var deferred = $q.defer();
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.checkin.override',
+ egAuth.token(), {
+ circ_lib : ffService.currentOrg().id(),
+ copy_id: item.copy.id(),
+ ff_action: item.next_action
+ }
+ ).then(function(response) {
+ $scope.action_pending = false;
+ // do some basic sanity checking before passing
+ // the response to the caller.
+ if (response) {
+ if (angular.isArray(response))
+ response = response[0];
+ // TODO: check for failure events
+ deferred.resolve(response);
+ } else {
+ // warn that checkin failed
+ deferred.reject();
+ }
+ });
+ return deferred.promise;
+ },
+
+ checkout : function(item) {
+ $scope.action_pending = true;
+ var deferred = $q.defer();
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.checkout.full.override',
+ egAuth.token(), {
+ circ_lib : ffService.currentOrg().id(),
+ patron_id : item.hold.usr().id(),
+ copy_id: item.copy.id(),
+ ff_action: item.next_action
+ }
+ ).then(function(response) {
+ $scope.action_pending = false;
+ // do some basic sanity checking before passing
+ // the response to the caller.
+ if (response) {
+ if (angular.isArray(response))
+ response = response[0];
+ // TODO: check for failure events
+ deferred.resolve(response);
+ } else {
+ // warn that checkin failed
+ deferred.reject();
+ }
+ });
+ return deferred.promise;
+ },
+
+
+ cancel : function(item) {
+ var deferred = $q.defer();
+ $scope.action_pending = true;
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.cancel',
+ egAuth.token(), item.hold.id()
+ ).then(function() {
+ $scope.action_pending = false;
+ deferred.resolve();
+ });
+ return deferred.promise;
+ },
+
+ retarget : function(item) {
+ var deferred = $q.defer();
+ $scope.action_pending = true;
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.reset',
+ egAuth.token(), item.hold.id()
+ ).then(function() {
+ $scope.action_pending = false;
+ deferred.resolve();
+ });
+ return deferred.promise;
+ },
+
+ abort_transit : function(item) {
+ var deferred = $q.defer();
+ $scope.action_pending = true;
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.transit.abort',
+ egAuth.token(),
+ {transitid : item.transit.id()}
+ ).then(function() {
+ $scope.action_pending = false;
+ deferred.resolve();
+ });
+ return deferred.promise;
+ },
+
+ mark_lost : function(item) {
+ var deferred = $q.defer();
+ $scope.action_pending = true;
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.circulation.set_lost',
+ egAuth.token(), {barcode : item.item_barcode}
+ ).then(function(resp) {
+ $scope.action_pending = false;
+ if (resp == 1) {
+ deferred.resolve();
+ } else {
+ console.error('mark lost failed: ' + js2JSON(resp));
+ deferred.reject();
+ }
+ });
+ return deferred.promise;
+ },
+
+ print : function(item) {
+ var deferred = $q.defer();
+ $scope.action_pending = true;
+ var focus = item.hold ? 'hold' :
+ (item.circ ? 'circ' :
+ (item.transit ? 'transit' : 'copy'));
+
+ egNet.request(
+ 'open-ils.actor',
+ 'open-ils.actor.web_action_print_template.fetch',
+ ffService.currentOrg().id(), focus
+ ).then(function(template) {
+
+ if (!template || !(template = template.template())) { // assign
+ console.warn('unable to find template for ' +
+ item.copy_barcode + ' : ' + focus);
+ return;
+ }
+
+ // NOTE: templates stored for now as dojo-style
+ // template. mangle to angular templates manually.
+ template = template.replace(/\${([^}]+)}/g, '{{$1}}');
+
+ // compile the template w/ a temporary print scope
+ var printScope = $rootScope.$new();
+ angular.forEach(item, function(val, key) {
+ printScope[key] = val;
+ });
+ var element = angular.element(template);
+ $compile(element)(printScope);
+
+ // append the compiled element to the new window and print
+ var w = window.open();
+ $(w.document.body).append(element);
+ w.document.close();
+
+ // $timeout needed in some environments (Mac confirmed)
+ // to allow the new window to fully digest before printing.
+ $timeout(
+ function() {
+ w.print();
+ w.close();
+ $scope.action_pending = false;
+ deferred.resolve();
+ }
+ );
+ });
+ return deferred.promise;
+ }
+ };
+
+ // default batch action handlers.
+ // when a batch is done, reload the route (unless printing).
+ angular.forEach(
+ Object.keys($scope.actions),
+ function(action) {
+ $scope[action] = function() {
+ var total = Object.keys($scope.itemList.selected).length;
+ angular.forEach(
+ $scope.itemList.selected,
+ function(val, idx) {
+ var item = $scope.itemList.items.filter(
+ function(i) {return i.index == idx})[0];
+ console.debug(item.index + ' => ' + action);
+ $scope.actions[action](item)
+ .then(
+ // when all items are done processing, reload the route
+ function(resp) {
+ console.debug(item.index + ' => ' + action + ' : done');
+ if (--total == 0 && action != 'print')
+ $route.reload()
+ },
+ function(resp) {
+ console.error("error in " + action + ": " + resp);
+ }
+ );
+ }
+ );
+ };
+ }
+ );
+}])
+
+.controller('TransitsCtrl',
+['$scope', '$q', 'egPCRUD', 'ffService',
+function ($scope, $q, egPCRUD, ffService) {
+
+ $scope.drawTable = function(filterLender) {
+ var deferred = $q.defer();
+ $scope.itemList.items = [];
+ $scope.itemList.toggleFilters(filterLender);
+
+ var fullPath = ffService.relatedOrgs();
+
+ var dest = fullPath; // inbound transits
+ var circ_lib = fullPath; // our copies
+
+ if ($scope.itemList.filterBorrower) {
+ // borrower always means not-our-copies
+ circ_lib = {'not in' : fullPath};
+ }
+ if ($scope.tab_outbound) {
+ // outbound transits away from "here"
+ dest = {'not in' : fullPath};
+ }
+
+ var query = {
+ dest_recv_time : null,
+ dest : dest,
+ target_copy : {
+ 'in' : {
+ select: {acp : ['id']},
+ from : 'acp',
+ where : {
+ deleted : 'f',
+ id : {'=' : {'+atc' : 'target_copy'}},
+ circ_lib : circ_lib
+ }
+ }
+ }
+ };
+
+ return egPCRUD.search('atc', query,
+ { limit : $scope.itemList.limit,
+ offset : $scope.itemList.offset,
+ flesh : 1,
+ flesh_fields : {atc : ['target_copy']}
+ }, {atomic : true}
+ ).then(function(transits) {
+ angular.forEach(transits, function(transit) {
+ $scope.itemList.addItem(
+ {barcode : transit.target_copy().barcode()});
+ });
+ });
+ };
+
+ // outbound tab defaults to lender view
+ return $scope.drawTable($scope.tab_outbound == true);
+}])
+
+.controller('CircCtrl',
+['$scope', '$q', 'egPCRUD', 'ffService',
+function ($scope, $q, egPCRUD, ffService) {
+
+ $scope.drawTable = function(filterLender) {
+ var deferred = $q.defer();
+ $scope.itemList.items = [];
+ $scope.itemList.toggleFilters(filterLender);
+
+ var fullPath = ffService.relatedOrgs();
+
+ var copy_lib = fullPath; // our copies
+ var circ_lib = fullPath; // circulating here
+
+ if ($scope.itemList.filterBorrower) {
+ // borrower always means not-our-copies
+ circ_lib = {'not in' : fullPath};
+ } else {
+ copy_lib = {'not in' : fullPath};
+ }
+
+ var query = {
+ checkin_time : null,
+ circ_lib : circ_lib,
+ target_copy : {
+ 'in' : {
+ select: {acp : ['id']},
+ from : 'acp',
+ where : {
+ deleted : 'f',
+ id : {'=' : {'+circ' : 'target_copy'}},
+ circ_lib : copy_lib
+ }
+ }
+ }
+ };
+
+ return egPCRUD.search('circ', query,
+ { limit : $scope.itemList.limit,
+ offset : $scope.itemList.offset,
+ flesh : 1,
+ flesh_fields : {circ : ['target_copy']}
+ }, {atomic : true}
+ ).then(function(circs) {
+ angular.forEach(circs, function(circ) {
+ $scope.itemList.addItem(
+ {barcode : circ.target_copy().barcode()});
+ });
+ });
+ };
+
+ // outbound tab defaults to lender view
+ return $scope.drawTable();
+}])
+
+
+.controller('ItemStatusCtrl',
+['$scope', '$q', '$route', '$location', 'egPCRUD', 'ffService',
+function ($scope, $q, $route, $location, egPCRUD, ffService) {
+ $scope.focusMe = true;
+
+ $scope.draw = function(barcode) {
+ if ($scope.illRouteParams.barcode != barcode) {
+ // keep the scan box and URL in sync
+ $location.path('/fulfillment/status/' +
+ encodeURIComponent(barcode));
+ } else {
+ $scope.itemList.items = [];
+ $scope.item = {barcode : barcode};
+ $scope.itemList.addItem($scope.item);
+ $scope.selectMe = true;
+ }
+ }
+
+ // item status actions all call the parent scope's action
+ // handlers unadorned then reload the route.
+ // TODO: set selected == item; no more need for custom action handers??
+ angular.forEach(['checkin', 'checkout',
+ 'cancel', 'abort_transit', 'retarget', 'mark_lost'],
+ function(action) {
+ $scope[action] = function() {
+ $scope.actions[action]($scope.item)
+ .then(function(resp) {$route.reload()});
+ };
+ }
+ );
+
+ // barcode passed via URL
+ if ($scope.illRouteParams.barcode) {
+ $scope.barcode = $scope.illRouteParams.barcode;
+ $scope.draw($scope.illRouteParams.barcode);
+ }
+}])
+
+
+
+/**
+ * Table of pending requests
+ * This is the only table based purely on holds instead of items,
+ * so the data is managed a little differently.
+ */
+.controller('PendingRequestsCtrl',
+['$scope', '$q', '$route', 'egNet', 'egAuth', 'egPCRUD', 'egOrg', 'ffService',
+function ($scope, $q, $route, egNet, egAuth, egPCRUD, egOrg, ffService) {
+ var self = this;
+
+ this.displayOne = function(display, hold_blob) {
+ var hold = display.hold = hold_blob.hold;
+ display.request_time = hold.hold_request_time = hold.request_time();
+ display.expire_time = hold.expire_time();
+ display.user_name = display.hold_request_usr =
+ (hold_blob.patron_first || '') +
+ ' ' + (hold_blob.patron_last || '');
+ display.request_lib = display.hold_request_lib =
+ egOrg.get(hold.request_lib()).shortname();
+ display.hold_pickup_lib =
+ egOrg.get(hold.pickup_lib()).shortname();
+ display.title = hold_blob.mvr.title(); // MVR BOO
+ display.author = hold_blob.mvr.author(); // MVR BOO
+ if (hold_blob.copy) {
+ display.copy = hold_blob.copy;
+ display.current_copy = hold_blob.copy.barcode();
+ display.current_copy_lib =
+ egOrg.get(hold_blob.copy.source_lib()).shortname();
+ display.current_copy_enc = encodeURIComponent(hold_blob.copy.barcode());
+ display.barcode = display.copy.barcode();
+ display.status = display.copy.status();
+ display.item_circ_lib = egOrg.get(display.copy.circ_lib()).shortname();
+ }
+ if (hold_blob.volume) {
+ display.call_number = hold_blob.volume.label();
+ }
+ if ($scope.itemList.filterLender)
+ display.next_action = 'ill-home-capture';
+ };
+
+ // call the parent scope's action handler for each selected item.
+ // when all are done, reload the route (when not printing).
+ /*
+ angular.forEach(['checkin', 'cancel', 'retarget', 'print'],
+ function(action) {
+ $scope[action] = function() {
+ var total = Object.keys($scope.itemList.selected).length;
+ angular.forEach(
+ $scope.itemList.selected,
+ function(idx) {
+ var item = $scope.itemList.items.filter(
+ function(i) {return i.index == idx})[0];
+ $scope.actions[action](item)
+ .then(
+ // when all items are done processing, reload the route
+ function(resp) {
+ if (--total == 0 && action != 'print')
+ $route.reload()
+ },
+ function(resp) {
+ console.error("error in " + action + ": " + resp);
+ }
+ );
+ }
+ );
+ };
+ }
+ );
+ */
+
+ $scope.drawTable = function(filterLender) {
+ $scope.itemList.items = [];
+ $scope.itemList.toggleFilters(filterLender);
+
+ var fullPath = ffService.relatedOrgs();
+
+ var query = {
+ capture_time : null,
+ cancel_time : null,
+ frozen : 'f'
+ };
+
+ if ($scope.itemList.filterBorrower) {
+ // holds for my patrons originate "here"
+ // current_copy is not relevant
+ query.request_lib = fullPath;
+ } else {
+ // holds for other originate from not-"here" and
+ // have a current copy at "here".
+ query.request_lib = {'not in' : fullPath};
+ query.current_copy = {
+ "in" : {
+ select: {acp : ['id']},
+ from : 'acp',
+ where: {
+ deleted : 'f',
+ circ_lib : fullPath,
+ id : {'=' : {'+ahr' : 'current_copy'}}
+ }
+ }
+ }
+ }
+
+ egPCRUD.search('ahr', query,
+ { limit : $scope.itemList.limit,
+ offset : $scope.itemList.offset,
+ order_by : {'ahr' : 'request_time, id'}
+ },
+ {atomic : true}
+
+ ).then(function(holds) {
+
+ // fetch the extended hold details for each hold
+ // to pick up the title, etc.
+ angular.forEach(holds, function(hold) {
+ var display = {
+ id : hold.id(),
+ index : $scope.itemList.items.length
+ };
+ // we don't use itemList.addItem(),
+ // since it fetches data differently
+ $scope.itemList.items.push(display);
+ egNet.request(
+ 'open-ils.circ',
+ 'open-ils.circ.hold.details.retrieve',
+ egAuth.token(), hold.id()
+ ).then(function(hold_blob) {
+ self.displayOne(display, hold_blob);
+ });
+ });
+ });
+ };
+
+
+ $scope.firstPage = function() {
+ $scope.itemList.offset = 0;
+ $scope.drawTable($scope.itemList.filterLender == true);
+ };
+
+ $scope.nextPage = function() {
+ $scope.itemList.offset += $scope.itemList.limit;
+ $scope.drawTable($scope.itemList.filterLender == true);
+ };
+
+ $scope.prevPage = function() {
+ $scope.itemList.offset -= $scope.itemList.limit;
+ $scope.drawTable($scope.itemList.filterLender == true);
+ };
+
+ $scope.drawTable();
+}])
+
+
+
+