LP#1350042 Browser client templates/scripts (phase 1)
authorBill Erickson <berick@esilibrary.com>
Mon, 4 Aug 2014 18:06:02 +0000 (14:06 -0400)
committerBen Shum <bshum@biblio.org>
Fri, 29 Aug 2014 20:12:10 +0000 (16:12 -0400)
 * Templates in Open-ILS/src/templates/staff/
 * JS files in Open-ILS/web/js/ui/default/staff/
 * Also includes a few TPAC modifications for embedded mode
 * Resurrects the pre-XUL user permission editor

Signed-off-by: Bill Erickson <berick@esilibrary.com>
Signed-off-by: Ben Shum <bshum@biblio.org>

200 files changed:
.gitignore
Open-ILS/src/templates/opac/parts/bookbag_actions.tt2
Open-ILS/src/templates/opac/parts/record/copy_table.tt2
Open-ILS/src/templates/staff/README [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/user_perms.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/workstation/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/base.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/base_js.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_grid_menu.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/catalog/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/item/t_view.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/checkin/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/holds/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/holds/t_pull.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/in_house_use/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/pending.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/register.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bills.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_group.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_messages.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_notes.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_summary.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/renew/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/renew/t_renew.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/circ_strings.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/hold_strings.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/config.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/css/circ.css.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/css/print.css.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/css/style.css.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/README [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_alert_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_autogrid.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_eframe.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/statusbar.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_login.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/t_splash.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/opac/staff.js
Open-ILS/web/js/ui/default/staff/Gruntfile.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/README.install [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/admin/user_perms.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/admin/workstation/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/bower.json [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/item/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/services/record.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/checkin/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/holds/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/bills.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/holds.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/pending.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/patron/register.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/renew/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/services/billing.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/services/circ.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/circ/services/holds.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/package.json [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/auth.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/core.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/coresvc.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/eframe.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/env.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/event.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/file.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/grid.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/hatch.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/idl.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/navbar.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/net.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/org.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/pcrud.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/print.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/startup.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/statusbar.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/strings.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/ui.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/user.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/karma.conf.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egCore.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js [new file with mode: 0644]
Open-ILS/web/opac/locale/en-US/lang.dtd
Open-ILS/xul/staff_client/server/patron/user_edit.xhtml [new file with mode: 0644]
Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js [new file with mode: 0644]

index 76675d6..073c02d 100644 (file)
@@ -353,3 +353,6 @@ Open-ILS/xul/staff_client/xulrunner-stub.exe
 Thumbs.db
 /js*
 /JavaScript*
+Open-ILS/web/js/ui/default/staff/build/
+Open-ILS/web/js/ui/default/staff/node_modules/
+Open-ILS/web/js/ui/default/staff/bower_components/
index de33841..611d1fc 100644 (file)
@@ -4,7 +4,7 @@
 
     # Wrap a url to open in a new tab in staff client.
     MACRO opac_wrap(url) BLOCK;
-         IF ctx.is_staff;
+         IF ctx.is_staff AND NOT ctx.is_browser_staff;
               # void(0) to return false and not go to new page in current tab.
               "javascript:xulG.new_tab(xulG.urls.XUL_OPAC_WRAPPER, {}, {'opac_url' : 'oils://remote" _ url _ "'});void(0);";
           ELSE;
index 814c4f9..6ecaa10 100644 (file)
@@ -105,6 +105,19 @@ END; # FOREACH bib
             <td headers='copy_header_barcode' property="serialNumber">
                 [% copy_info.barcode | html -%]
                 [% IF ctx.is_staff %]
+                  [%- IF ctx.is_browser_staff %]
+                    <a target="_top" href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]">[% l('view') %]</a>
+                    [% IF ctx.has_perm('UPDATE_COPY', copy_info.circ_lib) 
+                        OR ctx.has_perm('UPDATE_COPY', copy_info.call_number_owning_lib) %]
+                        <span> | </span>
+                        <!-- XXX: copy edit is not yet supported in browser client.
+                          Enable this link when available
+                        -->
+                        <!--
+                        <a href="[% ctx.base_path %]/staff/cat/item/[% copy_info.id %]/edit">[% l('edit') %]</a>
+                        -->
+                    [% END %]
+                  [% ELSE %]
                     <a onclick="xulG.new_tab(xulG.urls.XUL_COPY_STATUS, {}, {'from_item_details_new': true, 'barcodes': ['[%- copy_info.barcode | html | replace('\'', '\\\'') -%]']})"
                         href="javascript:;">[% l('view') %]</a>
                     [%# if the user can edit copies, show the copy edit link %]
@@ -116,6 +129,7 @@ END; # FOREACH bib
                             [% l(' edit') %]
                         </a>
                     [% END %]
+                  [% END %]
                 [% END %]
                 [%- IF attrs.gtin13;
                     '<meta property="gtin13" content="' _ attrs.gtin13 _ '" />';
diff --git a/Open-ILS/src/templates/staff/README b/Open-ILS/src/templates/staff/README
new file mode 100644 (file)
index 0000000..9206301
--- /dev/null
@@ -0,0 +1,6 @@
+AnguarJS/Web Staff Client
+=========================
+
+ * TT templates loaded via JS routes must be preceded with t_* (or similar), 
+   otherwise apache will serve the template at that path instead of the 
+   index file since the path maps to a real template.
diff --git a/Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2 b/Open-ILS/src/templates/staff/admin/t_user_perms_lookup.tt2
new file mode 100644 (file)
index 0000000..3a23cc5
--- /dev/null
@@ -0,0 +1,24 @@
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input 
+      focus-me="selectMe" 
+      select-me="selectMe"
+      class="form-control barcode"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-lookup-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/admin/user_perms.tt2 b/Open-ILS/src/templates/staff/admin/user_perms.tt2
new file mode 100644 (file)
index 0000000..23a1257
--- /dev/null
@@ -0,0 +1,18 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("User Permission Editor"); 
+  ctx.page_app = "egUserPermsEditor";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/user_perms.js"></script>
+[% END %]
+
+<script type="text/ng-template" id="user-perms-template">
+  <eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
+</script>
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/workstation/index.tt2 b/Open-ILS/src/templates/staff/admin/workstation/index.tt2
new file mode 100644 (file)
index 0000000..3f927a4
--- /dev/null
@@ -0,0 +1,24 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Workstation Administration"); 
+  ctx.page_app = "egWorkstationAdmin";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/workstation/app.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.PREFS_REMOVE_KEY_CONFIRM = 
+    '[% l('Delete content for key "[_1]"?', '{{deleteKey}}') %]';
+  s.DEFAULT_WS_LABEL = '[% l('[_1] (Default)', '{{ws}}') %]';
+  s.WS_EXISTS = '[% l("Workstation name already exists.  Use it anyway?") %]';
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_print_config.tt2
new file mode 100644 (file)
index 0000000..06067ac
--- /dev/null
@@ -0,0 +1,176 @@
+<div class="container" id="admin-workstation-printing">
+
+  <style>
+    /* TODO: more context and move me */
+    textarea {
+      height: 400px;
+      width: 100%;
+    }
+    .tab-pane .row {
+      padding-top: 20px;
+    }
+    h2 { margin-bottom: 15px }
+      
+  </style>
+
+  <div class="row"> 
+    <div class="col-md-12">
+      <h2>[% l('Printer Settings for Remote Printing') %]</h2>
+    </div>
+  </div>
+
+  <div class="row"> 
+    <div class="col-md-12">
+      <ul class="nav nav-tabs">
+        <li ng-class="{active : context == 'default'}">
+          <a href='' ng-click="setContext('default')">[% l('Default') %]</a>
+        </li>
+        <li ng-class="{active : context == 'receipt'}">
+          <a href='' ng-click="setContext('receipt')">[% l('Receipt') %]</a>
+        </li>
+        <li ng-class="{active : context == 'label'}">
+          <a href='' ng-click="setContext('label')">[% l('Label') %]</a>
+        </li>
+        <li ng-class="{active : context == 'mail'}">
+          <a href='' ng-click="setContext('mail')">[% l('Mail') %]</a>
+        </li>
+        <li ng-class="{active : context == 'offline'}">
+          <a href='' ng-click="setContext('offline')">[% l('Offline') %]</a>
+        </li>
+        <li ng-class="{active : isTestView}" class="pull-right">
+          <a href='' ng-click="isTestView=true">[% l('Test Printing') %]</a>
+        </li>
+      </ul>
+      <div class="tab-content">
+        <div class="tab-pane active">
+
+          <!-- printer config UI -->
+          <div class="row" ng-hide="isTestView">
+            <div class="col-md-6">
+              <div class="input-group">
+                <div class="input-group-btn" dropdown>
+                  <button type="button" class="btn btn-default dropdown-toggle">
+                    [% l('Select Printer') %]
+                    <span class="caret"></span></button>
+                  <ul class="dropdown-menu">
+                    <li ng-repeat="printer in printers">
+                      <a href='' ng-click="setPrinter(printer.name)">
+                        {{printer.name}}
+                      </a>
+                    </li>
+                  </ul>
+                </div><!-- /btn-group -->
+                <input ng-if="!printers[0]" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="[% l('No Printers Found') %]">
+                <input ng-if="printers[0] && !printConfig[context]" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="[% l('No Printer Selected') %]">
+                <input ng-if="printConfig[context].printer" type="text" 
+                  class="form-control" disabled="disabled"
+                  value="{{printConfig[context].printer}}">
+              </div><!-- /input-group -->
+            </div><!-- col -->
+            <div class="col-md-6">
+              <div class="input-group">
+                <div class="input-group-btn">
+                  <button type="button" 
+                    ng-click="configurePrinter()"
+                    ng-class="{disabled : actionPending || !printers[0]}"
+                    class="btn btn-default btn-success">
+                      [% l('Configure Printer') %]
+                  </button>
+                  <button type="button" 
+                    ng-click="resetConfig()"
+                    ng-class="{disabled : actionPending}"
+                    class="btn btn-default btn-warning">
+                      [% l('Reset Configuration') %]
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div><!-- row -->
+          <div class="row" ng-hide="isTestView"> 
+            <div class="col-md-12">
+              <h2>[% l('Compiled Printer Settings') %]</h2>
+              <pre>{{printerConfString()}}</pre>
+            </div><!-- col -->
+          </div><!-- row -->
+
+          <!-- printer test UI -->
+          <div class="row" ng-show="isTestView"> 
+            <div class="col-md-10">
+              <div class="btn-group">
+                <button type="button" 
+                  class="btn btn-default btn-lg" 
+                  ng-class="{active : contentType=='text/plain'}"
+                  ng-click="setContentType('text/plain')">[% l('Plain Text') %]</button>
+                <button type="button" 
+                  class="btn btn-default btn-lg" 
+                  ng-class="{active : contentType=='text/html'}"
+                  ng-click="setContentType('text/html')">[% l('HTML') %]</button>
+              </div>
+            </div>
+            <div class="col-md-2">
+              <div class="input-group pull-right">
+                <div class="input-group-btn">
+                  <button type="button" 
+                    ng-click="testPrint()"
+                    class="btn btn-default btn-success">
+                      [% l('Print') %]</button>
+                  <button type="button" 
+                    ng-click="testPrint(true)"
+                    class="btn btn-default btn-info">
+                      [% l('Print with Dialog') %]</button>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div class="row" ng-show="isTestView"> 
+            <div class="col-md-12">
+              <div ng-show="contentType=='text/plain'"
+ng-init="textPrintContent='
+[% l('Test Print') %]
+
+1234567890
+
+12345678901234567890
+
+123456789012345678901234567890
+
+1234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890
+
+12345678901234567890123456789012345678901234567890123456790
+
+[% l('Test Print') %]
+'">
+        <pre><textarea>{{textPrintContent}}</textarea></pre>
+      </div>
+
+      <div ng-show="contentType=='text/html'">
+        <textarea ng-model="htmlPrintContent" 
+ng-init="htmlPrintContent='
+<div>
+  <style>p { color: blue }</style>
+  <h2>[% l('Test HTML Print') %]</h2>
+  <br/>
+  <img src=\'https://[% ctx.hostname %]/opac/images/main_logo.png\' width=\'140\' height=\'24\'/>
+  <p>[% l('Welcome, Stranger!') %]</p>
+  <p>{{value1}}</p>
+  <p>{{value2}}</p>
+  <p>{{date_value | date}}</p>
+</div>
+'">
+                </textarea>
+              </div><!-- html content -->
+            </div><!-- col -->
+          </div><!-- row -->
+        </div><!-- tab pane -->
+      </div><!-- tab content -->
+    </div><!-- col -->
+  </div><!-- row -->
+</div><!-- container -->
+
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_print_templates.tt2
new file mode 100644 (file)
index 0000000..cbc79f8
--- /dev/null
@@ -0,0 +1,59 @@
+<style>
+  /* TODO: move me */
+  .print-template-text {
+    height: 36em;
+    width: 100%;
+  }
+</style>
+
+<h2>[% l('Print Templates') %]</h2>
+
+<div class="row">
+  <div class="col-md-2">[% l('Template Name') %]</div>
+  <div class="col-md-3">
+    <select class="form-control" ng-model="print.template_name" ng-change="template_changed()">
+      <option value="bills_current">[% l('Bills, Current') %]</option>
+      <option value="bills_historical">[% l('Bills, Historical') %]</option>
+      <option value="bill_payment">[% l('Bills, Payment') %]</option>
+      <option value="checkout">[% l('Checkout') %]</option>
+      <option value="hold_transit_slip">[% l('Hold Transit Slip') %]</option>
+      <option value="hold_shelf_slip">[% l('Hold Shelf Slip') %]</option>
+      <option value="holds_for_bibs">[% l('Holds for Bib Record') %]</option>
+      <option value="holds_for_patron">[% l('Holds for Patron') %]</option>
+      <option value="patron_address">[% l('Patron Address') %]</option>
+      <option value="patron_note">[% l('Patron Note') %]</option>
+      <option value="transit_slip">[% l('Transit Slip') %]</option>
+    </select>
+  </div>
+  <div class="col-md-7">
+    <div class="pull-right">
+      <button class="btn btn-default" ng-click="save_locally()">[% l('Save Locally') %]</button>
+    </div>
+  </div>
+  <!-- other stuff -->
+</div>
+
+<hr/>
+
+<div class="row">
+  <div class="col-md-5">
+    <h3>[% l('Preview') %]</h3>
+    <div eg-print-template-output 
+      content="print.template_content" 
+      context="preview_scope"></div>
+  </div>
+  <div class="col-md-7">
+    <h3>[% l('Template') %]</h3>
+    <div ng-if="print.load_failed" class="alert alert-danger">
+      [% l(
+        "Unable to load template '[_1]'.  The web server returned an error.", 
+        '{{print.template_name}}') 
+      %]
+    </div>
+    <div>
+      <textarea ng-model="print.template_content" class="print-template-text">
+      </textarea>
+    </div>
+  </div> <!-- col -->
+</div>
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_splash.tt2
new file mode 100644 (file)
index 0000000..3a14bbe
--- /dev/null
@@ -0,0 +1,120 @@
+<br/>
+<style>
+  #admin-workstation-container .row {
+    margin-top: 5px;
+  }
+  #admin-workstation-container .new-entry {
+    margin-top: 10px;
+    padding-top: 10px;
+    border-top: 2px solid #F5F5F5;
+  }
+</style>
+
+<div class="container" id="admin-workstation-container">
+
+  <div class="row">
+    <div class="col-md-6">
+      <div class="checkbox">
+        <label>
+          <input type="checkbox" ng-class="{disabled : !userHasAdminPerm}"
+            ng-model="hatchRequired" ng-change="updateHatchRequired()">
+[% l('This workstation uses a remote print / storage service ("Hatch")?') %]
+        </label>
+      </div>
+    </div><!-- row -->
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <input type='text' class='form-control'  
+        ng-disabled="!hatchRequired || !userHasRegPerm"
+        title="[% l('Hatch URL') %]"
+        placeholder="[% l('Hatch URL') %]"
+        ng-change='updateHatchURL()' ng-model='hatchURL'/>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      [% l('Workstations Registered With This Computer') %]
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <select class="form-control" ng-model="selectedWS">
+        <option ng-repeat="ws in workstations" value="{{ws}}"
+          ng-selected="ws == selectedWS">
+          {{getWSLabel(ws)}}
+        </option>
+      </select>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-6">
+      <button class="btn btn-default" ng-click="useWS()">
+        [% l('Use Now') %]
+      </button>
+      <button class="btn btn-default" ng-click="setDefaultWS()">
+        [% l('Mark As Default') %]
+      </button>
+      <button class="btn btn-default btn-danger disabled">
+        [% l('Delete') %]
+      </button>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      [% l('Register a New Workstation For This Computer') %]
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <div class="input-group">
+        <div class="input-group-btn">
+          <eg-org-selector 
+            selected="contextOrg"
+            hidden-test="wsOrgHidden">
+          </eg-org-selector>
+        </div>
+        <input type='text' class='form-control'  
+          title="[% l('Workstation Name') %]"
+          placeholder="[% l('Workstation Name') %]"
+          ng-model='newWSName'/>
+        <div class="input-group-btn">
+          <button class="btn btn-default" ng-click="registerWS()">
+            [% l('Register') %]
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-print"></span>
+      <a target="_self" href="./admin/workstation/print/config">
+        [% l('Printer Settings') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-film"></span>
+      <a target="_self" href="./admin/workstation/print/templates">
+        [% l('Print Templates') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="row new-entry">
+    <div class="col-md-6">
+      <span class="glyphicon glyphicon-info-sign"></span>
+      <a target="_self" href="./admin/workstation/stored_prefs">
+        [% l('Stored Preferences') %]
+      </a>
+    </div>
+  </div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2 b/Open-ILS/src/templates/staff/admin/workstation/t_stored_prefs.tt2
new file mode 100644 (file)
index 0000000..dc031b4
--- /dev/null
@@ -0,0 +1,65 @@
+<style>
+  /* TODO */
+  #stored-prefs-container .selected {
+    background-color: #F5F5F5;   
+  }
+  #stored-prefs-container .row {
+    padding-top: 10px;
+  }
+</style>
+<div class="container" id="stored-prefs-container">
+  <div class="row">
+    <div class="col-md-12">
+      <h2>[% l('Stored User Preferences') %]</h2>
+      <div class="well">
+[% |l %]
+Preference values are stored as JSON strings.  
+Click on a preference to view the stored value.
+Click on the delete (X) button to remove a preference's value.
+[% END %]
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-4">
+
+      <ul class="nav nav-tabs">
+        <li ng-class="{active : context == 'local'}">
+          <a href='' ng-click="setContext('local')">[% l('Local Prefs') %]</a>
+        </li>
+        <li ng-class="{active : context == 'remote'}">
+          <a href='' ng-click="setContext('remote')">[% l('Remote Prefs') %]</a>
+        </li>
+      </ul>
+      <div class="tab-content">
+        <div class="tab-pane active">
+
+          <div class="row" ng-repeat="key in keys[context]">
+            <div class="col-md-1">{{$index + 1}}.</div>
+            <div class="col-md-8 stored-prefs-key" 
+              ng-class="{selected : currentKey == key}">
+              <a href='' ng-click="selectKey(key)">{{key}}</a>
+            </div>
+            <div class="col-md-1">
+              <!-- padding to give the buttom some overflow space -->
+            </div>
+            <div class="col-md-1" class="stored-prefs-remove-button">
+              <button class="btn btn-default btn-danger"
+                ng-class="{disabled : !userHasDeletePerm}"
+                ng-click="removeKey(key)" title="[% l('Remove Item') %]">
+                <span class="glyphicon glyphicon-remove"></span>
+              </button>
+            </div>
+          </div><!-- row -->
+
+        </div><!-- tab pane -->
+      </div><!-- tab content -->
+    </div><!-- col -->
+
+    <div class="col-md-8">
+      <pre>{{getCurrentKeyContent()}}</pre>
+    </div><!-- col -->
+
+  </div><!-- row -->
+</div><!-- container -->
diff --git a/Open-ILS/src/templates/staff/base.tt2 b/Open-ILS/src/templates/staff/base.tt2
new file mode 100644 (file)
index 0000000..ce9cc5e
--- /dev/null
@@ -0,0 +1,48 @@
+<!doctype html>
+[%- PROCESS 'staff/config.tt2' %]
+<html lang="[% ctx.locale %]"
+    [%- IF ctx.page_app %] ng-app="[% ctx.page_app %]"[% END -%]
+    [%- IF ctx.page_ctrl %] ng-controller="[% ctx.page_ctrl %]"[% END %]>
+  <head>
+    <title>[% l('Evergreen Staff [_1]', ctx.page_title) %]</title>
+    <base href="/eg/staff/">
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    [% IF EXPAND_WEB_IMPORTS %]
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/bootstrap.min.css" />
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/hotkeys.min.css" />
+    [% ELSE %]
+    <link rel="stylesheet" href="[% WEB_BUILD_PATH %]/css/evergreen-staff-client-deps.[% EVERGREEN_VERSION %].min.css" />
+    [% END %]
+    <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/style.css" />
+    <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/print.css" type="text/css" media="print" />
+  </head>
+  <body>
+
+    <!-- load the navbar template inline since it's used on every page -->
+    <script type="text/ng-template" id="eg-navbar-template">
+      [% INCLUDE "staff/navbar.tt2" %]
+    </script>
+
+    <!-- instantiate the navbar by invoking it's name -->
+    <eg-navbar></eg-navbar>
+
+    <!-- main page content goes here -->
+    <div id="top-content-container" class="container">[% content %]</div>
+
+    [% 
+      # status bar along bottom of page
+      INCLUDE "staff/statusbar.tt2";
+
+      # script imports
+      INCLUDE "staff/base_js.tt2";
+
+      # App-specific JS load commands go into an APP_JS block.
+      PROCESS APP_JS;
+    %]
+
+    <!-- content printed via the browser is inserted here for 
+         DOM-ification prior to delivery to the printer -->
+    <div id="print-div" eg-print-container></div>
+  </body>
+</html>
diff --git a/Open-ILS/src/templates/staff/base_js.tt2 b/Open-ILS/src/templates/staff/base_js.tt2
new file mode 100644 (file)
index 0000000..76bc5a3
--- /dev/null
@@ -0,0 +1,47 @@
+<script src="/IDL2js"></script>
+
+[% IF EXPAND_WEB_IMPORTS %]
+
+<!-- angular -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/angular-route.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/ui-bootstrap-tpls.min.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/hotkeys.min.js"></script>
+
+<!-- IDL / opensrf (network) -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/JSON_v1.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf.js"></script>
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/opensrf_ws.js"></script>
+
+<!-- evergreen core services -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/strings.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/idl.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/event.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/net.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/auth.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/pcrud.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/env.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/org.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/startup.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/hatch.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/print.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/coresvc.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/statusbar.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+
+[% ELSE %]
+
+<!-- concatenated, minified version of all of the above -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/build/js/evergreen-staff-client.[% EVERGREEN_VERSION %].min.js"></script>
+
+[% END %]
+
+<script>
+  // Configure OpenSRF
+  // pending api_level thunking in C
+  // OpenSRF.api_level = 2;
+  OpenSRF.Session.transport = OSRF_TRANSPORT_TYPE_WS;
+</script>
+
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2
new file mode 100644 (file)
index 0000000..2f21e72
--- /dev/null
@@ -0,0 +1,65 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Record Buckets"); 
+  ctx.page_app = "egCatRecordBuckets";
+  ctx.page_ctrl = "RecordBucketCtrl";
+%]
+
+[% 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/record/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 == 'search'}">
+    <a href="./cat/bucket/record/search/{{bucketSvc.currentBucket.id()}}">
+        [% l('Record Query') %]
+        <span ng-cloak>({{bucketSvc.queryRecords.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'pending'}">
+    <a href="./cat/bucket/record/pending/{{bucketSvc.currentBucket.id()}}">
+        [% l('Pending Records') %]
+        <span ng-cloak>({{bucketSvc.pendingList.length}})</span>
+    </a>
+  </li>
+  <li ng-class="{active : tab == 'view'}">
+    <a href="./cat/bucket/record/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/record/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/record/t_bucket_create.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_bucket_delete.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_bucket_edit.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_bucket_export.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2
new file mode 100644 (file)
index 0000000..ffc26d0
--- /dev/null
@@ -0,0 +1,41 @@
+<!-- export bucket dialog -->
+<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('Export Records') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="export-bucket-format">[% l('Record Format') %]</label>
+        <select class="form-control" ng-model="args.format" id="export-bucket-format">
+          <option value="XML">[% l('MARC XML') %]</option>
+          <option value="USMARC">[% l('USMARC') %]</option>
+          <option value="UNIMARC">[% l('UNIMARC') %]</option>
+          <option value="BRE">[% l('Evergreen Record Entry') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="export-bucket-encoding">[% l('Encoding') %]</label>
+        <select class="form-control" ng-model="args.encoding" id="export-bucket-encoding">
+          <option value="UTF-8">[% l('UTF-8') %]</option>
+          <option value="MARC8">[% l('MARC8') %]</option>
+        </select>
+      </div>
+
+       <div class="checkbox">
+        <label>
+          <input ng-model="args.holdings" type="checkbox"> 
+          [% l('Include Items?') %]
+        </label>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary"
+          ng-click="ok(args)" value="[% l('Export') %]"/>
+      <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/record/t_bucket_info.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_bucket_selector.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_grid_menu.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_load_shared.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/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/record/t_pending.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2
new file mode 100644 (file)
index 0000000..eefc60a
--- /dev/null
@@ -0,0 +1,20 @@
+<eg-grid
+  ng-hide="forbidden"
+  features="-sort,-multisort"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  items-provider="gridDataProvider"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.pending">
+
+  [% INCLUDE 'staff/cat/bucket/record/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="clearPendingList"></eg-grid-action>
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2
new file mode 100644 (file)
index 0000000..684b139
--- /dev/null
@@ -0,0 +1,46 @@
+<br/>
+
+<!-- search bar -->
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="search()">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Record Query') %]</span>
+        <input type="text" class="form-control" focus-me="focusMe"
+        ng-model="bucketSvc.queryString" placeholder="[% l('Query...') %]">
+      </div>
+    </form>
+  </div>
+</div>
+<br/>
+<div class="row" ng-show="searchInProgress">
+  <div class="col-md-6">
+    <div class="progress progress-striped active">
+        <div class="progress-bar"  role="progressbar" aria-valuenow="100" 
+              aria-valuemin="0" aria-valuemax="100" style="width: 100%">
+            <span class="sr-only">[% l('Searching...') %]</span>
+        </div>
+    </div>
+  </div>
+</div>
+
+
+<eg-grid
+  ng-hide="forbidden"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.search">
+
+  [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Add To Pending') %]"
+    handler="addToPending"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Add To Bucket') %]" 
+    handler="addToBucket"></eg-grid-action>
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2
new file mode 100644 (file)
index 0000000..39c866f
--- /dev/null
@@ -0,0 +1,28 @@
+<eg-grid
+  ng-hide="forbidden"
+  id-field="id"
+  idl-class="rmsr"
+  auto-fields="true"
+  grid-controls="gridControls"
+  menu-label="[% l('Buckets') %]"
+  persist-key="cat.bucket.record.view">
+
+  [% INCLUDE 'staff/cat/bucket/record/t_grid_menu.tt2' %]
+
+  <!-- actions drop-down -->
+  <eg-grid-action label="[% l('Remove Selected Records') %]" 
+    handler="detachRecords"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Export Records') %]" 
+    handler="openExportBucketDialog"></eg-grid-action>
+
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.id}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/cat/catalog/index.tt2 b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
new file mode 100644 (file)
index 0000000..9e799d4
--- /dev/null
@@ -0,0 +1,21 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Catalog"); 
+  ctx.page_app = "egCatalogApp";
+%]
+
+[% 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/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/catalog/app.js"></script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
new file mode 100644 (file)
index 0000000..47f1c6f
--- /dev/null
@@ -0,0 +1,48 @@
+
+<div class="row pad-vert">
+  <div class="col-md-9">
+    <div class="alert alert-info alert-less-pad strong-text-2">
+      <span ng-if="record_tab == 'catalog'">[% l('Catalog') %]</span>
+      <span ng-if="record_tab == 'marc_html'">[% l('MARC HTML') %]</span>
+      <span ng-if="record_tab == 'holds'">[% l('Holds for Record') %]</span>
+    </div>
+  </div>
+  <div class="col-md-3">
+    <!-- actions for this record menu -->
+    <div class="btn-group pull-right" dropdown>
+      <button type="button" 
+          class="btn btn-default dropdown-toggle" ng-disabled="!record_id">
+        [% l('Actions for This Record') %] 
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu dropdown-menu-right" role="menu">
+        <li><a href dropdown-toggle ng-click="set_record_tab('catalog')">
+            [% l('OPAC View') %]</a></li>
+        <li><a href dropdown-toggle ng-click="set_record_tab('marc_html')">
+            [% l('MARC View') %]</a></li>
+        <li class="divider"></li>
+        <li><a href dropdown-toggle ng-click="set_record_tab('holds')">
+            [% l('View Holds') %]</a></li>
+        <li><a href dropdown-toggle ng-click="mark_hold_transfer_dest()">
+            [% l('Mark as Title Hold Transfer Destination') %]</a></li>
+        <li><a href dropdown-toggle ng-click="transfer_holds_to_marked()">
+            [% l('Transfer All Title Holds') %]</a></li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+<div>
+  <!-- ng-show allows the catalog iframe to stay loaded (unlike ng-if) -->
+  <div ng-show="record_tab == 'catalog'">
+    <eg-embed-frame url="catalog_url" handlers="handlers" onchange="handle_page"></eg-embed-frame>
+  </div>
+  <!-- ng-if the remaining tabs so they can be instantiated on demand -->
+  <div ng-if="record_tab == 'marc_html'">
+    <eg-record-html record-id="record_id"></eg-record-html>
+  </div>
+  <div ng-if="record_tab == 'holds'">
+    [% INCLUDE 'staff/cat/catalog/t_holds.tt2' %]
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_holds.tt2
new file mode 100644 (file)
index 0000000..62af918
--- /dev/null
@@ -0,0 +1,108 @@
+
+<div ng-if="!detail_hold_id">
+  <div class="row">
+    <div class="col-md-3">
+      <div class="input-group">
+        <span class="input-group-addon">[% l('Pickup Library') %]</span>
+        <eg-org-selector selected="pickup_ou" onchange="pickup_ou_changed"></eg-org-selector>
+      </div>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+
+  <eg-grid
+    id-field="id"
+    features="-sort,-multisort"
+    items-provider="hold_grid_data_provider"
+    grid-controls="hold_grid_controls"
+    persist-key="cat.catalog.holds">
+
+    <eg-grid-menu-item handler="detail_view" 
+      label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+    <eg-grid-action handler="grid_actions.show_recent_circs"
+      label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+    <eg-grid-action divider="true"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.set_copy_quality"
+      label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_pickup_lib"
+      label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_notify_prefs"
+      label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.edit_dates"
+      label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.activate"
+      label="[% l('Activate') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.suspend"
+      label="[% l('Suspend') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.set_top_of_queue"
+      label="[% l('Set Top of Queue') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.clear_top_of_queue"
+      label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+      label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.mark_damaged"
+      label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.mark_missing"
+      label="[% l('Mark Item Missing') %]"></eg-grid-action>
+    <eg-grid-action divider="true"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.retarget"
+      label="[% l('Find Another Target') %]"></eg-grid-action>
+    <eg-grid-action handler="grid_actions.cancel_hold"
+      label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+    <eg-grid-field label="[% l('Current Copy') %]" 
+      path='hold.current_copy.barcode'>
+      <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+        {{item.hold.current_copy().barcode()}}
+      </a>
+    </eg-grid-field>
+
+    <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+    <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+    <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+        {{item.mvr.title()}}
+      </a>
+    </eg-grid-field>
+
+    <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+    <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+    <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+    <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+    <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+    <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+    <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+    <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+  </eg-grid>
+
+  <div class="flex-row pad-vert">
+    <div class="flex-cell"></div>
+    <div>
+      <button class="btn btn-default" ng-click="print_holds()">
+        [% l('Print') %]
+      </button>
+    </div>
+  </div>
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/item/index.tt2 b/Open-ILS/src/templates/staff/cat/item/index.tt2
new file mode 100644 (file)
index 0000000..2232a7d
--- /dev/null
@@ -0,0 +1,83 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Item Status"); 
+  ctx.page_app = "egItemStatus";
+  ctx.page_ctrl = "SearchCtrl";
+%]
+
+[% 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/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.js"></script>
+[% END %]
+
+<style>
+  /* FIXME: MOVE ME */
+  #item-status-barcode {width: 16em;}
+  #item-status-form { 
+    margin-bottom: 20px; 
+  }
+</style>
+
+<h1 class="sr-only">[% l('Item Status Display') %]</h1>
+
+<h2>[% l('Scan Item') %]</h2>
+
+<form id="item-status-form" ng-submit="context.search(args)" role="form">
+  <!-- the upload button drops down to the line below when it sits in the
+    same col-md-x as the text input and submit.  avoid by using a flex-row -->
+  <div class="flex-row">
+    <div class="input-group">
+      <input type="text" id="item-status-barcode" class="form-control"
+        select-me="context.selectBarcode" ng-model="args.barcode">
+      <input class="btn btn-default" 
+        type="submit" value="[% l('Submit') %]"/>
+    </div>
+    <!-- give the upload container div some padding to prevent force the
+        upload widget into the vertical middle of the row -->
+    <div class="btn-pad" style="padding:4px;">
+      <div class="flex-row">
+        <div class="strong-text">[% l('OR') %]</div>
+        <div class="btn-pad">
+          <input type="file" eg-file-reader 
+            container="barcodesFromFile" value="[% l('Upload from File') %]">
+        </div>
+      </div>
+    </div>
+    <div class="flex-cell"></div><!-- force the final divs to the right -->
+    <div>
+      <button class="btn btn-default" ng-click="toggleView($event)">
+        <span ng-show="context.page == 'list'">[% l('Detail View') %]</span>
+        <span ng-show="context.page == 'detail'">[% l('List View') %]</span>
+      </button>
+    </div>
+    <!--
+    <div class="btn-group btn-pad" dropdown>
+      <button type="button" class="btn btn-default dropdown-toggle">
+        [% l('Actions for Catalogers') %]<span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu" role="menu">
+      </ul>
+    </div>
+    -->
+  </div><!-- flex row -->
+</form>
+
+
+<div class="row">
+  <div class="col-md-6">
+    <div ng-show="context.itemNotFound" class="alert alert-danger">
+      [% l('Item Not Found') %]
+    </div>
+  </div>
+</div>
+
+<div ng-view></div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2 b/Open-ILS/src/templates/staff/cat/item/missing_pieces.tt2
new file mode 100644 (file)
index 0000000..2581ba7
--- /dev/null
@@ -0,0 +1,69 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Scan Item as Missing Pieces"); 
+  ctx.page_app = "egItemMissingPieces";
+  ctx.page_ctrl = "MissingPiecesCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/missing_pieces.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.CONFIRM_MARK_MISSING_TITLE = "[% l('Mark item as missing pieces?') %]";
+s.CONFIRM_MARK_MISSING_BODY = 
+  "[% l('[_1] / [_2]', '{{barcode}}', '{{title}}') %]";
+s.CIRC_NOT_FOUND = 
+  "[% l('No circulation found for item with barcode [_1].  Item not modified.', '{{barcode}}') %]"
+}])
+</script>
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Scan Item as Missing Pieces') %]</span>
+  </div>
+</div>
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-lookup-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input 
+      focus-me="selectMe" 
+      select-me="selectMe"
+      class="form-control barcode"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-lookup-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+<hr/>
+
+<div ng-show="letter"> 
+  <div class="row">
+    <div class="col-md-2">
+      <button ng-click="print_letter()" class="btn btn-default">[% l('Print Letter') %]</button>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-6">
+      <textarea ng-model="letter" rows="25" style="width:100%"></textarea>
+    </div>
+  </div>
+</div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2 b/Open-ILS/src/templates/staff/cat/item/replace_barcode/index.tt2
new file mode 100644 (file)
index 0000000..6472a48
--- /dev/null
@@ -0,0 +1,49 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Replace Item Barcode"); 
+  ctx.page_app = "egItemReplaceBarcode";
+  ctx.page_ctrl = "ReplaceItemBarcodeCtrl";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/item/replace_barcode/app.js"></script>
+[% END %]
+
+<h2>[% l('Replace Item Barcode') %]</h2>
+
+<div class="row">
+  <div class="col-md-6 pad-vert">
+    <form role="form" ng-submit="updateBarcode()">
+      <div class="form-group">
+        <label for="barcode1">[% l('Enter Original Barcode for Item') %]</label>
+        <input type="text" class="form-control" id="barcode1" required
+          ng-model="barcode1"
+          placeholder="[% l('Original Barcode...') %]" select-me="focusBarcode">
+      </div>
+      <div class="form-group">
+        <label for="barcode2">[% l('Enter New Barcode for Item') %]</label>
+        <input type="text" class="form-control" id="barcode2" 
+          ng-model="barcode2"
+          required placeholder="[% l('New Barcode...') %]">
+      </div>
+      <button type="submit" class="btn btn-default">[% l('Submit') %]</button>
+    </form>
+  </div>
+</div>
+
+<div class="row pad-vert">
+  <div class="col-md-6">
+    <div class="alert alert-danger" ng-if="copyNotFound">
+      [% l('Copy Not Found') %]
+    </div>
+    <div class="alert alert-success" ng-if="updateOK">
+      <span>[% l('Copy Updated') %]</span>
+      <span class="horiz-pad" ng-if="copyId">
+        <a href="./cat/item/{{copyId}}/summary" target="_self">
+          [% l('View Item Details') %]
+        </a>
+    </div>
+  </div>
+</div>
+[% END %]
diff --git a/Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_cat_pane.tt2
new file mode 100644 (file)
index 0000000..43436af
--- /dev/null
@@ -0,0 +1,3 @@
+<h3>[% l('MARC Record') %]</h3>
+
+<eg-record-html record-id="recordId"></eg-record-html>
diff --git a/Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_circ_list_pane.tt2
new file mode 100644 (file)
index 0000000..9f7bbb5
--- /dev/null
@@ -0,0 +1,51 @@
+<div class="col-md-12" ng-show="!circ_list.length">
+  <div class="alert alert-info">
+    [% l('Item has not circulated.') %]
+  </div>
+</div>
+
+<div class="row" ng-repeat="circ in circ_list">
+  <div class="flex-row">
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{circ.usr().id()}}/checkout" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{circ.usr().family_name()}}'
+          '{{circ.usr().first_given_name()}}'
+          '{{circ.usr().second_given_name()}}'
+          '{{circ.usr().card().barcode()}}') %]
+      </a>
+      <span class="pad-horiz">[% l('Circulation ID: [_1]', '{{circ.id()}}') %]</span>
+    </div>
+    <div>
+      <button class="btn btn-default" ng-click="addBilling(circ)">
+        [% l('Add Billing') %]
+      </button>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Check Out Date') %]</div>
+    <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Due Date') %]</div>
+    <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">{{circ.stop_fines_time() | date:'short'}}</div>
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">{{circ.checkin_time() | date:'short'}}</div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Check Out Library') %]</div>
+    <div class="flex-cell well">{{circ.circ_lib().shortname()}}</div>
+    <div class="flex-cell">[% l('Renewal?') %]</div>
+    <div class="flex-cell well">{{
+      circ.phone_renewal() == 't' ||
+      circ.desk_renewal() == 't' ||
+      circ.opac_renewal() == 't'
+    }}</div>
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">{{circ.stop_fines()}}</div>
+    <div class="flex-cell">[% l('Check In Library') %]</div>
+    <div class="flex-cell well">{{circ.checkin_lib().shortname()}}</div>
+  </div>
+  <hr/>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2
new file mode 100644 (file)
index 0000000..24a3241
--- /dev/null
@@ -0,0 +1,189 @@
+<div class="col-md-6" ng-show="!prev_circ_summary">
+  <div class="alert alert-info">
+    [% l('No Previous Circ Group') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="prev_circ_summary">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Previous Circ Group') %]
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{prev_circ_usr.id()}}/checkout" 
+        ng-if="prev_circ_summary" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{prev_circ_usr.family_name()}}'
+          '{{prev_circ_usr.first_given_name()}}'
+          '{{prev_circ_usr.second_given_name()}}'
+          '{{prev_circ_usr.card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.num_circs()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.start_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.checkout_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Last Renewed On') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_renewal_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_renewal_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_stop_fines()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_stop_fines_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{prev_circ_summary.last_checkin_workstation()}}
+    </div>
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="!circ">
+  <div class="alert alert-info">
+    [% l('No Recent Circ Group') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="circ">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Most Recent Circ Group') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{circ.usr().id()}}/checkout" 
+        ng-if="circ" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{circ.usr().family_name()}}'
+          '{{circ.usr().first_given_name()}}'
+          '{{circ.usr().second_given_name()}}'
+          '{{circ.usr().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.num_circs()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">
+      {{circ.xact_start() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.workstation().name()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Last Renewed On') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.last_renewal_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ_summary.last_renewal_workstation()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Reason') %]</div>
+    <div class="flex-cell well">
+      {{circ.stop_fines()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Stop Fines Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.stop_fines_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_workstation.name()}}
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_holds_pane.tt2
new file mode 100644 (file)
index 0000000..601c128
--- /dev/null
@@ -0,0 +1,125 @@
+<div class="col-md-6" ng-show="!hold">
+  <div class="alert alert-info">
+    [% l('Item is not captured for a hold') %]
+  </div>
+</div>
+<div class="col-md-6" ng-show="hold">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Captured Hold Info') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Patron') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{hold.usr().id()}}/checkout" 
+        ng-if="hold" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{hold.usr().family_name()}}'
+          '{{hold.usr().first_given_name()}}'
+          '{{hold.usr().second_given_name()}}'
+          '{{hold.usr().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Requestor') %]</div>
+    <div class="flex-cell well">
+      <a href="./circ/patron/{{hold.requestor().id()}}/checkout" 
+        ng-if="hold" target="_self">
+        [% l('[_1], [_2] [_3] : [_4]', 
+          '{{hold.requestor().family_name()}}'
+          '{{hold.requestor().first_given_name()}}'
+          '{{hold.requestor().second_given_name()}}'
+          '{{hold.requestor().card().barcode()}}') %]
+      </a>
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Pickup Lib') %]</div>
+    <div class="flex-cell well">
+      {{hold.pickup_lib().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+    <div class="flex-cell well">
+      {{hold.current_shelf_lib().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Request Date') %]</div>
+    <div class="flex-cell well">
+      {{hold.request_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Capture Date') %]</div>
+    <div class="flex-cell well">
+      {{hold.capture_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Shelf Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.shelf_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Shelf Expire Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.shelf_expire_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Hold Expire Time') %]</div>
+    <div class="flex-cell well">
+      {{hold.expire_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Behind Desk') %]</div>
+    <div class="flex-cell well">
+      {{hold.behind_desk()}}
+    </div>
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="!transit">
+  <div class="alert alert-info">
+    [% l('Item has not transited') %]
+  </div>
+</div>
+
+<div class="col-md-6" ng-show="transit">
+  <div class="flex-row">
+    <div class="flex-cell flex-2 strong-text-2">
+      [% l('Most Recent Transit') %]
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Source') %]</div>
+    <div class="flex-cell well">
+      {{transit.source().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Destination') %]</div>
+    <div class="flex-cell well">
+      {{transit.dest().shortname()}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Send Time') %]</div>
+    <div class="flex-cell well">
+      {{transit.source_send_time() | date:'short'}}
+    </div>
+  </div>
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Transit Receive Time') %]</div>
+    <div class="flex-cell well">
+      {{transit.source_recv_time() | date:'short'}}
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_list.tt2 b/Open-ILS/src/templates/staff/cat/item/t_list.tt2
new file mode 100644 (file)
index 0000000..cbd2e76
--- /dev/null
@@ -0,0 +1,21 @@
+<eg-grid
+  id-field="index"
+  idl-class="acp"
+  features="-display,-sort,-multisort"
+  main-label="[% l('Item Status') %]"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="cat.items">
+
+  <eg-grid-field label="[% l('Barcode') %]"     path='barcode' visible></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/item/t_summary_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
new file mode 100644 (file)
index 0000000..6918124
--- /dev/null
@@ -0,0 +1,178 @@
+<style>
+/* FIXME: move me */
+#item-status-alert-msg {
+  flex:7; /* fill the remaining horizontal space */
+}
+</style>
+
+<div class="">
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Barcode') %]</div>
+    <div class="flex-cell well">{{copy.barcode()}}</div>
+
+    <div class="flex-cell">[% l('Circ Library') %]</div>
+    <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+    <div class="flex-cell">[% l('Call # Prefix') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().prefix().label()}}
+    </div>
+
+    <div class="flex-cell">[% l('Status') %]</div>
+    <div class="flex-cell well">{{copy.status().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Price') %]</div>
+    <div class="flex-cell well">{{copy.price()}}</div>
+
+    <div class="flex-cell">[% l('Owning Library') %]</div>
+    <div class="flex-cell well">{{copy.circ_lib().shortname()}}</div>
+
+    <div class="flex-cell">[% l('Call #') %]</div>
+    <div class="flex-cell well">{{copy.call_number().label()}}</div>
+
+    <div class="flex-cell">[% l('Due Date') %]</div>
+    <div class="flex-cell well">{{circ.due_date() | date:'short'}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('ISBN') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().record().simple_record().isbn()}}
+    </div>
+
+    <div class="flex-cell">[% l('Copy Location') %]</div>
+    <div class="flex-cell well">{{copy.location().name()}}</div>
+
+    <div class="flex-cell">[% l('Call # Suffix') %]</div>
+    <div class="flex-cell well">
+      {{copy.call_number().suffix().label()}}
+    </div>
+
+    <div class="flex-cell">[% l('Checkout Date') %]</div>
+    <div class="flex-cell well">{{circ.xact_start() | date:'short'}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Date Created') %]</div>
+    <div class="flex-cell well">{{copy.create_date() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Loan Duration') %]</div>
+    <div class="flex-cell well">{{circ.duration()}}</div>
+
+    <div class="flex-cell">[% l('Renewal Type') %]</div>
+    <div class="flex-cell well">
+      <div ng-if="circ.opac_renewal() == 't'">[% l('OPAC') %]</div>
+      <div ng-if="circ.desk_renewal() == 't'">[% l('Desk') %]</div>
+      <div ng-if="circ.phone_renewal() == 't'">[% l('Phone') %]</div>
+    </div>
+
+    <div class="flex-cell">[% l('Checkout Workstation') %]</div>
+    <div class="flex-cell well">{{circ.workstation().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Date Active') %]</div>
+    <div class="flex-cell well">{{copy.active_date() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Fine Level') %]</div>
+    <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs') %]</div>
+    <div class="flex-cell well">{{total_circs}}</div>
+
+    <div class="flex-cell">[% l('Duration Rule') %]</div>
+    <div class="flex-cell well">{{circ.duration_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Status Changed') %]</div>
+    <div class="flex-cell well">{{copy.status_changed_time() | date:'short'}}</div>
+
+    <div class="flex-cell">[% l('Reference') %]</div>
+    <div class="flex-cell well">{{copy.ref()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs - Current Year') %]</div>
+    <div class="flex-cell well">{{total_circs_this_year}}</div>
+
+    <div class="flex-cell">[% l('Recurring Fine Rule') %]</div>
+    <div class="flex-cell well">{{circ.recurring_fine_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Copy ID') %]</div>
+    <div class="flex-cell well">{{copy.id()}}</div>
+
+    <div class="flex-cell">[% l('OPAC Visible') %]</div>
+    <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+    <div class="flex-cell">[% l('Total Circs - Prev Year') %]</div>
+    <div class="flex-cell well">{{total_circs_prev_year}}</div>
+
+    <div class="flex-cell">[% l('Max Fine Rule') %]</div>
+    <div class="flex-cell well">{{circ.max_fine_rule().name()}}</div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('TCN') %]</div>
+    <div class="flex-cell well">{{copy.call_number().record().tcn_value()}}</div>
+
+    <div class="flex-cell">[% l('Holdable') %]</div>
+    <div class="flex-cell well">{{copy.opac_visible()}}</div>
+
+    <div class="flex-cell">[% l('Renewal Workstation') %]</div>
+    <div class="flex-cell well">{{circ_summary.last_renewal_workstation()}}</div>
+
+    <div class="flex-cell">[% l('Checkin Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_time() || 
+        circ_summary.last_checkin_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Floating') %]</div>
+    <div class="flex-cell well">{{copy.floating()}}</div>
+
+    <div class="flex-cell">[% l('Circulate') %]</div>
+    <div class="flex-cell well">{{copy.circulate()}}</div>
+
+    <div class="flex-cell">[% l('Remaining Renewals') %]</div>
+    <div class="flex-cell well">{{circ.renewal_remaining()}}</div>
+
+    <div class="flex-cell">[% l('Checkin Scan Time') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_scan_time() || 
+        circ_summary.last_checkin_scan_time() | date:'short'}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <!-- empty -->
+    <div class="flex-cell"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell">[% l('Circ Modifier') %]</div>
+    <div class="flex-cell well">{{copy.circ_modifier().name()}}</div>
+
+    <!-- empty -->
+    <div class="flex-cell"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell">[% l('Checkin Workstation') %]</div>
+    <div class="flex-cell well">
+      {{circ.checkin_workstation().name() || 
+        circ_summary.last_checkin_workstation().name()}}
+    </div>
+  </div>
+
+  <div class="flex-row">
+    <div class="flex-cell">[% l('Alert Message') %]</div>
+    <div id="item-status-alert-msg" class="well">
+      {{copy.alert_message()}}
+    </div>
+  </div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2 b/Open-ILS/src/templates/staff/cat/item/t_triggered_events_pane.tt2
new file mode 100644 (file)
index 0000000..1e32073
--- /dev/null
@@ -0,0 +1,2 @@
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/cat/item/t_view.tt2 b/Open-ILS/src/templates/staff/cat/item/t_view.tt2
new file mode 100644 (file)
index 0000000..82920b4
--- /dev/null
@@ -0,0 +1,34 @@
+<eg-record-summary record="summaryRecord"></eg-record-summary>
+
+<!-- tabbed copy data view -->
+
+<div class="pad-vert"></div>
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : tab == 'summary'}">
+    <a href="./cat/item/{{copy.id()}}">[% l('Quick Summary') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'circs'}">
+    <a href="./cat/item/{{copy.id()}}/circs">[% l('Recent Circ History') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'circ_list'}">
+    <a href="./cat/item/{{copy.id()}}/circ_list">[% l('Circ History List') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'holds'}">
+    <a href="./cat/item/{{copy.id()}}/holds">[% l('Holds / Transit') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'cat'}">
+    <a href="./cat/item/{{copy.id()}}/cat">[% l('Cataloging Info') %]</a>
+  </li>
+  <li ng-class="{active : tab == 'triggered_events'}">
+    <a href="./cat/item/{{copy.id()}}/triggered_events">[% l('Triggered Events') %]</a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+    <div ng-if="tab.length">
+      <div ng-include="'[% ctx.base_path %]/staff/cat/item/t_'+tab+'_pane'"></div>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2 b/Open-ILS/src/templates/staff/cat/share/t_record_summary.tt2
new file mode 100644 (file)
index 0000000..3de97e5
--- /dev/null
@@ -0,0 +1,53 @@
+<div class="strong-text-2">[% l('Record Summary') %]</div>
+
+<div class="flex-container-striped flex-container-bordered">
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Title:') %]</div>
+    <div class="flex-cell flex-2">
+      <a target="_self" 
+        href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.id()}}">
+        {{record.simple_record().title()}}
+      </a>
+    </div>
+
+    <div class="flex-cell strong-text">[% l('Edition:') %]</div>
+    <div class="flex-cell"><!-- FIXME: no edition field on simple record --></div>
+
+    <div class="flex-cell strong-text">[% l('TCN:') %]</div>
+    <div class="flex-cell">{{record.tcn_value()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Created By:') %]</div>
+    <div class="flex-cell">{{record.creator().usrname()}}</div>
+  </div><!-- flex-row -->
+
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Author:') %]</div>
+    <div class="flex-cell flex-2">{{record.simple_record().author()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Pub Date:') %]</div>
+    <div class="flex-cell">
+      {{record.simple_record().pubdate()}}
+    </div>
+
+    <div class="flex-cell strong-text">[% l('Databse ID:') %]</div>
+    <div class="flex-cell">{{record.id()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Last Edited By:') %]</div>
+    <div class="flex-cell">{{record.editor().usrname()}}</div>
+  </div><!-- flex-row -->
+
+  <div class="flex-row">
+    <div class="flex-cell strong-text">[% l('Bib Call #:') %]</div>
+    <div class="flex-cell flex-2"><!-- FIXME: no bib call no on simple rec --></div>
+
+    <div class="flex-cell strong-text"></div>
+    <div class="flex-cell"></div>
+
+    <div class="flex-cell strong-text">[% l('Record Owner:') %]</div>
+    <div class="flex-cell">{{record.owner().shortname()}}</div>
+
+    <div class="flex-cell strong-text">[% l('Last Edited On:') %]</div>
+    <div class="flex-cell">{{record.edit_date() | date:'short'}}</div>
+  </div><!-- flex-row -->
+</div>
+
diff --git a/Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2 b/Open-ILS/src/templates/staff/cat/t_triggered_events_pane.tt2
new file mode 100644 (file)
index 0000000..1e32073
--- /dev/null
@@ -0,0 +1,2 @@
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/checkin/index.tt2 b/Open-ILS/src/templates/staff/circ/checkin/index.tt2
new file mode 100644 (file)
index 0000000..dd2c0cf
--- /dev/null
@@ -0,0 +1,19 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Check In"); 
+  ctx.page_app = "egCheckinApp";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/checkin/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2 b/Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2
new file mode 100644 (file)
index 0000000..83b88f5
--- /dev/null
@@ -0,0 +1,225 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span ng-if="!is_capture">[% l('Checkin Items') %]</span>
+    <span ng-if="is_capture">[% l('Capture Holds') %]</span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-12">
+    <div class="flex-row left-anchored">
+      <div ng-if="is_backdate()" class="alert-danger pad-all-min">
+        [% l('Backdated Check In [_1]', 
+          '{{checkinArgs.backdate | date:"shortDate"}}') %]
+      </div>
+      <div ng-if="modifiers.no_precat_alert" class="alert-danger pad-all-min">
+        [% l('Ignore Pre-Cataloged Items') %]
+      </div>
+      <div ng-if="modifiers.noop" class="alert-danger pad-all-min">
+        [% l('Suppress Holds and Transits') %]
+      </div>
+      <div ng-if="modifiers.void_overdues" class="alert-danger pad-all-min">
+        [% l('Amnesty Mode') %]
+      </div>
+      <div ng-if="modifiers.auto_print_holds_transits" 
+        class="alert-danger pad-all-min">
+        [% l('Auto-Print Hold and Transit Slips') %]
+      </div>
+      <div ng-if="modifiers.clear_expired" class="alert-danger pad-all-min">
+        [% l('Clear Holds Shelf') %]
+      </div>
+      <div ng-if="modifiers.retarget_holds" class="alert-danger pad-all-min">
+        <div ng-if="modifiers.retarget_holds_all">
+          [% l('Always Retarget Local Holds') %]
+        </div>
+        <div ng-if="!modifiers.retarget_holds_all">
+          [% l('Retarget Local Holds') %]
+        </div>
+      </div>
+      <div ng-if="modifiers.hold_as_transit" class="alert-danger pad-all-min">
+        [% l('Capture Local Holds As Transits') %]
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- checkin form -->
+<div class="row pad-vert">
+  <div class="col-md-4">
+    <form ng-submit="checkin(checkinArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <label class="input-group-addon" 
+          for="patron-checkin-barcode" >[% l('Barcode') %]</label>
+
+        <input focus-me="focusMe" blur-me="blurMe" 
+          class="form-control"
+          ng-model="checkinArgs.copy_barcode" 
+          placeholder="[% l('Barcode') %]"
+          id="patron-checkin-barcode" type="text"/> 
+
+        <input type="submit" class="btn btn-default" value="[% l('Submit') %]"/>
+      </div>
+    </form>
+  </div>
+
+  <div class="col-md-4">
+    <div ng-if="alert" class="col-md-12 alert-danger pad-all-min">
+      <span ng-if="alert.already_checked_in">
+        [% l('[_1] was already checked in.', '{{alert.already_checked_in}}') %]
+      </span>
+      <span ng-if="alert.item_never_circed">
+        [% l('Item [_1] has never circulated.', '{{alert.item_never_circed}}') %]
+      </span>
+    </div>
+  </div>
+
+  <div class="col-md-4" ng-if="!is_capture">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="pad-horiz">[% l('Effective Date') %]</div>
+      <!-- date max= not yet supported -->
+      <div><input eg-date-input
+        class="form-control" ng-model="checkinArgs.backdate"/>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="row" ng-if="fine_total">
+  <div class="col-md-12">
+    <span>[% l('Fine Tally:') %]</span>
+    <span class="pad-horiz alert alert-danger">{{fine_total | currency}}</span>
+    <span ng-if="billable_barcode">
+      <span>[% l('Transaction for [_1] billed:', '{{billable_barcode}}') %]</span>
+      <span class="pad-horiz alert alert-danger">{{billable_amount | currency}}</span>
+    </span>
+  </div>
+</div>
+
+<hr/>
+
+[% INCLUDE 'staff/circ/checkin/t_checkin_table.tt2' %]
+
+<div class="row pad-vert">
+  <div class="col-md-10">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="pad-horiz">
+        <button class="btn btn-default" 
+          ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+      </div>
+      <div class="checkbox" ng-if="using_hatch">
+        <label>
+          <input ng-model="show_print_dialog" type="checkbox"/>
+          [% l('Show Print Dialog') %]
+        </label>
+      </div>
+      <div class="pad-horiz" ng-if="using_hatch"></div>
+      <div class="checkbox">
+        <label>
+          <input ng-model="trim_list" type="checkbox"/>
+          [% l('Trim List (20 Rows)') %]
+        </label>
+      </div>
+      <div class="pad-horiz"></div>
+      <div class="checkbox">
+        <label>
+          <input ng-model="strict_barcode" type="checkbox"/>
+          [% l('Strict Barcode') %]
+        </label>
+      </div>
+    </div><!-- flex row -->
+  </div><!-- col -->
+  <div class="col-md-2">
+    <div class="input-group-btn" dropdown>
+      <button type="button" class="btn btn-default dropdown-toggle">
+        [% l('Checkin Modifiers') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu pull-right">
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('no_precat_alert')">
+            <span ng-if="modifiers.no_precat_alert" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.no_precat_alert"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Ignore Pre-cataloged Items') %]</span>
+          </a>
+        </li>
+        <li ng-if="!is_capture"><!-- nonsensical for hold capture -->
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('noop')">
+            <span ng-if="modifiers.noop" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.noop"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Suppress Holds and Transits') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('void_overdues')">
+            <span ng-if="modifiers.void_overdues" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.void_overdues"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Amnesty Mode') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('auto_print_holds_transits')">
+            <span ng-if="modifiers.auto_print_holds_transits" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.auto_print_holds_transits"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Auto-Print Hold and Transit Slips') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('clear_expired')">
+            <span ng-if="modifiers.clear_expired" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.clear_expired"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Clear Holds Shelf') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('retarget_holds')">
+            <span ng-if="modifiers.retarget_holds" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.retarget_holds"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Retarget Local Holds') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('retarget_holds_all')">
+            <span ng-if="modifiers.retarget_holds_all" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.retarget_holds_all"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Retarget All Statuses') %]</span>
+          </a>
+        </li>
+        <li>
+          <a href dropdown-toggle 
+            ng-click="toggle_mod('hold_as_transit')">
+            <span ng-if="modifiers.hold_as_transit" 
+              class="label label-success">&#x2713;</span>
+            <span ng-if="!modifiers.hold_as_transit"
+              class="label label-warning">&#x2717;</span>
+            <span>[% l('Capture Local Holds As Transits') %]</span>
+          </a>
+        </li>
+      </ul>
+    </div><!-- btn grp -->
+  </div><!-- col -->
+</div><!-- row -->
+
diff --git a/Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2 b/Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
new file mode 100644 (file)
index 0000000..1ee0c09
--- /dev/null
@@ -0,0 +1,89 @@
+<!-- checkins list -->
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  main-label="[% l('Items Checked In') %]"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="{{grid_persist_key}}">
+
+  <eg-grid-action 
+    handler="fetchLastCircPatron"
+    label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showBackdateDialog"
+    label="[% l('Backdate Post-Checkin') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showMarkDamaged"
+    label="[% l('Mark Items Damaged') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="abortTransit"
+    label="[% l('Abort Transits') %]">
+  </eg-grid-action>
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Checkin Date') %]"    
+    path='circ.checkin_time' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Finish') %]"    
+    path='circ.stop_fines_time'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]"    
+    path='acp.location.name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Route To') %]" path='route_to'>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Start') %]"    
+    path='circ.xact_start'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+  <eg-grid-field path="transit.*" parent-idl-class="atc" hidden></eg-grid-field>
+  <eg-grid-field path="hold.*" parent-idl-class="ahr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/holds/index.tt2 b/Open-ILS/src/templates/staff/circ/holds/index.tt2
new file mode 100644 (file)
index 0000000..b327738
--- /dev/null
@@ -0,0 +1,30 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Holds Shelf"); 
+  ctx.page_app = "egHoldsApp";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/holds/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.CLEAR_SHELF_ACTION_shelf = "[% l('Reshelve') %]";
+  s.CLEAR_SHELF_ACTION_hold = "[% l('Needed for Hold') %]";
+  s.CLEAR_SHELF_ACTION_transit = "[% l('Needs Transiting') %]";
+  s.CLEAR_SHELF_ACTION_pl_changed = "[% l('Wrong Shelf') %]";
+}])
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_pull.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_pull.tt2
new file mode 100644 (file)
index 0000000..de2988b
--- /dev/null
@@ -0,0 +1,26 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Holds Pull List') %]</span>
+  </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_pull_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_pull_list.tt2
new file mode 100644 (file)
index 0000000..8cea1d1
--- /dev/null
@@ -0,0 +1,88 @@
+<div ng-if="print_list_progress !== null" class="strong-text-2">
+  [% l('Loading... [_1]', '{{print_list_progress}}') %]
+</div>
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.holds.pull">
+
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="print_full_list" 
+    label="[% l('Print Full List') %]"></eg-grid-menu-item>
+
+  <!--
+  The Alternate print UI appears to be generated in a very similar
+  fashion to our native full list printer.  Also, since it's 
+  generated from a separate standalone HTML page, the print
+  action bypasses Hatch and goes straight to the browser printer.
+  <eg-grid-menu-item handler="print_list_alt" 
+    label="[% l('Print Full List (Alt)') %]"></eg-grid-menu-item>
+  -->
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Part') %]" path='part.label'></eg-grid-field>
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Copy Location') %]" path='copy.location.name'></eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path='volume.label'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_shelf.tt2
new file mode 100644 (file)
index 0000000..b0a4aaf
--- /dev/null
@@ -0,0 +1,40 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Holds Shelf') %]</span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-3">
+    <div class="input-group">
+      <span class="input-group-addon">[% l('Pickup Library') %]</span>
+      <eg-org-selector selected="pickup_ou" ng-disabled="is_clearing()"></eg-org-selector>
+    </div>
+  </div>
+  <div class="col-md-3" ng-show="is_clearing()">
+    <progressbar max="clear_progress.max" value="clear_progress.value">
+      <span class="progressbar-text">{{clear_progress.value}} / {{clear_progress.max}}</span>
+    </progressbar>
+  </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/holds/t_shelf_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2 b/Open-ILS/src/templates/staff/circ/holds/t_shelf_list.tt2
new file mode 100644 (file)
index 0000000..3700df9
--- /dev/null
@@ -0,0 +1,85 @@
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.holds.shelf">
+
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="show_clearable" 
+    hidden="clear_mode" disabled="is_clearing"
+    label="[% l('Show Clearable Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="show_active" 
+    hidden="active_mode" disabled="is_clearing"
+    label="[% l('Show All Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-menu-item handler="clear_holds" disabled="disable_clear"
+    label="[% l('Clear These Holds') %]"></eg-grid-menu-item>
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Post-Clear') %]" path='post_clear'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/in_house_use/index.tt2 b/Open-ILS/src/templates/staff/circ/in_house_use/index.tt2
new file mode 100644 (file)
index 0000000..2299603
--- /dev/null
@@ -0,0 +1,81 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("In-House Use"); 
+  ctx.page_app = "egInHouseUseApp";
+  ctx.page_ctrl = "InHouseUseCtrl";
+%]
+
+[% 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/circ/in_house_use/app.js"></script>
+[% END %]
+
+<style>
+  /* FIXME: MOVE ME */
+  #in-house-use-barcode {width: 16em;}
+  #in-house-use-form { margin-bottom: 20px }
+</style>
+
+<form id="in-house-use-form" ng-submit="checkout(args)" role="form">
+  <div class="row">
+
+    <div class="col-md-2">
+      <div class="input-group">
+        <label class="input-group-addon" for="in-house-num-uses">
+          [% l('# of Uses:') %]
+        </label>
+        <input type="number" min="1" max="{{countMax}}"
+          class="form-control" focus-me="useFocus"
+           id="in-house-num-uses" ng-model="args.num_uses"/>
+      </div>
+    </div>
+
+    <div class="col-md-6">
+      <div class="input-group">
+        <div class="input-group-btn" dropdown>
+          <button type="button" class="btn btn-default dropdown-toggle">
+            {{selectedNcType() || "[% l('Barcode') %]"}}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu">
+            <li><a href dropdown-toggle
+              ng-click="args.noncat_type='barcode';bcFocus=true">
+              [% l('Barcode') %]</a>
+            </li>
+            <li class="divider"></li>
+            <li><a href dropdown-toggle
+              ng-repeat='type in nonCatTypes'
+              ng-click="args.noncat_type=type.id()">{{type.name()}}</a>
+            </li>
+          </ul>
+        </div>
+
+        <input type="text" id="in-house-use-barcode" focus-me="bcFocus"
+          class="form-control" ng-model="args.barcode"
+          ng-disabled="args.noncat_type != 'barcode'"/>
+        <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+      </div><!-- input group -->
+    </div><!-- col -->
+  </div><!-- row -->
+</form>
+
+<div clas="row" ng-if="copyNotFound">
+  <div class="col-md-6 alert alert-danger">[% l('Copy Not Found') %]</div>
+</div>
+
+<eg-grid
+  id-field="index"
+  features="-display,-sort,-multisort"
+  main-label="[% l('In-House Use') %]"
+  items-provider="gridDataProvider"
+  persist-key="circ.in_house_use">
+  <eg-grid-field label="[% l('# of Uses') %]"   path='num_uses' visible></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]"     path='copy.barcode' visible></eg-grid-field>
+  <eg-grid-field label="[% l('Call Number') %]" path="copy.call_number.label" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Location') %]"    path="copy.location.name" visible></eg-grid-field>
+  <eg-grid-field label="[% l('Title') %]"       path="title" visible></eg-grid-field>
+</eg-grid>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/index.tt2 b/Open-ILS/src/templates/staff/circ/patron/index.tt2
new file mode 100644 (file)
index 0000000..9048faa
--- /dev/null
@@ -0,0 +1,175 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Patron"); 
+  ctx.page_app = "egPatronApp";
+  ctx.page_ctrl = "PatronCtrl";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/billing.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/holds.js"></script>
+[% INCLUDE 'staff/circ/share/hold_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
+
+<!-- load the rest on demand? -->
+
+<!-- required for credentials verify API -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/checkout.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/items_out.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/holds.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/bills.js"></script>
+
+<!-- TODO: APP_JS should really be called APP_ADDONS or some such.
+    It just means "load these things, too, and load them last" -->
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.ANNOTATE_PAYMENT_MSG = "[% l('Please annotate this payment') %]";
+  s.CONFIRM_REFUND_PAYMENT = 
+    "[% |l('{{xactIds}}') -%]Are you sure you would like to refund excess payment on bills [_1]?  This action will simply put the amount in the Payment Pending column as a negative value.  You must still select Apply Payment!  Certain types of payments may not be refunded.  The refund may be applied to checked transactions that follow the refunded transaction.[% END %]";
+  s.EDIT_BILL_PAY_NOTE = "[% l('Enter new note for #[_1]:','{{ids}}') %]";
+  s.GROUP_ADD_USER = "[% l('Enter the patron barcode') %]";
+  s.RENEW_ITEMS = "[% l('Renew Items?') %]";
+  s.RENEW_ALL_ITEMS = "[% l('Renew All Items?') %]";
+  s.CHECK_IN_CONFIRM = "[% l('Check In Items?') %]";
+}]);
+</script>
+
+[% END %]
+
+<div class="row">
+  <div class="col-md-3">
+    <div ng-show="patron()">
+      <h4 title="{{patron().id()}}">
+        <div class="flex-row">
+          <div class="flex-cell">
+            [% l('[_1], [_2] [_3]', 
+                '{{patron().family_name()}}',
+                '{{patron().first_given_name()}}',
+                '{{patron().second_given_name()}}') %]
+          </div>
+          <div ng-show="tab != 'search'">
+            <a href ng-click="toggle_expand_summary()"
+              title="[% l('Collapse Patron Summary Display') %]"
+              ng-hide="collapse_summary()">
+              <span class="glyphicon glyphicon-resize-small"></span>
+            </a>
+            <a href ng-click="toggle_expand_summary()"
+              title="[% l('Expand Patron Summary Display') %]"
+              ng-show="collapse_summary()">
+              <span class="glyphicon glyphicon-resize-full"></span>
+            </a>
+          </div>
+        </div><!-- row -->
+      </h4>
+    </div><!-- if patron -->
+  </div><!-- col -->
+  <div class="col-md-9">
+    <ul class="nav nav-pills nav-pills-like-tabs">
+      <li ng-class="{active : tab == 'checkout', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/checkout">[% l('Check Out') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'items_out', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/items_out">
+          [% l('Items Out') %] 
+          <span ng-if="patron()"><!-- lack of space / newline below intentional -->
+          (<span ng-class="{'patron-summary-alert-small' : patron_stats().checkouts.overdue}">{{patron_stats().checkouts.total_out}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'holds', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/holds">
+          [% l('Holds') %]
+          <span ng-if="patron()">
+            (<span>{{patron_stats().holds.total}} / {{patron_stats().holds.ready}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'bills', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/bills">
+          [% l('Bills') %]
+          <span ng-if="patron()">
+            (<span ng-class="{'patron-summary-alert-small' : patron_stats().fines.balance_owed}">{{patron_stats().fines.balance_owed | currency}}</span>)
+          </span>
+        </a>
+      </li>
+      <li ng-class="{active : tab == 'messages', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/messages">[% l('Messages') %]</a>
+      </li>
+      <li ng-class="{active : tab == 'edit', disabled : !patron()}">
+        <a href="./circ/patron/{{patron().id()}}/edit">[% l('Edit') %]</a>
+      </li>
+      <li class="dropdown" ng-class="{active : tab == 'other', disabled : !patron()}">
+        <a href class="dropdown-toggle" data-toggle="dropdown">
+            [% l('Other') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/alerts">
+              [% l('Display Alert and Messages') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/notes">
+              [% l('Notes') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/triggered_events">
+              [% l('Triggered Events / Notifications') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/stat_cats">
+              [% l('Statistical Categories') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/group">
+              [% l('Group Member Details') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/edit_perms">
+              [% l('User Permission Editor') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/{{patron().id()}}/credentials">
+              [% l('Test Password') %]
+            </a>
+          </li>
+       </ul>
+      </li>
+      <li ng-class="{active : tab == 'search'}" class="pull-right">
+        <a href="./circ/patron/search">[% l('Patron Search') %]</a>
+      </li>
+    </ul>
+  </div><!-- col -->
+</div><!-- row -->
+
+<div class="row">
+  <div class="col-md-3" ng-hide="collapse_summary()">
+    [% INCLUDE 'staff/circ/patron/t_summary.tt2' %]
+  </div>
+  <div ng-class="{'col-md-12' : collapse_summary(),'col-md-9' : !collapse_summary()}">
+    <div class="tab-content">
+      <div class="tab-pane active">
+        <div ng-view></div>
+      </div>
+    </div>
+  </div><!-- col -->
+</div><!-- row -->
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/pending.tt2 b/Open-ILS/src/templates/staff/circ/patron/pending.tt2
new file mode 100644 (file)
index 0000000..21c21cb
--- /dev/null
@@ -0,0 +1,17 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Pending Patrons"); 
+  ctx.page_app = "egPendingPatronsApp";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/pending.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/register.tt2 b/Open-ILS/src/templates/staff/circ/patron/register.tt2
new file mode 100644 (file)
index 0000000..4a3c9ce
--- /dev/null
@@ -0,0 +1,15 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Patron Registration"); 
+  ctx.page_app = "egPatronRegApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/register.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_alerts.tt2
new file mode 100644 (file)
index 0000000..822240c
--- /dev/null
@@ -0,0 +1,75 @@
+<div>
+
+  <!-- FIXME: move image file -->
+  <img src='/xul/server/skin/media/images/stop_sign.png'>
+
+  <div class="alert alert-info" ng-if="patron_stats().holds.ready > 0">
+    [% l('Holds available: [_1]', '{{patron_stats().holds.ready}}') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patronExpired">
+    [% l('Patron account is EXPIRED.') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patronExpiresSoon">
+    [% l('Patron account will expire soon.  Please renew.') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patron().barred() == 't'">
+    [% l('Patron account is BARRED') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="patron().active() == 'f'">
+    [% l('Patron account is INACTIVE') %]
+  </div>
+
+  <div class="alert alert-warning" ng-if="retrievedWithInactive">
+    [% l('Patron account retrieved with an INACTIVE card.') %]
+  </div>
+
+  <!-- alert message -->
+  <div class="row" ng-if="patron().alert_message()">
+    <div class="col-md-12">
+      <div class="panel panel-warning">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Alert Message') %]</div>
+        </div>
+        <div class="panel-body">
+          {{patron().alert_message()}}
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- penalties -->
+  <div class="row" ng-if="alert_penalties().length">
+    <div class="col-md-12">
+      <div class="panel panel-warning">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Penalties') %]</div>
+        </div>
+        <div class="panel-body">
+          <div class="row" 
+            ng-repeat="penalty in alert_penalties()">
+            <div class="col-md-2">
+              {{penalty.org_unit().shortname()}}
+            </div>
+            <div class="col-md-8"
+              title="{{penalty.standing_penalty().name()}}">
+              {{penalty.standing_penalty().label()}}
+              <div>{{penalty.note()}}</div><!-- force newline -->
+            </div>
+            <div class="col-md-2">
+              {{penalty.set_date() | date:'shortDate'}}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <br/>
+  <div class="well">
+[% l('Press a navigation button above (for example, Check Out) to clear this alert.') %]
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bcsearch.tt2
new file mode 100644 (file)
index 0000000..216b186
--- /dev/null
@@ -0,0 +1,22 @@
+
+<form ng-submit="submitBarcode(args)" role="form" class="form-inline">
+  <div class="input-group">
+
+    <label class="input-group-addon" 
+      for="patron-checkout-barcode" >[% l('Patron Barcode') %]</label>
+
+    <input select-me="selectMe" class="form-control"
+      ng-model="args.barcode" 
+      placeholder="[% l('Patron Barcode') %]"
+      id="patron-checkout-barcode" type="text"/> 
+
+    <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+  </div>
+</form>
+
+<br/>
+<div class="alert alert-warning" ng-show="bcNotFound">
+  [% l('Barcode Not Found: [_1]', '{{bcNotFound}}') %]
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history.tt2
new file mode 100644 (file)
index 0000000..6739d7e
--- /dev/null
@@ -0,0 +1,34 @@
+<h2>[% l('Bill History') %]</h2>
+
+<ul class="nav nav-tabs">
+  <li ng-class="{active : bill_tab == 'transactions'}">
+    <a href="./circ/patron/{{patron().id()}}/bill_history/transactions">
+        [% l('Transactions') %]
+    </a>
+  </li>
+  <li ng-class="{active : bill_tab == 'payments'}">
+    <a href="./circ/patron/{{patron().id()}}/bill_history/payments">
+        [% l('Payments') %]
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <div class="flex-row padded">
+      <div ng-if="bill_tab == 'transactions'">[% l('Selected Billed:') %]</div>
+      <div ng-if="bill_tab == 'transactions'">{{totals.selected_billed() | currency}}</div>
+      <div>[% l('Selected Paid:') %]</div>
+      <div>{{totals.selected_paid() | currency}}</div>
+      <div class="flex-cell"></div>
+      <div>[% l('Start Date:') %]</div>
+      <div><input eg-date-input class="form-control" ng-model="dates.xact_start"/></div>
+      <div>[% l('End Date:') %]</div>
+      <div><input eg-date-input class="form-control" ng-model="dates.xact_finish"/></div>
+    </div><!-- top row -->
+    <hr/>
+    [% INCLUDE 'staff/circ/patron/t_bill_history_xacts.tt2' %]
+    [% INCLUDE 'staff/circ/patron/t_bill_history_payments.tt2' %]
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_payments.tt2
new file mode 100644 (file)
index 0000000..c4fa9d3
--- /dev/null
@@ -0,0 +1,79 @@
+
+<div ng-if="bill_tab == 'payments'" ng-controller="BillPaymentHistoryCtrl">
+
+  <eg-grid
+    idl-class="mp"
+    id-field="id"
+    grid-controls="gridControls">
+
+    <eg-grid-action 
+      label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+    <eg-grid-field path="amount" label="[% l('Amount') %]"></eg-grid-field>
+    <eg-grid-field path="id" label="[% l('Payment ID') %]"></eg-grid-field>
+    <eg-grid-field path="payment_ts" label="[% l('Payment Time') %]"></eg-grid-field>
+    <eg-grid-field path="note" label="[% l('Note') %]"></eg-grid-field>
+    <eg-grid-field path="voided" label="[% l('Voided') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.xact_type" label="[% l('Transaction Type') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.last_billing_type" label="[% l('Last Billing Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="xact.circulation.target_copy.call_number.record.simple_record.title">
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <!-- needed for bib link -->
+    <eg-grid-field name="record_id" 
+      path="xact.circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="xact.circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <!-- needed for item link -->
+    <eg-grid-field name="copy_id" 
+      path="xact.circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <!-- ... -->
+
+    <eg-grid-field path="xact.id" required hidden></eg-grid-field>
+    <eg-grid-field path="xact.usr" required hidden></eg-grid-field>
+    <eg-grid-field path="xact.*" hidden></eg-grid-field>
+    <eg-grid-field path="xact.summary.*" hidden></eg-grid-field>
+
+    <!--
+    <eg-grid-field path="xact.summary.balance_owed"></eg-grid-field>
+    <eg-grid-field path="xact.xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+    <eg-grid-field path="xact.xact_start" label="[% l('Start') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+    <eg-grid-field path="xact.summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="circulation.target_copy.call_number.record.simple_record.title">
+      <a href="[% ctx.base_path %]/opac/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <eg-grid-field name="record_id" 
+      path="circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <eg-grid-field name="copy_id" 
+      path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+    -->
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bill_history_xacts.tt2
new file mode 100644 (file)
index 0000000..70f48b6
--- /dev/null
@@ -0,0 +1,49 @@
+
+<div ng-if="bill_tab == 'transactions'" ng-controller="BillXactHistoryCtrl">
+
+  <eg-grid
+    idl-class="mbt"
+    id-field="id"
+    grid-controls="gridControls">
+
+    <eg-grid-action 
+      label="[% l('Add Billing') %]" handler="addBilling"></eg-grid-action>
+    <eg-grid-action 
+      label="[% l('Full Details') %]" handler="showFullDetails"></eg-grid-action>
+
+    <eg-grid-field path="summary.balance_owed"></eg-grid-field>
+    <eg-grid-field path="id" label="[% l('Bill #') %]"></eg-grid-field>
+    <eg-grid-field path="xact_finish" label="[% l('Finish') %]"></eg-grid-field>
+    <eg-grid-field path="xact_start" label="[% l('Start') %]"></eg-grid-field>
+    <eg-grid-field path="summary.total_owed" label="[% l('Total Billed') %]"></eg-grid-field>
+    <eg-grid-field path="summary.total_paid" label="[% l('Total Paid') %]"></eg-grid-field>
+    <eg-grid-field path="summary.xact_type" label="[% l('Type') %]"></eg-grid-field>
+
+    <eg-grid-field label="[% l('Title') %]" name="title" 
+      path="circulation.target_copy.call_number.record.simple_record.title">
+      <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+    </eg-grid-field>
+
+    <!-- needed for bib link -->
+    <eg-grid-field name="record_id" 
+      path="circulation.target_copy.call_number.record.id" 
+      required hidden></eg-grid-field>
+
+    <eg-grid-field label="[% l('Barcode') %]" name="copy_barcode" 
+      path="circulation.target_copy.barcode">
+      <a target="_self" href="./cat/item/{{item.copy_id}}">{{item.copy_barcode}}</a>
+    </eg-grid-field>
+
+    <!-- needed for item link -->
+    <eg-grid-field name="copy_id" 
+      path="circulation.target_copy.id" required hidden></eg-grid-field>
+
+    <!-- needed for grid query -->
+    <eg-grid-field path="summary.last_payment_ts" required hidden></eg-grid-field>
+
+    <eg-grid-field path="summary.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.*" hidden></eg-grid-field>
+    <eg-grid-field path="circulation.target_copy.call_number.*" hidden></eg-grid-field>
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
new file mode 100644 (file)
index 0000000..b8ab917
--- /dev/null
@@ -0,0 +1,109 @@
+
+<div class="row">
+  <div class="col-md-7">
+
+    <div class="row">
+      <div class="col-md-4">[% l('Total Owed:') %]</div>
+      <div class="col-md-2 strong-text">{{summary.balance_owed() | currency}}</div>
+      <div class="col-md-4">[% l('Refunds Available:') %]</div>
+      <div class="col-md-2">{{refunds_available() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Total Billed:') %]</div>
+      <div class="col-md-2">{{summary.total_owed() | currency}}</div>
+      <div class="col-md-4">[% l('Credit Available:') %]</div>
+      <div class="col-md-2">{{patron().credit_forward_balance() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Total Paid:') %]</div>
+      <div class="col-md-2">{{summary.total_paid() | currency}}</div>
+      <div class="col-md-4">[% l('Session Voided:') %]</div>
+      <div class="col-md-2">{{session_voided | currency}}</div>
+    </div>
+    <div class="row"><hr/></div>
+    <div class="row">
+      <div class="col-md-4">[% l('Owed for Selected:') %]</div>
+      <div class="col-md-2">{{owed_selected() | currency}}</div>
+      <div class="col-md-4">[% l('Pending Payment:') %]</div>
+      <div class="col-md-2 strong-text">{{pending_payment() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Billed for Selected:') %]</div>
+      <div class="col-md-2">{{billed_selected() | currency}}</div>
+      <div class="col-md-4">[% l('Pending Change:') %]</div>
+      <div class="col-md-2 strong-text">{{pending_change() | currency}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Paid for Selected:') %]</div>
+      <div class="col-md-2">{{paid_selected() | currency}}</div>
+    </div>
+  </div><!-- col -->
+
+  <div class="col-md-5">
+    <form role="form" class="form-horizontal" ng-submit="applyPayment()">
+      <fieldset>
+        <legend>[% l('Pay Bill') %]</legend>
+
+        <div class="form-group">
+          <label for="type-input" class="col-md-6 control-label">[% l('Payment Type') %]</label>
+          <div class="col-md-6">
+            <select ng-model="payment_type" class="form-control">
+              <option value="cash_payment" selected="selected">[% l('Cash') %]</option>
+              <option value="check_payment">[% l('Check') %]</option>
+              <option value="credit_card_payment">[% l('Credit Card') %]</option>
+              <option value="credit_payment">[% l('Patron Credit') %]</option>
+              <option value="work_payment">[% l('Work') %]</option>
+              <option value="forgive_payment">[% l('Forgive') %]</option>
+              <option value="goods_payment">[% l('Goods') %]</option>
+            </select>
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="amount-input" class="col-md-6 control-label">
+            [% l('Payment Received') %]
+          </label>
+          <div class="col-md-6">
+            <input type="number" min="0" step="any" id="amount-input" 
+              ng-model="payment_amount" focus-me="focus_payment" 
+              value="" class="form-control col-md-6 "/>
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="annotate-payment" class="control-label col-md-5">[% l('Annotate') %]</label>
+          <div class="col-md-1">
+            <input id="annotate-payment" type="checkbox" ng-model="annotate_payment"/>
+          </div>
+          <div class="col-md-6">
+            <button type="submit" class="btn btn-default">[% l('Apply Payment') %]</button>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  </div>
+</div>
+
+<div class="pad-vert">
+[% INCLUDE 'staff/circ/patron/t_bills_list.tt2' %]
+</div>
+
+<!-- pull-right is causing the content to flow several pixels 
+off to the right.  flex-row is honoring the boundaries better. 
+not sure what's up, there. -->
+<div class="flex-row">
+  <div class="flex-cell"></div>
+  <form class="form-inline" role="form">
+   <div class="checkbox">
+      <label>
+        <input type="checkbox" ng-model="receipt_on_pay"/> 
+        [% l('Receipt On Payment') %]
+      </label>
+    </div>
+    <div class="form-group" style="margin-left:10px">
+      <label for="bill-receipt-copies">[% l('# Copies') %]</label>
+      <input type="number" min="1" style="width:5em"
+        ng-model="receipt_count"
+        class="form-control" id="bill-receipt-copies"/>
+    </div>
+  </form>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_bills_list.tt2
new file mode 100644 (file)
index 0000000..c98d041
--- /dev/null
@@ -0,0 +1,87 @@
+
+<eg-grid
+  idl-class="mbt"
+  query="gridQuery"
+  sort="gridSort"
+  grid-controls="gridControls"
+  revision="gridRevision"
+  persist-key="circ.patron.bills">
+
+  <eg-grid-menu-item label="[% l('Bill Patron') %]" 
+    handler="showBillDialog"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('History') %]" 
+    handler="showHistory"></eg-grid-menu-item>
+
+  <eg-grid-menu-item label="[% l('Check All Refunds') %]" 
+    handler="selectRefunds"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Print Bills') %]" 
+    handler="printBills"></eg-grid-action>
+
+  <!--
+  need to decide if these are necessary here w/ inline links
+  to record and copy details (though they could be hidden).
+  it's misleading to allow the user to select multiple bills
+  but only open the link to one
+
+  <eg-grid-action label="[% l('Show in Catalog') %]" 
+    handler=""></eg-grid-action>
+
+  <eg-grid-action label="[% l('Show Item Details') %]" 
+    handler=""></eg-grid-action>
+  -->
+
+  <eg-grid-action label="[% l('Void All Billings') %]" 
+    handler="voidAllBillings"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Refund') %]" 
+    handler="refundXact"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Add Billing') %]" 
+    handler="addBilling"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Full Details') %]" 
+    handler="showFullDetails"></eg-grid-action>
+
+  <eg-grid-field label="[% ('Balance Owed') %]" path='summary.balance_owed'></eg-grid-field>
+  <eg-grid-field label="[% ('Bill #') %]" path='id'></eg-grid-field>
+  <eg-grid-field label="[% ('Start') %]" path='xact_start'></eg-grid-field>
+  <eg-grid-field label="[% ('Total Billed') %]" path='summary.total_owed'></eg-grid-field>
+  <eg-grid-field label="[% ('Total Paid') %]" path='summary.total_paid'></eg-grid-field>
+  <eg-grid-field label="[% ('Type') %]" path='xact_type'></eg-grid-field>
+
+  <!-- receipt data -->
+  <eg-grid-field path='summary.last_billing_type' required></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" name="title"
+    path='circulation.target_copy.call_number.record.simple_record.title'>
+    <a target="_self" href="[% ctx.base_path %]/staff/cat/catalog/record/{{item.record_id}}">{{item.title}}</a>
+  </eg-grid-field>
+  <!-- fetch the record ID so we can link to it.  hide it by default -->
+  <eg-grid-field path="circulation.target_copy.call_number.record.id" 
+    label="[% l('Record ID') %]" name="record_id" required hidden>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" required
+    path='circulation.target_copy.barcode' name="copy_barcode">
+    <a href="./cat/item/{{item.copy_id}}" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+  <!-- fetch the copy ID so we can link to it.  hide it by default -->
+  <eg-grid-field path="circulation.target_copy.id" 
+    label="[% l('Copy ID') %]" name="copy_id" required hidden>
+  </eg-grid-field>
+
+  <!-- virtual field -->
+  <eg-grid-field datatype="money" label="[% ('Payment Pending') %]" 
+    name="payment_pending"></eg-grid-field>
+
+  <!-- import all circ fields, hidden by default -->
+  <eg-grid-field path='circulation.*' hidden> </eg-grid-field>
+
+  <eg-grid-field path='circulation.target_copy.*' hidden> </eg-grid-field>
+  
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
new file mode 100644 (file)
index 0000000..945f2db
--- /dev/null
@@ -0,0 +1,143 @@
+<!-- item checkout form / list -->
+
+<div class="row pad-vert">
+  <div class="col-md-6">
+    <form ng-submit="checkout(checkoutArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <div class="input-group-btn" dropdown>
+          <button type="button" class="btn btn-default dropdown-toggle"
+            ng-class="{disabled : disable_checkout()}">
+            {{selectedNcType() || "[% l('Barcode') %]"}}
+            <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu">
+            <li><a href dropdown-toggle
+              ng-click="checkoutArgs.noncat_type='barcode';focusMe=true">
+              [% l('Barcode') %]</a>
+            </li>
+            <li class="divider"></li>
+            <li>
+              <a href ng-repeat='type in nonCatTypes' dropdown-toggle
+               ng-click="checkoutArgs.noncat_type=type.id()">{{type.name()}}</a>
+            </li>
+          </ul>
+        </div>
+
+        <input focus-me="focusMe" class="form-control"
+          ng-model="checkoutArgs.copy_barcode" 
+          ng-disabled="checkoutArgs.noncat_type != 'barcode' || disable_checkout()"
+          id="patron-checkout-barcode" type="text"/> 
+
+        <input class="btn btn-default" type="submit" 
+          ng-class="{disabled : disable_checkout()}" value="[% l('Submit') %]"/>
+
+      </div>
+    </form>
+  </div>
+  <div class="col-md-6">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="checkbox pad-horiz">
+        <label>
+          <input type="checkbox" ng-model="checkoutArgs.sticky_date"/>
+          [% l('Specific Due Date') %]
+        </label>
+      </div>
+      <!--
+      <div><input type="checkbox" class="checkbox" ng-model="checkoutArgs.sticky_date"/></div>
+      <div class="pad-horiz">[% l('Specific Due Date') %]</div>
+      -->
+      <!-- FIXME: This needs a time component as well, but type="datetime" 
+            is not yet supported by any browsers -->
+      <div><input eg-date-input class="form-control" 
+        ng-model="checkoutArgs.due_date"/>
+      </div>
+    </div>
+  </div>
+</div>
+<hr/>
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.patron.checkout">
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]" 
+    path='acp.location.name'> </eg-grid-field>
+
+  <eg-grid-field label="[% l('Remaining Renewals') %]" 
+    path='circ.renewal_remaining'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="strict_barcode" type="checkbox"/>
+      [% l('Strict Barcode') %]
+    </label>
+  </div>
+  <div class="pad-horiz" ng-if="using_hatch"></div>
+  <div class="checkbox" ng-if="using_hatch">
+    <label>
+      <input ng-model="show_print_dialog" type="checkbox"/>
+      [% l('Show Print Dialog') %]
+    </label>
+  </div>
+  <div class="pad-horiz">
+    <button class="btn btn-default" 
+      ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+  </div>
+  <div>
+    <button class="btn btn-default" 
+      ng-click="done()">[% l('Done') %]</button>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_credentials.tt2
new file mode 100644 (file)
index 0000000..524cb7f
--- /dev/null
@@ -0,0 +1,65 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-6">
+      <fieldset>
+        <legend>[% l('Verify Credentials') %]</legend>
+        <form ng-submit="verify()" 
+          name="verify-creds-form" class="form-horizontal" role="form">
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-username">[% l('Username') %]</label>
+            <div class="col-md-8">
+              <input type="text" id="verify-username" class="form-control" 
+                focus-me="focusMe" ng-disabled="prepop"
+                placeholder="[% l('Username') %]" ng-model="username"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-barcode">[% l('Barcode') %]</label>
+            <div class="col-md-8">
+              <input type="text" id="verify-barcode" class="form-control" 
+                ng-disabled="prepop"
+                placeholder="[% l('Barcode') %]" ng-model="barcode"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label class="col-md-4 control-label" 
+              for="verify-password">[% l('Password') %]</label>
+            <div class="col-md-8">
+              <input type="password" id="verify-password" class="form-control" 
+                placeholder="[% l('Password') %]" ng-model="password"/>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <div class="col-md-offset-4 col-md-2">
+              <button type="submit" class="btn btn-default">[% l('Verify') %]</button>
+            </div>
+            <div class="col-md-2" ng-hide="prepop">
+              <button class="btn btn-default" ng-click="load($event)">[% l('Retrieve') %]</button>
+            </div>
+          </div>
+
+          <div class="form-group" ng-cloak>
+            <div class="col-md-offset-4 col-md-8">
+              <div class="alert alert-success" ng-show="verified">
+                [% l('Succes testing credentials') %]
+              </div>
+              <div class="alert alert-danger" ng-show="verified === false">
+                [% l('Failure testing credentials') %]
+              </div>
+              <div class="alert alert-danger" ng-show="notFound">
+                [% l('No user found with the requested username / barcode') %]
+              </div>
+            </div>
+          </div>
+
+        </form>
+      </fieldset>
+    <div><!-- col -->
+  </div><!-- row -->
+</div><!-- container -->
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
new file mode 100644 (file)
index 0000000..3f85366
--- /dev/null
@@ -0,0 +1,2 @@
+<eg-embed-frame url="patron_edit_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit_due_date_dialog.tt2
new file mode 100644 (file)
index 0000000..dcca6d3
--- /dev/null
@@ -0,0 +1,24 @@
+<form ng-submit="ok(args)" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Edit Due Date For [_1] Items', '{{args.num_circs}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group row pad-vert">
+      <div class="col-md-6">
+        [% l('Enter Due Date: ') %]
+      </div>
+      <div class="col-md-6">
+        <input eg-date-input class="form-control" ng-model="args.due_date"/>
+      </div>
+    </div>
+    <!-- TODO: time picker -->
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+    <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_edit_perms.tt2
new file mode 100644 (file)
index 0000000..76d10f6
--- /dev/null
@@ -0,0 +1 @@
+<eg-embed-frame url="user_perms_url" handlers="funcs"></eg-embed-frame>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_group.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_group.tt2
new file mode 100644 (file)
index 0000000..21bb4c2
--- /dev/null
@@ -0,0 +1,55 @@
+
+<div class="strong-text-2">[% l('Group Member Details') %]</div>
+<div class="pad-vert flex-row">
+  <div>[% l('Total Owed: ') %]</div>
+  <div class="pad-horiz">{{totals.owed | currency}}</div>
+  <div>[% l('Total Out: ') %]</div>
+  <div class="pad-horiz">{{totals.total_out}}</div>
+  <div>[% l('Total Overdue: ') %]</div>
+  <div class="pad-horiz">{{totals.overdue}}</div>
+</div>
+<div class="pad-vert"></div>
+<eg-grid
+  idl-class="au"
+  sort="gridSort"
+  grid-controls="gridControls"
+  menu-label="[% l('Group Actions') %]">
+
+  <eg-grid-menu-item handler="moveToGroup"
+    label="[% l('Move Another Patron To This Group') %]"></eg-grid-menu-item>
+
+  <eg-grid-action 
+    label="[% l('Register a New Group Member by Cloning Selected Patron') %]" 
+    handler="cloneUser"></eg-grid-action>
+
+  <eg-grid-action label="[% l('Remove Selected From Group') %]" 
+    handler="removeFromGroup"></eg-grid-action>
+
+  <eg-grid-action label="[% l("Move Selected Patrons to Another Patron's Group") %]" 
+    handler="moveToAnotherGroup"></eg-grid-action>
+
+  <eg-grid-action label="[% l("Retrieve Selected Patron") %]" 
+    handler="retrieveSelected"></eg-grid-action>
+
+  <eg-grid-field path="active"></eg-grid-field>
+  <eg-grid-field path="barred"></eg-grid-field>
+  <eg-grid-field path="dob"></eg-grid-field>
+  <eg-grid-field path="family_name"></eg-grid-field>
+  <eg-grid-field path="first_given_name"></eg-grid-field>
+  <eg-grid-field path="master_account"></eg-grid-field>
+  <eg-grid-field path="second_given_name"></eg-grid-field>
+  <eg-grid-field path="stats.fines.balance_owed" nonsortable label="[% l('Balance Owed') %]"></eg-grid-field>
+  <eg-grid-field path="stats.checkouts.out" nonsortable label="[% l('Items Out') %]"></eg-grid-field>
+  <eg-grid-field path="stats.checkouts.overdue" nonsortable label="[% l('Items Overdue') %]"></eg-grid-field>
+
+  <!-- needed for query, sorting -->
+  <eg-grid-field path="id" hidden required></eg-grid-field>
+  <eg-grid-field path="usrgroup" hidden required></eg-grid-field>
+  <eg-grid-field path="deleted" hidden required></eg-grid-field>
+  <eg-grid-field path="create_date" hidden required></eg-grid-field>
+
+  <!--
+  <eg-grid-field path=".*"></eg-grid-field>
+  -->
+
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2
new file mode 100644 (file)
index 0000000..5ccd762
--- /dev/null
@@ -0,0 +1,38 @@
+<!-- patron holds list -->
+
+<ul class="nav nav-tabs" ng-if="!detail_hold_id">
+  <li ng-class="{active : holds_display == 'main', disabled : detail_hold_id}">
+    <a href ng-click="show_main_list()">[% l('Open Hold Requests') %]</a>
+  </li>
+  <li ng-class="{active : holds_display == 'alt', disabled : detail_hold_id}">
+    <a href ng-click="show_alt_list()">[% l('Recently Canceled Holds') %]</a>
+  </li>
+</ul>
+
+<div class="pad-vert"></div>
+
+<div ng-if="!detail_hold_id">
+[% INCLUDE 'staff/circ/patron/t_holds_list.tt2' %]
+</div>
+
+<!-- hold details -->
+<div ng-if="detail_hold_id">
+  <div class="row">
+    <div class="col-md-2">
+      <button class="btn btn-default" ng-click="list_view()">
+        [% l('List View') %]
+      </button>
+    </div>
+  </div>
+  <div class="pad-vert"></div>
+  <eg-record-summary record='detail_hold_record' 
+    record-id="detail_hold_record_id"></eg-record-summary>
+  <eg-hold-details hold-retrieved="set_hold" hold-id="detail_hold_id"></eg-hold-details>
+</div>
+
+<!-- catalog view for holds placement -->
+
+<div ng-if="placing_hold">
+  <eg-embed-frame url="catalog_url" handlers="handlers" 
+    onchange="handle_page"></eg-embed-frame>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_create.tt2
new file mode 100644 (file)
index 0000000..e8e82e4
--- /dev/null
@@ -0,0 +1,4 @@
+<!-- holds are created within the catalog -->
+
+<eg-embed-frame url="catalog_url" 
+  handlers="handlers" onchange="handle_page"></eg-embed-frame>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2
new file mode 100644 (file)
index 0000000..df026fa
--- /dev/null
@@ -0,0 +1,82 @@
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.patron.holds">
+
+  <eg-grid-menu-item handler="place_hold" 
+    label="[% l('Place Hold') %]"></eg-grid-menu-item>
+  <eg-grid-menu-item handler="detail_view" 
+    label="[% l('Detail View') %]"></eg-grid-menu-item>
+
+  <eg-grid-action handler="grid_actions.show_recent_circs"
+    label="[% l('Show Last Few Circulations') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_copy_quality"
+    label="[% l('Set Desired Copy Quality') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_pickup_lib"
+    label="[% l('Edit Pickup Library') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_notify_prefs"
+    label="[% l('Edit Notification Settings') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.edit_dates"
+    label="[% l('Edit Hold Dates') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.activate"
+    label="[% l('Activate') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.suspend"
+    label="[% l('Suspend') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.set_top_of_queue"
+    label="[% l('Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.clear_top_of_queue"
+    label="[% l('Un-Set Top of Queue') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.transfer_to_marked_title"
+    label="[% l('Transfer To Marked Title') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_damaged"
+    label="[% l('Mark Item Damaged') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.mark_missing"
+    label="[% l('Mark Item Missing') %]"></eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.retarget"
+    label="[% l('Find Another Target') %]"></eg-grid-action>
+  <eg-grid-action handler="grid_actions.cancel_hold"
+    label="[% l('Cancel Hold') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Hold ID') %]" path='hold.id'></eg-grid-field>
+  <eg-grid-field label="[% l('Current Copy') %]" 
+    path='hold.current_copy.barcode'>
+    <a href="./cat/item/{{item.hold.current_copy().id()}}/summary" target="_self">
+      {{item.hold.current_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Request Date') %]" path='hold.request_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Capture Date') %]" path='hold.capture_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Available Date') %]" path='hold.shelf_time'></eg-grid-field>
+  <eg-grid-field label="[% l('Hold Type') %]" path='hold.hold_type'></eg-grid-field>
+  <eg-grid-field label="[% l('Pickup Library') %]" path='hold.pickup_lib.shortname'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path='mvr.title'>
+    <a href="[% ctx.base_path %]/opac/record/{{item.mvr.doc_id()}}">
+      {{item.mvr.title()}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]" path='mvr.author'></eg-grid-field>
+  <eg-grid-field label="[% l('Potential Copies') %]" path='potential_copies'></eg-grid-field>
+  <eg-grid-field label="[% l('Status') %]" path='status_string'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Queue Position') %]" path='queue_position' hidden></eg-grid-field>
+  <eg-grid-field path='hold.*' parent-idl-class="ahr" hidden></eg-grid-field>
+  <eg-grid-field path='copy.*' parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path='volume.*' parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path='mvr.*' parent-idl-class="mvr" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div>
+    <button class="btn btn-default" ng-click="print()">
+      [% l('Print') %]
+    </button>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
new file mode 100644 (file)
index 0000000..71fc1a0
--- /dev/null
@@ -0,0 +1,73 @@
+<!-- items out list -->
+
+<div ng-if="show_alt_circs">
+  <!-- only show the main vs. alt circ list tabs if the alt
+      circ list is meant to display -->
+  <ul class="nav nav-tabs">
+    <li ng-class="{active : items_out_display == 'main'}">
+      <a href ng-click="show_main_list()">
+        [% l('Items Checked Out') %] ({{main_list.length}})
+      </a>
+    </li>
+    <li ng-class="{active : items_out_display == 'alt'}">
+      <a href ng-click="show_alt_list()">
+        [% l('Other/Special Circulations') %] ({{alt_list.length}})
+      </a>
+    </li>
+  </ul>
+</div>
+<div ng-if="!show_alt_circs" class="strong-text-2">
+  [% l('Items Checked Out') %]
+</div>
+
+<div class="tab-content">
+  <div class="tab-pane active">
+<eg-grid
+  idl-class="circ"
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  persist-key="circ.patron.items_out">
+
+  <eg-grid-action handler="print_receipt"
+    label="[% l('Print Item Receipt') %]"></eg-grid-action>
+  <eg-grid-action handler="edit_due_date"
+    label="[% l('Edit Due Date') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_lost"
+    label="[% l('Mark Lost (By Patron)') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_claims_returned"
+    label="[% l('Mark Claims Returned') %]"></eg-grid-action>
+  <eg-grid-action handler="mark_claims_never_checked_out"
+    label="[% l('Mark Claims Never Checked Out') %]"></eg-grid-action>
+  <eg-grid-action handler="renew" label="[% l('Renew') %]"></eg-grid-action>
+  <eg-grid-action handler="renew_all" label="[% l('Renew All') %]"></eg-grid-action>
+  <eg-grid-action handler="renew_with_date" 
+    label="[% l('Renew With Specific Due Date') %]"></eg-grid-action>
+  <eg-grid-action handler="checkin" 
+    label="[% l('Check In') %]"></eg-grid-action>
+  <eg-grid-action handler="add_billing" 
+    label="[% l('Add Billing') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Circ ID') %]" path='id'></eg-grid-field>
+  <eg-grid-field label="[% l('Barcode') %]" path='target_copy.barcode'>
+    <a href="./cat/item/{{item.target_copy().id()}}" target="_self">
+      {{item.target_copy().barcode()}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field label="[% l('Due Date') %]" path='due_date' dateformat='short'></eg-grid-field>
+  <eg-grid-field label="[% l('Checkout / Renewal Library') %]" path='circ_lib.shortname'></eg-grid-field>
+  <eg-grid-field label="[% l('Renewals Remaining') %]" path='renewal_remaining'></eg-grid-field>
+  <eg-grid-field label="[% l('Fines Stopped') %]" path='stop_fines'></eg-grid-field>
+  <eg-grid-field label="[% l('Title') %]" name="title">
+    <a href="[% ctx.base_path %]/opac/record/{{item.target_copy().call_number().record().id()}}">
+      {{item.target_copy().call_number().record().simple_record().title()}}
+    </a>
+  </eg-grid-field>
+  <eg-grid-field path="*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.record.*" hidden></eg-grid-field>
+  <eg-grid-field path="target_copy.call_number.record.simple_record.*" hidden></eg-grid-field>
+</eg-grid>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_last_patron.tt2
new file mode 100644 (file)
index 0000000..9a917d8
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="col-md-6">
+  <div ng-if="no_last" class="alert alert-warning">
+    [% l('No patrons recently accessed.') %]
+    <span class="pad-horiz">
+      <a href='./circ/patron/search'>[% l('Try Patron Search') %]</a>
+    </span>
+  </div>
+  <br/>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2
new file mode 100644 (file)
index 0000000..e23c258
--- /dev/null
@@ -0,0 +1,52 @@
+
+<div class="strong-text-2">[% l('Staff-Generated Penalties / Messages') %]</div>
+<div class="pad-vert"></div>
+<eg-grid
+  idl-class="ausp"
+  grid-controls="activeGridControls">
+
+  <eg-grid-menu-item handler="createPenalty"
+    label="[% l('Apply Penalty / Message') %]"></eg-grid-menu-item>
+
+  <eg-grid-action label="[% l('Remove Penalty / Message') %]" 
+    handler="removePenalty"></eg-grid-action>
+  <eg-grid-action label="[% l('Modify Penalty / Message') %]" 
+    handler="editPenalty"></eg-grid-action>
+  <eg-grid-action label="[% l('Archive Penalty / Message') %]" 
+    handler="archivePenalty"></eg-grid-action>
+
+  <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+  <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+  <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+  <eg-grid-field path="note"></eg-grid-field>
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+<div class="pad-vert"><hr/></div>
+
+<div class="pad-vert flex-row padded">
+  <div class="strong-text-2">[% l('Archived Penalties / Messages') %]</div>
+  <div class="flex-cell"></div>
+  <div>[% l('Set Date Start:') %]</div>
+  <div><input eg-date-input class="form-control" ng-model="dates.start_date"/></div>
+  <div>[% l('Set Date End:') %]</div>
+  <div><input eg-date-input class="form-control" ng-model="dates.end_date"/></div>
+</div>
+<eg-grid
+  idl-class="ausp"
+  grid-controls="archiveGridControls">
+
+  <eg-grid-field path="set_date" label="[% l('Applied On') %]"></eg-grid-field>
+  <eg-grid-field path="standing_penalty.label"></eg-grid-field>
+  <eg-grid-field path="org_unit.shortname" label="[% l('Library') %]"></eg-grid-field>
+  <eg-grid-field path="note"></eg-grid-field>
+  <eg-grid-field path="id" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.block_list" required hidden></eg-grid-field>
+  <eg-grid-field path="standing_penalty.*" hidden></eg-grid-field>
+
+</eg-grid>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_move_to_group_dialog.tt2
new file mode 100644 (file)
index 0000000..96e9733
--- /dev/null
@@ -0,0 +1,28 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 ng-if="!outbound" class="modal-title">
+      [% l('Move user into this group?') %]
+    </h4>
+    <h4 ng-if="outbound" class="modal-title">
+      [% l("Move selected users to the following user's group?") %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <a href="./circ/patron/{{user.id()}}/checkout" target="_self">
+      [% 
+        l('[_1], [_2] [_3] : [_4]', 
+          '{{user.family_name()}}',
+          '{{user.first_given_name()}}',
+          '{{user.second_given_name()}}',
+          '{{user.card().barcode()}}') 
+      %]
+    </a>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-primary" ng-click="ok()">[% l('Move User') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_new_note_dialog.tt2
new file mode 100644 (file)
index 0000000..cfc5b40
--- /dev/null
@@ -0,0 +1,42 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create a new note') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-title">[% l('Title') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-pub">[% l('Patron Visible?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-pub" ng-model="args.pub"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-value">[% l('Value') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-value" ng-model="args.value" placeholder="[% l('Value...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_notes.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_notes.tt2
new file mode 100644 (file)
index 0000000..b83f317
--- /dev/null
@@ -0,0 +1,44 @@
+<div class="row">
+  <div class="col-md-12">
+    <button class="btn btn-default" ng-click="newNote()">
+      [% l('Add New Note') %]
+    </button>
+  </div>
+</div>
+
+<div class="row pad-vert" ng-repeat="note in notes">
+  <div class="col-md-12">
+    <div class="row">
+      <div class="col-md-6 strong-text">{{note.title()}}</div>
+      <div class="col-md-6">
+        <div class="pull-right">
+          <span class="pad-horiz alert alert-warning" ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+          <span class="pad-horiz alert alert-info" ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+          <span class="pad-horiz">{{note.create_date() | date:'short'}}</span>
+          <span>[% l('Created by [_1]', '{{note.creator().usrname()}}') %]</span>
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <!-- hmm, not sure why the margin-left is needed.. the well? -->
+      <div class="col-md-12 well" style="margin-left:12px">
+        <div class="row">
+          <div class="col-md-8">
+            <div class="">{{note.value()}}</div>
+          </div>
+          <div class="col-md-4">
+            <div class="pull-right">
+              <button ng-click="printNote(note)" class="btn btn-default">
+                [% l('Print') %]
+              </button>
+              <button ng-click="deleteNote(note)" class="btn btn-warning">
+                [% l('Delete') %]
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <hr/>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_pending_list.tt2
new file mode 100644 (file)
index 0000000..3809d12
--- /dev/null
@@ -0,0 +1,34 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Pending Patrons') %]</span>
+  </div>
+</div>
+
+<span>[% l('Home Library: ' ) %]</span>
+<span><eg-org-selector selected="context_org"></eg-org-selector></span>
+<hr/>
+
+<eg-grid
+  id-field="id"
+  features="-sort,-multisort"
+  items-provider="grid_data_provider"
+  grid-controls="grid_controls"
+  persist-key="circ.pending_patrons.list">
+
+  <eg-grid-menu-item handler="load_patron" 
+    label="[% l('Load Patron') %]"></eg-grid-menu-item>
+
+  <eg-grid-field path='user.row_date' label="[% l('Create Date') %]"></eg-grid-field>
+  <eg-grid-field path='user.first_given_name' label="[% l('First Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.second_given_name' label="[% l('Middle Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.family_name' label="[% l('Last Name') %]"></eg-grid-field>
+  <eg-grid-field path='user.email' label="[% l('Email') %]"></eg-grid-field>
+  <eg-grid-field path='user.home_ou.shortname' label="[% l('Home Library') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.street1' label="[% l('Street 1') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.city' label="[% l('City') %]"></eg-grid-field>
+  <eg-grid-field path='mailing_address.post_code' label="[% l('Post Code') %]"></eg-grid-field>
+  <eg-grid-field path='user.usrname' label="[% l('Requested Username') %]"></eg-grid-field>
+  <eg-grid-field path='user.*' parent-idl-class="stgu" hidden></eg-grid-field>
+  <eg-grid-field path='mailing_address.*' parent-idl-class="stgma" hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_renew_with_date_dialog.tt2
new file mode 100644 (file)
index 0000000..d8112d0
--- /dev/null
@@ -0,0 +1,24 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Renew Items with Specific Due Date') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div class="pad-vert row">
+    <div class="col-md-12">
+      [% l('Enter due date for items: [_1]', '{{args.barcodes.join(" ")}}') %]
+    </div>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-5">
+      <input eg-date-input required 
+        class="form-control" ng-model="args.date"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2
new file mode 100644 (file)
index 0000000..29abb40
--- /dev/null
@@ -0,0 +1,158 @@
+
+<!-- TODO: inputs need sr-only labels
+   <label class="sr-only" for="input-id">label</label>
+-->
+
+<div class="row" id="patron-search-form-row">
+  <div class="col-md-11">
+    <form ng-submit="search(searchArgs)" id="patron-search-form" 
+        role="form" class="form-horizontal">
+
+      <div class="form-group">
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            focus-me="focusMe"
+            ng-model="searchArgs.family_name" placeholder="[% l('Last Name') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.first_given_name" placeholder="[% l('First Name') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.second_given_name" placeholder="[% l('Middle Name') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="submit" class="btn btn-default" value="[% l('Search') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="reset" class="btn btn-default" ng-click="searchArgs={}" 
+            value="[% l('Clear Form') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <button class="btn btn-default" ng-click="applyShowExtras($event, true)" 
+            title="[% l('Show More Fields') %]" ng-show="!showExtras">
+            <span class="glyphicon glyphicon-circle-arrow-down"></span>
+          </button>
+          <button class="btn btn-default" ng-click="applyShowExtras($event, false)" 
+            title="[% l('Show Fewer Fields') %]" ng-show="showExtras">
+            <span class="glyphicon glyphicon-circle-arrow-up"></span>
+          </button>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.card" 
+            placeholder="[% l('Barcode') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.alias" placeholder="[% l('Alias') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.usrname" placeholder="[% l('Username') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.email" placeholder="[% l('Email') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.ident" placeholder="[% l('Identification') %]"/>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.id" placeholder="[% l('Database ID') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.phone" placeholder="[% l('Phone') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.street1" placeholder="[% l('Street 1') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.street2" placeholder="[% l('Street 2') %]"/>
+        </div>
+        <div class="col-md-2">
+          <input type="text" class="form-control" 
+            ng-model="searchArgs.city" placeholder="[% l('City') %]"/>
+        </div>
+      </div>
+
+      <div class="form-group" ng-show="showExtras">
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.state" 
+            placeholder="[% l('State') %]" title="[% l('State') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-model="searchArgs.post_code" 
+            placeholder="[% l('Post Code') %]" title="[% l('Post Code') %]"/>
+        </div>
+
+        <div class="col-md-2">
+          <!--
+          <input type="text" class="form-control"  
+            placeholder="[% l('Profile Group') %]"
+            ng-model="searchArgs.profile"
+            typeahead="grp as grp.name for grp in profiles | filter:$viewValue" 
+            typeahead-editable="false" />
+            -->
+
+            <div class="btn-group patron-search-selector" dropdown>
+              <button type="button" class="btn btn-default dropdown-toggle">
+                <span style="padding-right: 5px;">{{searchArgs.profile.name() || "[% l('Profile Group') %]"}}</span>
+                <span class="caret"></span>
+              </button>
+              <ul class="dropdown-menu">
+                <li ng-repeat="grp in profiles">
+                  <a href dropdown-toggle
+                    style="padding-left: {{pgt_depth(grp) * 10 + 5}}px"
+                    ng-click="searchArgs.profile = grp">{{grp.name()}}</a>
+                </li>
+              </ul>
+            </div>
+        </div>
+
+        <div class="col-md-2">
+          <eg-org-selector label="[% l('Home Library') %]" 
+            selected="searchArgs.home_ou">
+          </eg-org-selector>
+        </div>
+
+        <div class="col-md-2">
+          <div class="checkbox">
+            <label>
+              <input type="checkbox" ng-model="searchArgs.inactive"/>
+              [% l('Include Inactive?') %]
+            </label>
+          </div>
+        </div>
+      </div>
+    </form>
+  </div>
+</div>
+
+
+<br/>
+<div class="row">
+  <div class="col-md-12">
+    [% INCLUDE 'staff/circ/patron/t_search_results.tt2' %]
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2
new file mode 100644 (file)
index 0000000..ac83e6d
--- /dev/null
@@ -0,0 +1,30 @@
+<eg-grid
+  idl-class="au" id-field="id"
+  features="-sort,-display,-multisort"
+  main-label="[% l('Patron Search Results') %]"
+  grid-controls="gridControls"
+  items-provider="patronSearchGridProvider"
+  persist-key="circ.patron.search">
+  <eg-grid-field label="[% ('ID') %]" path='id' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Card') %]" path='card.barcode' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Last Name') %]" path='family_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('First Name') %]" path='first_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('Middle Name') %]" path='second_given_name' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('DoB') %]" path='dob' visible sortable multisortable></eg-grid-field>
+  <eg-grid-field label="[% ('Home Library') %]" path='home_ou.shortname' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Created On') %]" path='create_date' visible sortable multisortable></eg-grid-field>
+
+  <eg-grid-field label="[% ('Mailing:Street 1') %]" path='mailing_address.street1' visible></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:Street 2') %]" path='mailing_address.street2'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:City') %]" path='mailing_address.city'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:County') %]" path='mailing_address.county'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:State') %]" path='mailing_address.state'></eg-grid-field>
+  <eg-grid-field label="[% ('Mailing:Zip') %]" path='mailing_address.post_code'></eg-grid-field>
+
+  <eg-grid-field label="[% ('Billing:Street 1') %]" path='billing_address.street1'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:Street 2') %]" path='billing_address.street2'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:City') %]" path='billing_address.city'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:County') %]" path='billing_address.county'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:State') %]" path='billing_address.state'></eg-grid-field>
+  <eg-grid-field label="[% ('Billing:Zip') %]" path='billing_address.post_code'></eg-grid-field>
+</eg-grid>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_stat_cats.tt2
new file mode 100644 (file)
index 0000000..21eb217
--- /dev/null
@@ -0,0 +1,19 @@
+<div class="row pad-vert" ng-repeat="map in patron().stat_cat_entries()">
+  <div class="col-md-12 well" style="margin-left:12px">
+    <div class="row">
+      <div class="col-md-7">
+        <span class="strong-text">{{map.stat_cat().name()}}</span>
+        <span class="pad-horiz">{{map.stat_cat_entry()}}</span>
+      </div>
+      <div class="col-md-5">
+        <div class="pull-right">
+          <span class="pad-horiz alert alert-warning" 
+            ng-if="map.stat_cat().opac_visible() == 't'">[% l('Patron Visible') %]</span>
+          <span class="pad-horiz alert alert-info" 
+            ng-if="map.stat_cat().opac_visible() == 'f'">[% l('Staff Only') %]</span>
+          <span>[% l('@ [_1]', '{{map.stat_cat().owner().shortname()}}') %]</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2
new file mode 100644 (file)
index 0000000..1f09c77
--- /dev/null
@@ -0,0 +1,143 @@
+
+<div ng-cloak>
+  <div ng-show="patron()" id="patron-summary-grid">
+    <div class="row" 
+      ng-class="{'patron-summary-divider' : !$index}"
+      ng-repeat="penalty in alert_penalties()">
+      <div 
+        class="col-md-9 patron-summary-alert"
+        title="{{penalty.standing_penalty().name()}}">
+        {{penalty.note() || penalty.standing_penalty().label()}}
+      </div>
+      <div class="col-md-3">
+        {{penalty.set_date() | date:'shortDate'}}
+      </div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-divider' : alert_penalties().length}">
+      <div class="col-md-5">[% l('Profile') %]</div>
+      <div class="col-md-7">{{patron().profile().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Home Library') %]</div>
+      <div class="col-md-7">{{patron().home_ou().shortname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Net Access') %]</div>
+      <div class="col-md-7">{{patron().net_access_level().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Last Activity') %]</div>
+      <div class="col-md-7">{{patron().usr_activity()[0].event_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Last Updated') %]</div>
+      <div class="col-md-7">{{patron().last_update_time() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Create Date') %]</div>
+      <div class="col-md-7">{{patron().create_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Expire Date') %]</div>
+      <div class="col-md-7">{{patron().expire_date() | date:'shortDate'}}</div>
+    </div>
+    <div class="row patron-summary-divider" 
+      ng-class="{'patron-summary-alert' : patron_stats().fines.balance_owed}">
+      <div class="col-md-5">[% l('Fines Owed') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().fines.balance_owed | currency}}
+      </div>
+    </div>
+    <div class="row"
+      ng-show="patron_stats().fines.group_balance_owed > patron_stats().fines.balance_owed"
+      ng-class="{'patron-summary-alert' : patron_stats().fines.group_balance_owed}">
+      <div class="col-md-5">[% l('Group Fines') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().fines.group_balance_owed | currency}}
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Items Out') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.out}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.overdue}">
+      <div class="col-md-5">[% l('Overdue') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.long_overdue}">
+      <div class="col-md-5">[% l('Long Overdue') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.long_overdue}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.claims_returned}">
+      <div class="col-md-5">[% l('Claimed Returned') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.claims_returned}}</div>
+    </div>
+    <div class="row" 
+      ng-class="{'patron-summary-alert' : patron_stats().checkouts.lost}">
+      <div class="col-md-5">[% l('Lost') %]</div>
+      <div class="col-md-7">{{patron_stats().checkouts.lost}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Holds') %]</div>
+      <div class="col-md-7">
+        {{patron_stats().holds.total}} / {{patron_stats().holds.ready}}
+      </div>
+    </div>
+    <div class="row patron-summary-divider">
+      <div class="col-md-5">[% l('Card') %]</div>
+      <div class="col-md-7">{{patron().card().barcode()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Username') %]</div>
+      <div class="col-md-7">{{patron().usrname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Day Phone') %]</div>
+      <div class="col-md-7">{{patron().day_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Evening Phone') %]</div>
+      <div class="col-md-7">{{patron().evening_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Other Phone') %]</div>
+      <div class="col-md-7">{{patron().other_phone()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('ID1') %]</div>
+      <div class="col-md-7">{{patron().ident_type().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('ID2') %]</div>
+      <div class="col-md-7">{{patron().ident_type2().name()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-5">[% l('Email') %]</div>
+      <div class="col-md-7">{{patron().email()}}</div>
+    </div>
+    <div class="row" ng-repeat="map in summary_stat_cats()">
+      <div class="col-md-5">{{map.stat_cat().name()}}</div>
+      <div class="col-md-7">{{map.stat_cat_entry()}}</div>
+    </div>
+  </div>
+
+  <div class="row" ng-repeat="addr in patron().addresses()">
+    <div class="panel">
+      <div class="panel-body">
+        <fieldset>
+          <legend>
+            {{addr.address_type()}} 
+            <a href class="pad-horiz patron-summary-act-link" 
+              ng-click="print_address(addr)">[% l('(print)') %]</a>
+          </legend>
+          <div>{{addr.street1()}} {{addr.street2()}}</div>
+          <div>{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}</div>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_triggered_events.tt2
new file mode 100644 (file)
index 0000000..fe5091f
--- /dev/null
@@ -0,0 +1,3 @@
+<!-- insert the patron registration UI -->
+<eg-embed-frame url="triggered_events_url" handlers="funcs"></eg-embed-frame>
+
diff --git a/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_xact_details.tt2
new file mode 100644 (file)
index 0000000..7bc04e7
--- /dev/null
@@ -0,0 +1,144 @@
+<h3>[% l('Transaction #[_1]', '{{xact.id()}}') %]</h3>
+
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Billing Location') %]</div>
+  <div class="col-md-2">{{xact.billing_location().shortname()}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+  <div class="col-md-2">{{xact.summary().total_owed() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Title') %]</div>
+  <div class="col-md-2">
+    <a ng-if="title_id" href="[% ctx.base_path %]/opac/record/{{title_id}}">{{title}}</a>
+    <span ng-if="!title_id">{{title}}</span>
+  </div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Type') %]</div>
+  <div class="col-md-2">{{xact.summary().xact_type()}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Paid') %]</div>
+  <div class="col-md-2">{{xact.summary().total_paid() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Checked Out') %]</div>
+  <div class="col-md-2">{{xact.circulation().xact_start() | date:'short'}}</div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Start') %]</div>
+  <div class="col-md-2">{{xact.xact_start() | date:'short'}}</div>
+  <div class="col-md-2 strong-text">[% l('Total Billed') %]</div>
+  <div class="col-md-2">{{xact.summary().balance_owed() | currency}}</div>
+  <div class="col-md-2 strong-text">[% l('Due Date') %]</div>
+  <div class="col-md-2">{{xact.circulation().due_date() | date:'short'}}</div>
+</div>
+<div class="row">
+  <div class="col-md-2 strong-text">[% l('Finish') %]</div>
+  <div class="col-md-2">{{xact.xact_finish() | date:'short'}}</div>
+  <div class="col-md-2 strong-text">[% l('Renewal?') %]</div>
+  <div class="col-md-2">
+    <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+    <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+    <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+  </div>
+  <div class="col-md-2 strong-text">[% l('Checked In') %]</div>
+  <div class="col-md-2">{{xact.circulation().checkin_time() | date:'short'}}</div>
+</div>
+
+<div ng-if="xact.circulation()">
+  <hr/>
+  <h3>[% l('Item Summary') %]</h3>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Barcode') %]</div>
+    <div class="col-md-2">
+      <a title="[% l('Item Details') %]" target="_self"
+        href='./cat/item/{{xact.circulation().target_copy().id()}}'>
+        {{xact.circulation().target_copy().barcode()}}
+      </a>
+    </div>
+    <div class="col-md-2 strong-text">[% l('Location') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().location().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Call Number') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().call_number().label()}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Status') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().status().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Circulate') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().circulate() == 't'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Reference') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().ref() == 't'}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Holdable') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().holdable() == 't'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('OPAC Visible') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().opac_visible() == 't'}}
+    </div>
+
+    <div class="col-md-2 strong-text">[% l('Created') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().create_date() | date:'short'}}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-md-2 strong-text">[% l('Edited') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().edit_date() | date:'short'}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Age Protect') %]</div>
+    <div class="col-md-2">
+      {{xact.circulation().target_copy().age_protect().name()}}
+    </div>
+    <div class="col-md-2 strong-text">[% l('Total Circulations') %]</div>
+    <div class="col-md-2">
+      TODO
+    </div>
+  </div>
+</div>
+
+
+<!-- set a lower default page size (limit) to allow for more space -->
+<hr/>
+<eg-grid
+  main-label="[% l('Bills') %]"
+  idl-class="mb"
+  id-field="id"
+  grid-controls="xactGridControls"
+  auto-fields="true"
+  page-size="10">
+
+  <eg-grid-action 
+    label="[% l('Void Billings') %]" handler="voidBillings"></eg-grid-action>
+
+  <eg-grid-action 
+    label="[% l('Edit Note') %]" handler="editBillNotes"></eg-grid-action>
+
+</eg-grid>
+
+<!-- TODO: this grid may contain objects (payments) of different types.. 
+    apply manual columns, see xul -->
+<!-- NOTE: sorting disabled since payments are fetched via non-sortable API -->
+<br/>
+<eg-grid
+  main-label="[% l('Payments') %]"
+  idl-class="mp"
+  id-field="id"
+  auto-fields="true"
+  grid-controls="paymentGridControls"
+  page-size="10">
+  <eg-grid-action 
+    label="[% l('Edit Note') %]" handler="editPaymentNotes"></eg-grid-action>
+
+  <eg-grid-field path="cash_payment.cash_drawer.name" 
+    label="[% l('Cash Drawer') %]"></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/circ/renew/index.tt2 b/Open-ILS/src/templates/staff/circ/renew/index.tt2
new file mode 100644 (file)
index 0000000..415556b
--- /dev/null
@@ -0,0 +1,20 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Renew"); 
+  ctx.page_app = "egRenewApp";
+%]
+
+[% 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/services/user.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
+[% INCLUDE 'staff/circ/share/circ_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/patron/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/renew/app.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/circ.css" />
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/circ/renew/t_renew.tt2 b/Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
new file mode 100644 (file)
index 0000000..c2880c1
--- /dev/null
@@ -0,0 +1,141 @@
+<!-- item renewal form / list -->
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    [% l('Renew Items') %]
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-6">
+    <form ng-submit="renew(renewalArgs)" role="form" class="form-inline">
+      <div class="input-group">
+
+        <label class="input-group-addon" 
+          for="patron-renewal-barcode" >[% l('Barcode') %]</label>
+
+        <input focus-me="focusBarcode" class="form-control"
+          ng-model="renewalArgs.copy_barcode" 
+          id="patron-renewal-barcode" type="text"/> 
+
+        <input class="btn btn-default" type="submit" value="[% l('Submit') %]"/>
+      </div>
+    </form>
+  </div>
+  <div class="col-md-6">
+    <div class="flex-row">
+      <div class="flex-cell"></div>
+      <div class="checkbox pad-horiz">
+        <label>
+          <input type="checkbox" ng-model="renewalArgs.sticky_date"/>
+          [% l('Specific Due Date') %]
+        </label>
+      </div>
+      <!-- FIXME: This needs a time component as well, but type="datetime" 
+            is not yet supported by any browsers -->
+      <div><input eg-date-input class="form-control" ng-model="renewalArgs.due_date"/>
+      </div>
+    </div>
+  </div>
+</div>
+<hr/>
+
+<eg-grid
+  id-field="index"
+  features="-sort,-multisort"
+  items-provider="gridDataProvider"
+  grid-controls="gridControls"
+  persist-key="circ.renew">
+
+  <eg-grid-action 
+    handler="fetchLastCircPatron"
+    label="[% l('Retrieve Last Patron Who Circulated Item') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="showLastFewCircs"
+    label="[% l('Show Last Few Circluations') %]">
+  </eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action 
+    handler="showMarkDamaged"
+    label="[% l('Mark Items Damaged') %]">
+  </eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action 
+    handler="abortTransit"
+    label="[% l('Abort Transits') %]">
+  </eg-grid-action>
+
+
+  <eg-grid-field label="[% l('Alert Msg') %]"   
+    path="acp.alert_message"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Balance Owed') %]"     
+    path='mbts.balance_owed'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Barcode') %]" path="acp_barcode">
+    <!-- FIXME: ng-if / ng-disabled not working since the contents 
+        are $interpolate'd and not $compile'd.
+        I want to hide / disable the href when there is no acp ID 
+    -->
+    <a href="./cat/item/{{item.acp.id()}}/summary" target="_self">
+      {{item.copy_barcode}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Bill #') %]"     
+    path='circ.id'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Call Number') %]" 
+    path="acn.label"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Due Date') %]"    
+    path='circ.due_date' dateformat='short'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Family Name') %]"    
+    path='au.family_name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]" 
+    path='acp.location.name'> </eg-grid-field>
+
+  <eg-grid-field label="[% l('Remaining Renewals') %]" 
+    path='circ.renewal_remaining'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Title') %]" path="title">
+    <a target="_self" href="[% ctx.base_path %]/opac/record/{{record.doc_id()}}">
+      {{item.title}}
+    </a>
+  </eg-grid-field>
+
+  <eg-grid-field label="[% l('Author') %]"      
+    path="author" hidden></eg-grid-field>
+
+  <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
+  <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
+  <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
+  <eg-grid-field path="record.*" parent-idl-class="mvr" hidden></eg-grid-field>
+  <eg-grid-field path="mbts.*" parent-idl-class="mbts" hidden></eg-grid-field>
+  <eg-grid-field path="au.*" parent-idl-class="au" hidden></eg-grid-field>
+</eg-grid>
+
+<div class="flex-row pad-vert">
+  <div class="flex-cell"></div>
+  <div class="pad-horiz">
+    <button class="btn btn-default" 
+      ng-click="print_receipt()">[% l('Print Receipt') %]</button>
+  </div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="trim_list" type="checkbox"/>
+      [% l('Trim List (20 Rows)') %]
+    </label>
+  </div>
+  <div class="pad-horiz"></div>
+  <div class="checkbox">
+    <label>
+      <input ng-model="strict_barcode" type="checkbox"/>
+      [% l('Strict Barcode') %]
+    </label>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/share/circ_strings.tt2 b/Open-ILS/src/templates/staff/circ/share/circ_strings.tt2
new file mode 100644 (file)
index 0000000..316e64a
--- /dev/null
@@ -0,0 +1,39 @@
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s.PATRON_CARD_INACTIVE =
+  "[% l('The card used to retrieve this account is inactive and may not be used to circulate items.') %]";
+s.PATRON_INACTIVE =
+  "[% l('This account is inactive and may not circulate items.') %]";
+s.PATRON_ACCOUNT_EXPIRED =
+  "[% l('This account has expired and may not circulate items.') %]";
+s.CIRC_CLAIMS_RETURNED = 
+  '[% l('Item "[_1]" is marked as Claims Returned', '{{barcode}}') %]';
+s.CHECKOUT_FAILED_GENERIC =
+  '[% l('Unable to checkout copy "[_1]" : [_2]', '{{barcode}}', '{{textcode}}') %]';
+s.COPY_ALERT_MSG_DIALOG_TITLE =
+  '[% l('Copy Alert Message for "[_1]"', '{{copy_barcode}}') %]';
+s.UNCAT_ALERT_DIALOG =
+  '[% l('Copy "[_1]" was mis-scanned or is not cataloged', '{{copy_barcode}}') %]';
+s.PERMISSION_DENIED = 
+  '[% l('Permission Denied : [_1]', '{{permission}}') %]';
+s.PRECAT_CHECKIN_MSG = 
+  '[% l("This item needs to be routed to CATALOGING") %]';
+s.LOCATION_ALERT_MSG =
+  '[% l("Item [_1] needs to be routed to [_2]", 
+    "{{copy.barcode()}}","{{copy.location().name()}}") %]';
+s.MARK_DAMAGED_CONFIRM = '[% l("Mark {{num_items}} items as DAMAGED?") %]';
+s.MARK_MISSING_CONFIRM = '[% l("Mark {{num_items}} items as MISSING?") %]';
+s.ABORT_TRANSIT_CONFIRM = '[% l("Abort {{num_transits}} transits?") %]';
+s.ROUTE_TO_HOLDS_SHELF = '[% l("Holds Shelf") %]';
+s.ROUTE_TO_CATALOGING = '[% l("Cataloging") %]';
+s.COPY_IN_TRANSIT = '[% l("Copy is In-Transit") %]';
+s.TOO_MANY_CLAIMS_RETURNED = 
+  '[% l("Patron exceeds claims returned count.  Force this action?") %]';
+s.MARK_NEVER_CHECKED_OUT = 
+  '[% l("Mark Never Checked Out: [_1]", "{{barcodes.toString()}}") %]'
+}]);
+</script>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/hold_strings.tt2 b/Open-ILS/src/templates/staff/circ/share/hold_strings.tt2
new file mode 100644 (file)
index 0000000..9e67e19
--- /dev/null
@@ -0,0 +1,30 @@
+[%# Strings for circ/services/circ.js %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+s['HOLD_STATUS_-1'] = "[% l('Error (-1)') %]";
+s.HOLD_STATUS_1 = "[% l('Waiting for Copy') %]";
+s.HOLD_STATUS_2 = "[% l('Waiting for Capture') %]";
+s.HOLD_STATUS_3 = "[% l('In Transit') %]";
+s.HOLD_STATUS_4 = "[% l('Ready for Pickup') %]";
+s.HOLD_STATUS_5 = "[% l('Hold Shelf Delay') %]";
+s.HOLD_STATUS_6 = "[% l('Canceled') %]";
+s.HOLD_STATUS_7 = "[% l('Suspended') %]";
+s.HOLD_STATUS_8 = "[% l('Wrong Shelf') %]";
+s.ACTIVATE_HOLDS = "[% l('Activate [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SUSPEND_HOLDS = "[% l('Suspend [_1] Hold(s)?', '{{num_holds}}') %]"
+s.SET_TOP_OF_QUEUE = 
+  "[% l('Move [_1] Hold(s) to the front of the holds queue above other holds that are not likewise flagged as Top of Queue?', 
+    '{{num_holds}}') %]";
+s.CLEAR_TOP_OF_QUEUE = 
+  "[% l('Unset the Top of Queue flag for [_1] Hold(s)?', '{{num_holds}}') %]";
+s.TRANSFER_HOLD_TO_TITLE = 
+  "[% l('Tranfer [_1] Hold(s) to bib record ID [_2]?', '{{num_holds}}', '{{bib_id}}') %]";
+s.NO_HOLD_TRANSFER_TITLE_MARKED = 
+  "[% l('No record is marked as a hold transfer target!') %]";
+s.RETARGET_HOLDS = 
+  "[% l('Reset hold(s) [_1]?', '{{hold_ids}}') %]";
+}]);
+</script>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_backdate_dialog.tt2
new file mode 100644 (file)
index 0000000..034e909
--- /dev/null
@@ -0,0 +1,24 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Backdate Already Checked-In Circulations') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div>[% l('Number of circulations selected: [_1]', '{{dialog.num_circs}}') %]</div>
+  <div class="pad-vert">
+    <progress max="dialog.num_circs" value="dialog.num_processed"></progress>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-6">[% l('Effective Date:') %]</div>
+    <div class="col-md-6">
+      <input eg-date-input required 
+        class="form-control" ng-model="dialog.backdate"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_bad_barcode_dialog.tt2
new file mode 100644 (file)
index 0000000..c304416
--- /dev/null
@@ -0,0 +1,23 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+      aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Bad Barcode') %]</h4>
+  </div>
+  <div class="modal-body">
+    <img src="[% ctx.media_prefix %]/images/bad_barcode.png"/>
+    <div>
+[% |l('{{barcode}}') %]
+Bad check digit, possibly due to a bad scan.  Use this barcode ("[_1]") anyway?
+[% END %]
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('Accept Barcode') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_bill_patron_dialog.tt2
new file mode 100644 (file)
index 0000000..b946b8c
--- /dev/null
@@ -0,0 +1,93 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(billArgs)" role="form" class="form-horizontal">
+  <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('Bill Patron: [_1], [_2] [_3] : [_4]',
+            '{{patron.family_name()}}',
+            '{{patron.first_given_name()}}',
+            '{{patron.second_given_name()}}',
+            '{{patron.card().barcode()}}') %]
+      </h4>
+
+      <div ng-if="xact">
+        <hr/>
+        <div class="row">
+          <div class="col-md-3">[% l('Bill #') %]</div>
+          <div class="col-md-3">{{xact.id}}</div>
+          <div class="col-md-3">[% l('Total Billed') %]</div>
+          <div class="col-md-3">{{xact.summary.total_owed | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Type') %]</div>
+          <div class="col-md-3">{{xact.summary.xact_type}}</div>
+          <div class="col-md-3">[% l('Total Paid') %]</div>
+          <div class="col-md-3">{{xact.summary.total_paid | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Start') %]</div>
+          <div class="col-md-3">{{xact.xact_start | date:'short'}}</div>
+          <div class="col-md-3">[% l('Total Billed') %]</div>
+          <div class="col-md-3">{{xact.summary.balance_owed | currency}}</div>
+        </div>
+        <div class="row">
+          <div class="col-md-3">[% l('Finish') %]</div>
+          <div class="col-md-3">{{xact.xact_finish | date:'short'}}</div>
+          <div class="col-md-3">[% l('Renewal?') %]</div>
+          <div class="col-md-3">
+            <span ng-if="xact.circulation.desk_renewal == 't'">[% l('Desk') %]</span>
+            <span ng-if="xact.circulation.phone_renewal == 't'">[% l('Phone') %]</span>
+            <span ng-if="xact.circulation.opac_renewal == 't'">[% l('OPAC') %]</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="bill-dialog-location" class="control-label col-md-4">
+          [% l('Location:') %]
+        </label>
+        <div class="col-md-8">
+          <p class="form-control-static">{{location.shortname()}}</p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="bill-dialog-type" class="control-label col-md-4">
+          [% l('Billing Type:') %]
+        </label>
+        <div class="col-md-8">
+          <select ng-model="billArgs.billingType" class="form-control"
+            ng-change="updateDefaultPrice()">
+            <option ng-repeat="type in billingTypes" value="{{type.id()}}">
+              {{type.name()}}
+            </option>
+          </select>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="bill-dialog-amount" class="control-label col-md-4">[% l('Amount:') %]</label>
+        <div class="col-md-8">
+          <input type="number" min="0" step="any" class="form-control" 
+            focus-me='focus' required id="bill-dialog-amount" 
+            ng-model="billArgs.amount"/>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="bill-dialog-note" class="control-label col-md-4">[% l('Note:') %]</label>
+        <div class="col-md-8">
+          <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+            id="bill-dialog-note" ng-model="billArgs.note"></textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-success" value="[% l('Submit Bill') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div>
+</form>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_cancel_hold_dialog.tt2
new file mode 100644 (file)
index 0000000..81417f2
--- /dev/null
@@ -0,0 +1,38 @@
+<form ng-submit="ok()" role="form" class="form-horizontal">
+  <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('Cancel [_1] Hold(s)', '{{args.num_holds}}') %]
+      </h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="hold-cancel-reason" class="control-label col-md-4">
+          [% l('Cancel Reason:') %]
+        </label>
+        <div class="col-md-8">
+          <select class="form-control" id="hold-cancel-reason"
+            ng-model="args.cancel_reason"
+            ng-options="reason.label() for reason in args.cancel_reasons">
+          </select>
+        </div>
+      </div>
+      <div class="form-group">
+        <label for="hold-cancel-note" class="control-label col-md-4">
+          [% l('Note:') %]
+        </label>
+        <div class="col-md-8">
+          <textarea rows="3" class="form-control" placeholder="[% l('Note...') %]"
+            id="hold-cancel-note" ng-model="args.note"></textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-success" value="[% l('Cancel Hold') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Exit') %]</button>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_circ_exists_dialog.tt2
new file mode 100644 (file)
index 0000000..3a8b5d1
--- /dev/null
@@ -0,0 +1,31 @@
+<form class="form-validated" novalidate ng-submit="ok()" name="form">
+  <div>
+    <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('Open Circulation') %]</h4>
+      </div>
+      <div class="modal-body">
+        <div ng-if="sameUser">
+          [% |l("{{circDate | date:'shortDate'}}") %]
+          There is an open circulation on the requested item.  
+          This item was already checked out to this user on [_1].
+          [% END %]
+        </div>
+        <div ng-if="!sameUser">
+          [% |l("{{circDate | date:'shortDate'}}") %]
+          There is an open circulation on the requested item.  
+          This copy was checked out by another patron on [_1].
+          [% END %]
+        </div>
+      </div>
+      <div class="modal-footer">
+        <input type="submit" class="btn btn-primary" 
+            value="[% l('Normal Checkin then Checkout') %]"/>
+        <button class="btn btn-warning" 
+            ng-click="cancel($event)">[% l('Cancel') %]</button>
+      </div>
+    </div> <!-- modal-content -->
+  </div> <!-- modal-dialog -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_copy_in_transit_dialog.tt2
new file mode 100644 (file)
index 0000000..4d38922
--- /dev/null
@@ -0,0 +1,32 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Copy In Transit') %]</h4>
+  </div>
+  <div class="modal-body">
+    <div class="strong-text">
+      [% l('There is an open transit on copy [_1]', 
+        '{{transit.target_copy().barcode()}}') %]
+    </div>
+    <div class="pad-vert"></div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Date:') %]</div>
+      <div class="col-md-8">{{transit.source_send_time() | date:'short'}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Source:') %]</div>
+      <div class="col-md-8">{{transit.source().shortname()}}</div>
+    </div>
+    <div class="row">
+      <div class="col-md-4">[% l('Transit Destination:') %]</div>
+      <div class="col-md-8">{{transit.dest().shortname()}}</div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" ng-click="ok()"
+        value="[% l('Abort Transit then Checkout') %]"/>
+    <button class="btn btn-warning" 
+        ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_copy_not_avail_dialog.tt2
new file mode 100644 (file)
index 0000000..711e777
--- /dev/null
@@ -0,0 +1,20 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Copy Not Available.') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="alert alert-warning">
+      [% l('Copy Status: [_1]', '{{copyStatus.name()}}') %]
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" ng-click="ok()"
+        value="[% l('Force this action?') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_event_override_dialog.tt2
new file mode 100644 (file)
index 0000000..5850ac5
--- /dev/null
@@ -0,0 +1,27 @@
+<form ng-submit="ok()" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Exceptions occurred during checkout.') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="panel panel-danger">
+      <div class="panel-heading">{{evt.textcode}}</div>
+      <div class="panel-body">
+        <div ng-if="copy_barcode" class="strong-text-2">{{copy_barcode}}</div>
+        {{evt.desc}}
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <i ng-if="auto_override">[% |l %]If overridden, subsequent checkouts during this patron's 
+ session will auto-override this event[% END %]</i>
+    <br/><br/>
+    <input type="submit" class="btn btn-primary" 
+        value="[% l('Force Action?') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_copy_quality_dialog.tt2
new file mode 100644 (file)
index 0000000..19aa885
--- /dev/null
@@ -0,0 +1,24 @@
+<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('Accept only "Good Quality" copies?') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <div class="col-md-4">
+        <button class="btn btn-default" ng-click="good()">[% l('Good Condition') %]</button>
+      </div>
+      <div class="col-md-4">
+        <button class="btn btn-default" ng-click="any()">[% l('Any Condition') %]</button>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_dates.tt2
new file mode 100644 (file)
index 0000000..b145d0e
--- /dev/null
@@ -0,0 +1,75 @@
+<div class="modal-content" id='hold-notify-settings'>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Modify Dates for [_1] Hold(s)', '{{num_holds}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="row header-row">
+      <div class="col-md-12">
+        [% l('Check the checkbox next to each field you wish to modify.') %]
+      </div>
+    </div>
+    <hr/>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_thaw_date" class="sr-only">[% l('Update Activate Email') %]</label>
+        <input id='modify_thaw_date' ng-model="args.modify_thaw_date" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='thaw_date'>[% l("Hold Activate Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='thaw_date' eg-date-input 
+          ng-disabled="!args.modify_thaw_date" ng-model="args.thaw_date"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_request_time" class="sr-only">[% l('Update Phone Number') %]</label>
+        <input id='modify_request_time' ng-model="args.modify_request_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='request_time'>[% l("Hold Request Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='request_time' eg-date-input 
+          ng-disabled="!args.modify_request_time" ng-model="args.request_time"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_expire_time" class="sr-only">[% l('Update Expire Time') %]</label>
+        <input id='modify_expire_time' ng-model="args.modify_expire_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='expire_time'>[% l("Hold Expire Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='expire_time' eg-date-input 
+          ng-disabled="!args.modify_expire_time" ng-model="args.expire_time"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="modify_shelf_expire_time" class="sr-only">[% l('Update SMS Carrier') %]</label>
+        <input id='modify_shelf_expire_time' ng-model="args.modify_shelf_expire_time" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='shelf_expire_time'>[% l("Shelf Expire Date") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='shelf_expire_time' eg-date-input 
+          ng-disabled="!args.modify_shelf_expire_time" ng-model="args.shelf_expire_time"/>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_details.tt2
new file mode 100644 (file)
index 0000000..1117ae1
--- /dev/null
@@ -0,0 +1,149 @@
+<!-- hold info -->
+<h4 class="pad-vert">[% l('Hold Details') %]</h4>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Request Date') %]</div>
+  <div class="flex-cell well">{{hold.request_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Capture Date') %]</div>
+  <div class="flex-cell well">{{hold.capture_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Available On') %]</div>
+  <div class="flex-cell well">{{hold.shelf_time() | date:'short'}}</div>
+ </div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Hold Type') %]</div>
+  <div class="flex-cell well">{{hold.hold_type()}}</div>
+  <div class="flex-cell">[% l('Current Copy') %]</div>
+  <div class="flex-cell well">
+    <a href="./cat/item/{{hold.current_copy().id()}}" target="_self">
+      {{hold.current_copy().barcode()}}
+    </a>
+  </div>
+  <div class="flex-cell">[% l('Call Number') %]</div>
+  <div class="flex-cell well">{{volume.label()}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Pickup Lib') %]</div>
+  <div class="flex-cell well">{{hold.pickup_lib().shortname()}}</div>
+  <div class="flex-cell">[% l('Status') %]</div>
+  <div class="flex-cell well">{{status_string}}</div>
+  <div class="flex-cell">[% l('Behind Desk') %]</div>
+  <div class="flex-cell well">{{hold.behind_desk() == 't'}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Current Shelf Lib') %]</div>
+  <div class="flex-cell well">{{hold.current_shelf_lib().shortname()}}</div>
+  <div class="flex-cell">[% l('Current Copy Location') %]</div>
+  <div class="flex-cell well">{{copy.location().name()}}</div>
+  <div class="flex-cell">[% l('Force Copy Quality') %]</div>
+  <div class="flex-cell well">{{hold.mint_condition() == 't'}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Email Notify') %]</div>
+  <div class="flex-cell well">{{hold.email_notify() == 't'}}</div>
+  <div class="flex-cell">[% l('Phone Notify') %]</div>
+  <div class="flex-cell well">{{hold.phone_notify()}}</div>
+  <div class="flex-cell">[% l('SMS Notify') %]</div>
+  <div class="flex-cell well">{{hold.sms_notify()}}</div>
+</div>
+<div class="flex-row">
+  <div class="flex-cell">[% l('Cancel Cause') %]</div>
+  <div class="flex-cell well">{{hold.cancel_cause().label()}}</div>
+  <div class="flex-cell">[% l('Cancel Time') %]</div>
+  <div class="flex-cell well">{{hold.cancel_time() | date:'short'}}</div>
+  <div class="flex-cell">[% l('Cancel Note') %]</div>
+  <div class="flex-cell well">{{hold.cancel_note()}}</div>
+</div>
+
+<ul class="nav nav-tabs pad-vert" ng-init="detail_tab='notes'">
+  <li ng-class="{active : detail_tab == 'notes'}">
+    <a href ng-click="detail_tab = 'notes'">[% l('Notes') %]</a>
+  </li>
+  <li ng-class="{active : detail_tab == 'notify'}">
+    <a href ng-click="show_notify_tab()">
+      [% l('Staff Notifications') %]
+    </a>
+  </li>
+</ul>
+<div class="tab-content">
+  <div class="tab-pane active">
+
+    <div ng-if="detail_tab == 'notes'">
+      
+      <button class="btn btn-default" ng-click="new_note()">
+        [% l('New Note') %]
+      </button>
+
+      <div class="row pad-vert" ng-repeat="note in hold.notes()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 strong-text">{{note.title()}}</div>
+            <div class="col-md-6">
+              <div class="pull-right">
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.slip() == 't'">[% l('Print on Slip') %]</span>
+                <span class="pad-horiz alert alert-warning" 
+                  ng-if="note.pub() == 't'">[% l('Patron Visible') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.pub() == 'f'">[% l('Staff Only') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.staff() == 't'">[% l('Staff Created') %]</span>
+                <span class="pad-horiz alert alert-info" 
+                  ng-if="note.staff() == 'f'">[% l('Patron Created') %]</span>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <!-- hmm, not sure why the margin-left is needed.. the well? -->
+            <div class="col-md-12 well" style="margin-left:12px">
+              <div class="row">
+                <div class="col-md-8">
+                  <div class="">{{note.body()}}</div>
+                </div>
+                <div class="col-md-4">
+                  <div class="pull-right">
+                    <button ng-click="delete_note(note)" class="btn btn-warning">
+                      [% l('Delete') %]
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div><!-- notes tab content -->
+
+    <div ng-if="detail_tab == 'notify'">
+      
+      <button class="btn btn-default" ng-click="new_notification()">
+        [% l('Add Record of Notification') %]
+      </button>
+
+      <div class="row pad-vert" 
+          ng-repeat="notify in hold.notifications()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 strong-text">{{notify.method()}}</div>
+            <div class="col-md-6">
+              <div class="pull-right">
+                <span class="pad-horiz">{{notify.notify_time() | date:'short'}}</span>
+                <span>[% l('Created by [_1]', '{{notify.notify_staff().usrname()}}') %]</span>
+              </div>
+            </div>
+          </div>
+          <div class="row">
+            <!-- hmm, not sure why the margin-left is needed.. the well? -->
+            <div class="col-md-12 well" style="margin-left:12px">
+              <div class="row">
+                <div class="col-md-8">
+                  <div class="">{{notify.note()}}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div><!-- notes tab content -->
+
+  </div><!-- tab pane -->
+</div><!-- tab-content -->
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_edit_pickup_lib.tt2
new file mode 100644 (file)
index 0000000..895cda0
--- /dev/null
@@ -0,0 +1,23 @@
+<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('Edit hold pickup library') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <div class="col-md-4">[% l('Select Library:') %]</div>
+      <div class="col-md-8">
+        <eg-org-selector selected="args.org_unit"></eg-org-selector>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_note_dialog.tt2
new file mode 100644 (file)
index 0000000..92113da
--- /dev/null
@@ -0,0 +1,51 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create a new note') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-pub">[% l('Patron Visible?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-pub" ng-model="args.pub"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-slip">[% l('Print on Slip?') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="checkbox" class="checkbox" 
+            id="note-slip" ng-model="args.slip"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-title">[% l('Title') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-title" ng-model="args.title" placeholder="[% l('Title...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-body">[% l('Note Body') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-body" ng-model="args.body" placeholder="[% l('Note Body...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_dialog.tt2
new file mode 100644 (file)
index 0000000..01b5b48
--- /dev/null
@@ -0,0 +1,33 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Create Record of Hold Notification') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-method">[% l('Notification Method') %]</label>
+        </div>
+        <div class="col-md-9">
+          <input type="text" class="form-control" focus-me='focusNote' required
+            id="note-method" ng-model="args.method" placeholder="[% l('Notification Method...') %]"/>
+        </div>
+      </div>
+      <div class="form-group row">
+        <div class="col-md-3">
+          <label for="note-note">[% l('Note') %]</label>
+        </div>
+        <div class="col-md-9">
+          <textarea class="form-control" required
+            id="note-note" ng-model="args.note" placeholder="[% l('Note') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_notification_prefs.tt2
new file mode 100644 (file)
index 0000000..7f2659d
--- /dev/null
@@ -0,0 +1,78 @@
+<div class="modal-content" id='hold-notify-settings'>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Edit Notification Settings for [_1] Hold(s)', '{{num_holds}}') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="row header-row">
+      <div class="col-md-12">
+        [% l('Check the checkbox next to each field you wish to modify.') %]
+      </div>
+    </div>
+    <hr/>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-use-email" class="sr-only">[% l('Update Activate Email') %]</label>
+        <input id='activate-use-email' ng-model="args.update_email_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='use-email'>[% l("Send Emails") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id='use-email' ng-model="args.email_notify" 
+          type="checkbox" ng-disabled="!args.update_email_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-phone-number" class="sr-only">[% l('Update Phone Number') %]</label>
+        <input id='activate-phone-number' ng-model="args.update_phone_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='phone-number'>[% l("Phone #") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id="phone-number" type='tel' 
+          ng-model="args.phone_notify" ng-disabled="!args.update_phone_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-sms-number" class="sr-only">[% l('Update SMS Number') %]</label>
+        <input id='activate-sms-number' ng-model="args.update_sms_notify" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='sms-number'>[% l("Text/SMS #") %]</label>
+      </div>
+      <div class="col-md-7">
+        <input id="sms-number" type='tel' 
+          ng-model="args.sms_notify" ng-disabled="!args.update_sms_notify"/>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col-md-1">
+        <label for="activate-sms-carrier" class="sr-only">[% l('Update SMS Carrier') %]</label>
+        <input id='activate-sms-carrier' ng-model="args.update_sms_carrier" type="checkbox"/>
+      </div>
+      <div class="col-md-4">
+        <label for='sms-carrier'>[% l("SMS Carrier") %]</label>
+      </div>
+      <div class="col-md-7">
+        <select id='sms-carrier'
+          ng-model="args.sms_carrier" 
+          ng-disabled="!args.update_sms_carrier"
+          ng-options="carrier.name() for carrier in sms_carriers">
+        </select>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-default" ng-click="ok()">[% l('Submit') %]</button>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_hold_shelf_dialog.tt2
new file mode 100644 (file)
index 0000000..22e74e1
--- /dev/null
@@ -0,0 +1,63 @@
+<div class="">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="ok()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Hold Slip') %]</h4>
+  </div>
+  <div class="modal-body">
+    <div ng-switch on="hold.behind_desk">
+      <div ng-switch-when="t">
+        [% l('This item should be routed to the [_1]Private Holds Shelf[_2]',
+          '<strong>','</strong>') %]
+      </div>
+      <div ng-switch-when="f">
+        [% l('This item should be routed to the [_1]Public Holds Shelf[_2]',
+          '<strong>','</strong>') %]
+      </div>
+    </div>
+    <br/>
+    <div>
+      <span>[% l('Item Barcode:') %]</span>
+      <span>{{copy.barcode}}</span>
+    </div>
+    <div>
+      <span>[% l('Title:') %]</span>
+      <span>{{title}}</span>
+    </div>
+    <div>
+      <span>[% l('Author:') %]</span>
+      <span>{{author}}</span>
+    </div>
+    <br/>
+    <div>
+    
+    <div ng-show="patron.alias">
+      [% l('Hold for patron {{patron.alias}}') %]
+    </div>
+    <div ng-hide="patron.alias">
+      [% |l %]
+      Hold for patron {{patron.family_name}}, 
+      {{patron.first_given_name}} {{patron.second_given_name}}
+      [% END %]
+    </div>
+    <div>
+      <span>[% l('Patron Barcode:') %]</span>
+      <span>{{patron.card.barcode}}</span>
+    </div>
+    <br/>
+    <div>
+      <span>[% l('Request Date:') %]</span>
+      <span>{{hold.request_time | date:'shortDate'}}</span>
+    </div>
+    <div>
+      <span>[% l('Slip Date:') %]</span>
+      <span>{{today | date:'shortDate'}}</span>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="button" class="btn btn-primary"
+      ng-click="print()" value="[% l('Print') %]"/>
+    <input type="submit" class="btn btn-warning"
+      ng-click="ok()" value="[% l('Do Not Print') %]"/>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_mark_claims_returned_dialog.tt2
new file mode 100644 (file)
index 0000000..858e242
--- /dev/null
@@ -0,0 +1,25 @@
+<div class="modal-header">
+  <button type="button" class="close" 
+    ng-click="cancel()" aria-hidden="true">&times;</button>
+  <h4 class="modal-title">
+    [% l('Mark Item(s) Claims Returned') %]
+  </h4>
+</div>
+<div class="modal-body">
+  <div class="pad-vert row">
+    <div class="col-md-12">
+      [% l('Enter claims returned date for items: [_1]', 
+        '{{args.barcodes.toString()}}') %]
+    </div>
+  </div>
+  <div class="pad-vert row">
+    <div class="col-md-5">
+      <input eg-date-input required 
+        class="form-control" ng-model="args.date"/>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-primary" ng-click="ok()">[% l('Submit') %]</button>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_new_message_dialog.tt2
new file mode 100644 (file)
index 0000000..1cd94f0
--- /dev/null
@@ -0,0 +1,45 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Apply Standing Penalty / Message') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-8">
+          <ul class="nav nav-pills">
+            <!-- 21 == SILENT_NOTE -->
+            <li ng-class="{active : args.penalty == 21}">
+              <a href ng-click="args.penalty=21">[% l('Note') %]</a>
+            </li>
+            <!-- 20 == ALERT_NOTE -->
+            <li ng-class="{active : args.penalty == 20}">
+              <a href ng-click="args.penalty=20">[% l('Alert') %]</a>
+            </li>
+            <!-- 25 == STAFF_CHR -->
+            <li ng-class="{active : args.penalty == 25}">
+              <a href ng-click="args.penalty=25">[% l('Block') %]</a>
+            </li>
+          </ul>
+        </div>
+        <div class="col-md-4 pull-right">
+          <select class="form-control" ng-model="args.penalty">
+            <option ng-selected="args.penalty < 100"></option>
+            <option ng-repeat="penalty in penalties" 
+              value="{{penalty.id()}}">{{penalty.label()}}</option>
+          </select>
+        </div>
+      </div>
+      <div class="form-group row pad-vert">
+        <div class="col-md-12">
+          <textarea class="form-control" 
+            ng-model="args.note" placeholder="[% l('Note...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+      <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_noncat_dialog.tt2
new file mode 100644 (file)
index 0000000..345848e
--- /dev/null
@@ -0,0 +1,25 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(count)" role="form">
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+      [% l('Enter the number of {{type.name()}} circulating') %]
+    </h4>
+  </div>
+  <div class="modal-body">
+    <div class="form-group">
+      <label for="noncat-count" class="sr-only">[% l('Count') %]</label>
+      <input type="number" class="form-control" focus-me='focusMe' 
+        required id="noncat-title" ng-model="count" 
+        min="1" max="{{noncatMax}}"
+        placeholder="[% l('Count...') %]"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+        ng-disabled="form.$invalid" value="[% l('OK') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel($event)">[% l('Cancel') %]</button>
+  </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_precat_dialog.tt2
new file mode 100644 (file)
index 0000000..fc14ec3
--- /dev/null
@@ -0,0 +1,44 @@
+<!-- edit bucket dialog -->
+<form ng-submit="ok(precatArgs)" role="form">
+  <div class="">
+    <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('Barcode "{{precatArgs.copy_barcode}}" was mis-scanned or is a non-cataloged item.') %]
+        </h4>
+      </div>
+      <div class="modal-body">
+        <div class="form-group">
+          <label for="precat-title">[% l('Title') %]</label>
+          <input type="text" class="form-control" focus-me='focusMe' required
+            id="precat-title" ng-model="precatArgs.dummy_title" placeholder="[% l('Title...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-author">[% l('Author') %]</label>
+          <input type="text" class="form-control" id="precat-author"
+            ng-model="precatArgs.dummy_author" placeholder="[% l('Author...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-isbn">[% l('ISBN') %]</label>
+          <input type="text" class="form-control" id="precat-isbn"
+            ng-model="precatArgs.dummy_isbn" placeholder="[% l('ISBN...') %]"/>
+        </div>
+        <div class="form-group">
+          <label for="precat-circmod">[% l('Circulation Modifier') %]</label>
+          <select class="form-control" id="precat-circmod" 
+            ng-model="precatArgs.circ_modifier">
+            <option ng-repeat="mod in circModifiers" 
+              value="{{mod.code()}}">{{mod.name()}}</option>
+          </select>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <input type="submit" class="btn btn-primary" value="[% l('Precat Checkout') %]"/>
+        <button class="btn btn-warning" ng-click="cancel()"
+            ng-class="{disabled : actionPending}">[% l('Cancel') %]</button>
+      </div>
+    </div> <!-- modal-content -->
+  </div> <!-- modal-dialog -->
+</form>
diff --git a/Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2 b/Open-ILS/src/templates/staff/circ/share/t_transit_dialog.tt2
new file mode 100644 (file)
index 0000000..1491fb2
--- /dev/null
@@ -0,0 +1,65 @@
+<div class="">
+  <div class="">
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="ok()" aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Transit Slip') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div>
+        <span>[% l('Destination') %]</span>
+        <strong>{{dest_location.shortname}}</strong>
+      </div>
+      <br/>
+      <div>
+        <address>
+          <strong>{{dest_location.name}}</strong><br>
+          {{dest_address.street1}} {{dest_address.street2}}<br/>
+          {{dest_address.city}}, {{dest_address.state}} {{dest_address.post_code}}<br/>
+          <abbr title="[% l('Phone') %]">P:</abbr> {{dest_location.phone}}
+        </address>
+      </div>
+      <div>
+        <span>[% l('Item Barcode:') %]</span>
+        <span>{{copy.barcode}}</span>
+      </div>
+      <div>
+        <span>[% l('Title:') %]</span>
+        <span>{{title}}</span>
+      </div>
+      <div>
+        <span>[% l('Author:') %]</span>
+        <span>{{author}}</span>
+      </div>
+      <div ng-if="patron">
+        <br/>
+        <div>[% |l %]
+          Hold for patron {{patron.family_name}}, 
+          {{patron.first_given_name}} {{patron.second_given_name}}
+          [% END %]
+        </div>
+        <div>
+          <span>[% l('Patron Barcode:') %]</span>
+          <span>{{patron.card.barcode}}</span>
+        </div>
+        <br/>
+        <div>
+          <span>[% l('Request Date:') %]</span>
+          <span>{{hold.request_time | date:'shortDate'}}</span>
+        </div>
+      </div>
+      <div>
+        <div>
+          <span>[% l('Slip Date:') %]</span>
+          <span>{{today | date:'shortDate'}}</span>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="button" class="btn btn-primary"
+        ng-click="print()" value="[% l('Print') %]"/>
+      <input type="submit" class="btn btn-warning"
+        ng-click="ok()" value="[% l('Do Not Print') %]"/>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/config.tt2 b/Open-ILS/src/templates/staff/config.tt2
new file mode 100644 (file)
index 0000000..c563d7a
--- /dev/null
@@ -0,0 +1,13 @@
+[%- 
+
+# FIXME: update via build process
+EVERGREEN_VERSION='0.0.1' 
+
+# create script / css refs to individual files instead of using
+# compressed build files.  Use this for development and debugging.
+EXPAND_WEB_IMPORTS = 1; 
+
+# path to build files (js, css, fonts)
+WEB_BUILD_PATH = ctx.media_prefix _ '/js/ui/default/staff/build/';
+
+%]
diff --git a/Open-ILS/src/templates/staff/css/circ.css.tt2 b/Open-ILS/src/templates/staff/css/circ.css.tt2
new file mode 100644 (file)
index 0000000..8d6c139
--- /dev/null
@@ -0,0 +1,60 @@
+/** style to make a grid look like a striped table */
+#patron-summary-grid div.row {padding: 3px; border-right: 2px solid rgb(248, 248, 248);}
+#patron-summary-grid div.row:nth-child(odd) {background-color: rgb(248, 248, 248);}
+
+/* there are bootstrap tyles for error, warning, etc., 
+but the ones I'm finding aren't quite cutting it..*/
+.patron-summary-alert {color: red; font-weight:bold}
+.patron-summary-alert-small {color: red}
+.patron-summary-divider { border-top: 1px solid #CCC}
+.patron-summary-act-link {font-size: .8em;}
+
+/* FIXME: use .barcode instead */
+#patron-checkout-barcode,
+#patron-renewal-barcode,
+#patron-checkin-barcode { width: 16em; }
+
+#patron-search-form div.form-group {
+  margin-bottom: 5px;
+}
+
+/* let search form elements fill their containers w/ slight padding */
+#patron-search-form-row {margin-left: 0px;}
+#patron-search-form div.col-md-2 { padding: 2px; }
+#patron-search-form input:not([type="checkbox"]) { width: 100%; }
+#patron-search-form .eg-org-selector,
+#patron-search-form .eg-org-selector button,
+#patron-search-form .patron-search-selector,
+  #patron-search-form .patron-search-selector button { 
+  width: 100%; 
+  text-align: left
+}
+
+
+#patron-payments-spreadsheet {
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #aaa;
+}
+
+#patron-payments-spreadsheet .flex-cell {
+  margin: 2px;
+}
+
+#patron-payments-spreadsheet .flex-cell.well {
+  min-height: 1.5em;
+  margin-bottom: 0px; /* bootstrap default is 20px */
+}
+
+#hold-notify-settings div.row { margin-top: 12px; }
+#hold-notify-settings div.row:not(.header-row):nth-child(odd) {
+  background-color: rgb(248, 248, 248);
+}
+#hold-notify-settings div.row:not(.header-row) {
+  border-bottom: 1px solid #CCC;
+}
+
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/css/print.css.tt2 b/Open-ILS/src/templates/staff/css/print.css.tt2
new file mode 100644 (file)
index 0000000..5410ba9
--- /dev/null
@@ -0,0 +1,13 @@
+
+/* hide everything but the print div */
+head { display: none; } /* just to be safe */
+body div:not([id="print-div"]) { display:none }
+
+div { display: none }
+#print-div { display: block }
+#print-div div { display: block }
+#print-div pre { border: none }
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/css/style.css.tt2 b/Open-ILS/src/templates/staff/css/style.css.tt2
new file mode 100644 (file)
index 0000000..300668c
--- /dev/null
@@ -0,0 +1,412 @@
+/* --------------------------------------------------------------------------
+ * Simple default navbar style adjustements to apply the Evergreen color.
+ * TODO: style other components to match EG color scheme
+ */
+#top-navbar.navbar-default {
+    background: -webkit-linear-gradient(#00593d, #007a54);
+    background-color: #007a54;
+    color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a {
+    color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>li>a:hover {
+    color: #ddd;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a .caret {
+    border-top-color: #fff;
+    border-bottom-color: #fff;
+}
+#top-navbar.navbar-default .navbar-nav>.dropdown>a:hover .caret {
+    border-top-color: #ddd;
+    border-bottom-color: #ddd;
+}
+
+/* status bar along the bottom of the page ------------------------ */
+/* decrease padding to decrease overall height */
+
+/** TODO:move status bar items into navbar config entry (top-right)
+ * to avoid body padding weirdness.  Or if we want a permenently
+ * visible status bar, maybe put it just below the navbar.. */
+
+/* bottom padding ensures no body content is hidden behind the status
+ * bar.  When content reaches the status bar a scroll bar appears */
+/*body { padding-bottom: 26px; }*/
+
+#status-bar {
+  min-height:1.8em !important;
+}
+#status-bar > ul {
+  margin-right:6px; 
+}
+#status-bar li {
+  padding-left: 10px;
+}
+#status-bar > li > a {
+  padding-top:5px !important; 
+  padding-bottom:5px !important;
+}
+.status-bar-connected {
+  color: rgb(92, 184, 92); /* success */
+}
+
+/* --------------------------------------------------------------------------
+ * Structural modifications
+ */
+
+#top-content-container {
+    /* allow the primary container to occupy most of the page,
+     * but leave some narrow gutters along the side, much 
+     * narrower than the default Bootstrapp container gutters.
+     */
+    width: 95%;
+}
+
+
+/* --------------------------------------------------------------------------
+ * Temporaray local CSS required to make angular-ui-bootstrap
+ * version 0.6.0 look right with Bootstrap CSS 3.0
+ */
+.nav, .pagination, .carousel a { cursor: pointer; }
+/*
+.modal {
+    display: block;
+    height: 0;
+    overflow: visible;
+}
+.modal-body:before,
+.modal-body:after {
+    display: table;
+    content: " ";
+}
+.modal-header:before,
+.modal-header:after {
+    display: table;
+    content: " ";
+}
+*/
+
+/* --------------------------------------------------------------------------
+/* Form Validation CSS - http://docs.angularjs.org/guide/forms
+ * TODO: these colors are harsh and don't fit the EG color scheme
+ */
+.form-validated input.ng-invalid.ng-dirty {
+  background-color: #FA787E;
+}
+.form-validated input.ng-valid.ng-dirty {
+  background-color: #78FA89;
+}
+
+/* --------------------------------------------------------------------------
+ * Local style
+ */
+
+#splash-nav .panel-body div {
+    padding-bottom: 10px;
+}
+
+table.list tr.selected td { /* deprecated? */
+    color: #2a6496;
+    background-color: #F5F5F5;
+}
+
+.pad-horiz {padding : 0px 10px 0px 10px; }
+.pad-vert {padding : 20px 0px 10px 0px;}
+.pad-left {padding-left: 10px;}
+.pad-right {padding-right: 10px;}
+.pad-all-min {padding : 5px; }
+.pad-all {padding : 10px; }
+
+#print-div { display: none; }
+
+/* by default, give all tab panes some top padding */
+.tab-pane { padding-top: 20px; }
+
+.nav-pills-like-tabs {
+    border-bottom:1px solid #CCC;
+}
+
+.btn-pad {
+  /* sometimes you don't want buttons scrunched together -- add some margin */
+  margin-left: 10px;
+}
+
+.strong-text {
+  font-weight: bold;
+}
+.strong-text-1 {
+  font-size: 110%;
+  font-weight: bold;
+}
+.strong-text-2 {
+  font-size: 120%;
+  font-weight: bold;
+}
+.strong-text-3 {
+  font-size: 130%;
+  font-weight: bold;
+}
+.strong-text-4 {
+  font-size: 140%;
+  font-weight: bold;
+}
+
+.currency-input {
+  width: 8em;
+}
+
+/* barcode inputs are everywhere.  Let's have a consistent style. */
+.barcode { width: 16em !important; }
+
+/* bootstrap alerts are heavily padded.  use this to reduce */
+.alert-less-pad {padding: 5px;}
+
+/* text displayed inside a <progressbar>, typically the max/progress values */
+.progressbar-text {
+  color:black;
+  white-space:nowrap;
+}
+
+/* embedded UI iframe */
+.eg-embed-frame {
+  width: 100%;
+}
+.eg-embed-frame iframe {
+  width: 100%;
+  border: none;
+  margin: 0px;
+  padding: 0px;
+}
+
+/* ----------------------------------------------------------------------
+ * Grid
+ * ---------------------------------------------------------------------- */
+
+.eg-grid-primary-label {
+  font-weight: bold;
+  font-size: 120%;
+}
+
+/* odd/even row styling */
+.eg-grid-content-body > div:nth-child(odd):not(.eg-grid-row-selected) {
+  background-color: rgb(248, 248, 248);
+}
+
+.eg-grid-row {
+  width: 100%;
+  display: flex;
+  border: 1px solid #ccc;
+}
+
+.eg-grid-row:not(.eg-grid-header-row):not(.eg-grid-conf-row) {
+  /* TODO: remove, pretty sure this is no longer needed w/ nowrap */
+  /*height: 1.8em;*/
+}
+
+.eg-grid-action-row {
+  border: none;
+  /* margin should not have to be this large; something's up */
+  margin-bottom: 12px;
+}
+
+.eg-grid-header-row { 
+  font-weight: bold; 
+}
+
+.eg-grid-header-row > .eg-grid-cell {
+  border-right: 1px solid #CCC;
+  text-align: center;
+
+  /* vertically align header cell text by treating 
+     each header cell as a vertical flex container */
+  display:flex;
+  flex-direction:column;
+  justify-content:flex-end;
+}
+
+.eg-grid-cell {
+  /* avoid text flowing into adjacent cells */
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+
+/* in config display, make cells more obvious */
+.eg-grid-as-conf .eg-grid-row {
+  border: 1px solid #777;
+}
+.eg-grid-as-conf .eg-grid-cell {
+  border-right: 1px solid #777;
+}
+
+/* stock columns need fixed-width controls */
+.eg-grid-cell-stock {
+  width: 2.2em;
+  text-align: center;
+}
+
+/* the conf header must be twice the stock flex */
+.eg-grid-cell-conf-header {
+  width: 4.4em;
+  font-weight: bold;
+}
+
+.eg-grid-row-selected {
+  color: rgb(51, 51, 51);
+  background-color: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+/* Improve ::selection styling by only allowing selection on text
+ * content cells within the main body of the grid.  Otherwise, the browser 
+ * styles row background and text (all dark blue?) when shift-click or 
+ * click-drag is used.
+ */
+.eg-grid-content-body .eg-grid-row {
+  user-select:none;
+  -moz-user-select: none;
+  -webkit-user-select: none;
+}
+.eg-grid-content-body .eg-grid-cell-content {
+  user-select:text;
+  -moz-user-select: text;
+  -webkit-user-select: text;
+}
+.eg-grid-cell-content::-moz-selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+.eg-grid-cell-content::selection {
+  color: rgb(51, 51, 51);
+  background: rgb(201, 221, 225);
+  border-bottom: 1px solid #888;
+}
+
+.eg-grid-conf-cell-entry {
+  width:98%;
+  text-align:center;
+  padding: 3px;
+}
+
+.eg-grid-conf-cell-entry:not(:first-child) {
+  border-top:1px solid #ccc;
+}
+
+.eg-grid-conf-row {
+  background-color: #dff0d8;
+  border-color: #d6e9c6;
+}
+
+.eg-grid-conf-row:first-child {
+  /* alignment fix; account for one missing border */
+  padding-right: 1px;
+}
+
+.eg-grid-column-move-handle:hover {
+  cursor: move;
+}
+
+.eg-grid-column-move-handle-active,
+.eg-grid-column-move-handle-active:active {
+  /* similar to label-primary, sans padding */
+  background-color: rgb(66, 139, 202);
+  color: #fff;
+}
+
+.eg-grid-col-hover {
+  /* similar to label-success, sans padding */
+  background-color: rgb(92, 184, 92);
+  color: #fff;
+}
+
+.eg-grid-column-resize-handle {
+  height: 100%;
+}
+.eg-grid-column-resize-handle:hover {
+  cursor: col-resize;
+}
+
+/* for these to be useful, they would have to be applied 
+ * to the dragover targets.  not yet done */
+.eg-grid-column-resize-handle-west {
+  cursor: w-resize;
+}
+.eg-grid-column-resize-handle-east {
+  cursor: e-resize;
+}
+
+.eg-grid-menu-item {
+  margin-right: 10px;
+}
+
+
+/* hack to make the header columns line up with the content columns
+   when the scroll bar is visible along the right side of the content
+   columns. TODO: if this varies enough by browser, we'll need to
+   calculate the width instead. */
+/*
+.eg-grid-scroll > .eg-grid-header-row, 
+.eg-grid-scroll > .eg-grid-conf-row { 
+  padding-right: 15px;
+}
+.eg-grid-scroll > .eg-grid-content-body {
+  overflow-y:scroll; 
+  height: 600px; 
+}
+*/
+.eg-grid-column-picker {
+  height: auto;
+  max-height: 400px;
+  overflow: auto;
+  box-shadow: none;
+}
+
+
+/* ----------------------------------------------------------------------
+ * /Grid
+ * ---------------------------------------------------------------------- */
+
+
+/* simple flex container for consistent-width cell-based structures */
+.flex-container-striped > .flex-row:nth-child(odd) {
+  background-color: #f5f5f5;
+}
+.flex-container-bordered .flex-cell {
+  border-bottom: 1px solid #ddd;
+}
+.flex-row {
+  display: flex;
+}
+.flex-row.padded div {
+  padding: 5px;
+}
+.flex-row.left-anchored > div {
+  margin-right: 10px;
+}
+.flex-cell {
+  flex: 1;
+  padding: 4px; /* bootstrap default is much bigger */
+}
+.flex-cell.well {
+  min-height: 2.5em; /* don't let empty wells scrunch down */
+  margin-bottom: 5px; /* bootstrap default is 20px */
+}
+.flex-2 { /* meh, convience */
+  flex: 2;
+}
+
+/* TODO: match media size to Bootstrap "md" col resizing */
+@media all and (max-width: 800px) {
+  .flex-row {
+    flex-direction: column;
+  }
+  .eg-grid-row {
+    flex-direction: column;
+  }
+}
+
+
+[%# 
+vim: ft=css 
+%]
diff --git a/Open-ILS/src/templates/staff/index.tt2 b/Open-ILS/src/templates/staff/index.tt2
new file mode 100644 (file)
index 0000000..b2e25f8
--- /dev/null
@@ -0,0 +1,17 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Home"); 
+  ctx.page_app = "egHome";
+%]
+
+[% BLOCK APP_JS %]
+<!-- needed for login -->
+<script src="[% ctx.media_prefix %]/js/dojo/opensrf/md5.js"></script>
+<!-- splash / login page app -->
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/app.js"></script>
+[% END %]
+
+<div ng-view></div> 
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2
new file mode 100644 (file)
index 0000000..6d0e6d8
--- /dev/null
@@ -0,0 +1,244 @@
+<!-- 
+  main navigation bar
+    
+  note the use of target="_self" for navigation links.
+  this tells angular to treat the href as a new page 
+  and not an intra-app route.  This is necessary when
+  moving between applications.
+
+  For icons, see http://getbootstrap.com/components/#glyphicons
+-->
+
+<div id="top-navbar" role="navigation"
+  class="navbar navbar-default navbar-static-top" role="navigation">
+
+  <!-- navbar-header here needed for supporting angular-ui-bootstrap -->
+  <div class="navbar-header">
+    <button type="button" class="navbar-toggle" 
+        ng-init="navCollapsed = true" ng-click="navCollapsed = !navCollapsed">
+      <span class="sr-only">[% l('Toggle navigation') %]</span>
+      <span class="icon-bar"></span>
+      <span class="icon-bar"></span>
+      <span class="icon-bar"></span>
+    </button>
+  </div>
+
+  <div class="navbar-collapse collapse" ng-class="!navCollapsed && 'in'">
+    <ul class="nav navbar-nav">
+      <li><a href='./' title="[% l('Home') %]" target="_self"
+        class="glyphicon glyphicon-home"></a><li>
+
+      <!-- search -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle"
+          data-toggle="dropdown">[% l('Search') %]
+          <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/search" target="_self"
+              eg-accesskey="[% l('alt+s') %]" 
+              eg-accesskey-desc="[% l('Patron search by name, address, etc.') %]">
+              <span class="glyphicon glyphicon-user"></span>
+              <span eg-accesskey-label>[% l('Search for Patrons') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./cat/item/search" target="_self">
+              <span class="glyphicon glyphicon-barcode"></span>
+              <span>[% l('Search for Copies by Barcode') %]</span>
+            </a>
+          </li>
+        </ul>
+      </li>
+
+
+      <!-- circulation -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle"
+          data-toggle="dropdown">[% l('Circulation') %]
+          <b class="caret"></b>
+        </a>
+
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./circ/patron/bcsearch" target="_self">
+              <span class="glyphicon glyphicon-export"></span>
+              [% l('Check Out') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/checkin/checkin" target="_self">
+              <span class="glyphicon glyphicon-import"></span>
+              [% l('Check In') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/checkin/capture" target="_self">
+              <span class="glyphicon glyphicon-pushpin"></span>
+              [% l('Capture Holds') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/holds/pull" target="_self">
+              <span class="glyphicon glyphicon-th-list"></span>
+              [% l('Pull List for Hold Requests') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/renew/renew" target="_self">
+              <span class="glyphicon glyphicon-refresh"></span>
+              [% l('Renew Items') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/register" target="_self">
+              <span class="glyphicon glyphicon-user"></span>
+              [% l('Register Patron') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/last" target="_self">
+              <span class="glyphicon glyphicon-share-alt"></span>
+              [% l('Retrieve Last Patron') %]
+            </a>
+          </li>
+          <li>
+            <a href="./circ/patron/pending/list" target="_self">
+              <span class="glyphicon glyphicon-thumbs-up"></span>
+              [% l('Pending Patrons') %]
+            </a>
+          </li>
+          <li class="divider"></li>
+          <li>
+            <a href="./circ/patron/credentials" target="_self">
+              <span class="glyphicon glyphicon-ok"></span>
+              <span>[% l('Verify Credentials') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./circ/in_house_use/index" target="_self">
+              <span class="glyphicon glyphicon-pencil"></span>
+              <span>[% l('Record In-House Use') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./circ/holds/shelf" target="_self">
+              <span class="glyphicon glyphicon-tasks"></span>
+              <span>[% l('Holds Shelf') %]</span>
+            </a>
+          </li>
+          <li class="divider"></li>
+          <li>
+            <a href="./cat/item/replace_barcode/index" target="_self">
+              <span class="glyphicon glyphicon-barcode"></span>
+              <span>[% l('Replace Barcode') %]</span>
+            </a>
+          </li>
+          <li>
+            <a href="./cat/item/missing_pieces" target="_self">
+              <span class="glyphicon glyphicon-th"></span>
+              <span>[% l('Scan Item as Missing Pieces') %]</span>
+            </a>
+          </li>
+        </ul>
+      </li><!-- circ -->
+
+      <!-- cataloging -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">[% l('Cataloging') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./cat/catalog/index" target="_self">
+              <span class="glyphicon glyphicon-search"></span>
+              [% l('Search the Catalog') %]
+            </a>
+          </li>
+          <li>
+            <a href="./cat/bucket/record/view" target="_self">
+              <span class="glyphicon glyphicon-list-alt"></span>
+              [% l('Record Buckets') %]
+            </a>
+          </li>
+       </ul>
+      </li>
+
+      <!-- admin -->
+      <li class="dropdown">
+        <a href="javascript:;" class="dropdown-toggle" 
+          data-toggle="dropdown">[% l('Administration') %]
+            <b class="caret"></b>
+        </a>
+        <ul class="dropdown-menu">
+          <li>
+            <a href="./admin/workstation/index" target="_self">
+              <span class="glyphicon glyphicon-hdd"></span>
+              [% l('Workstation') %]
+            </a>
+          </li>
+          <li>
+            <a href="./admin/user_perms" target="_self">
+              <span class="glyphicon glyphicon-user"></span>
+              [% l('User Permission Editor') %]
+            </a>
+          </li>
+        </ul> <!-- admin dropdown -->
+      </li>
+    </ul> <!-- end left side entries -->
+
+    <!-- entries along the right side of the navbar -->
+    <ul class="nav navbar-nav navbar-right" style='margin-right: 6px;'>
+      <li>
+        <a ng-cloak ng-show="username" 
+          ng-init="workstation = '[% l('<no workstation>') %]'">
+            [% l('{{username}} @ {{workstation}}') %]
+        </a>
+      </li>
+
+      <!-- locale selector.  
+        only shown if multiple locales are registered -->
+      [% IF ctx.locales.keys.size > 1 %]
+      <li class="dropdown">
+        <a href='' class="dropdown-toggle" data-toggle="dropdown">
+            [% lcl = ctx.locale;  ctx.locales.$lcl %]
+            <span class="glyphicon glyphicon-flag"></span>
+        </a>
+        <ul class="dropdown-menu">
+        [% FOR locale IN ctx.locales.keys.sort %]
+          <!-- disable the selected locale -->
+          <li ng-class="{disabled : '[% ctx.locale %]'=='[% locale %]'}">
+            <a href="" ng-click="applyLocale('[% locale %]')">
+                [% ctx.locales.$locale %]
+            </a>
+          </li>
+        [% END %]
+        </ul>
+      </li>
+      [% END %]
+
+      <li class="dropdown" ng-show="username">
+        <a href='' class="dropdown-toggle glyphicon glyphicon-list" 
+          data-toggle="dropdown"></a>
+        <ul class="dropdown-menu">
+          <li class="disabled">
+            <a href="" ng-click="" target="_self">
+              <span class="glyphicon glyphicon-random"></span>
+              [% l('Change Operator') %]
+            </a>
+          </li>
+          <li>
+            <a href="./login" ng-click="logout()" target="_self">
+              <span class="glyphicon glyphicon-log-out"></span>
+              [% l('Log Out') %]
+            </a>
+          </li>
+        </ul>
+      </li>
+    </ul>
+  </div>
+</div>
+
+
diff --git a/Open-ILS/src/templates/staff/share/README b/Open-ILS/src/templates/staff/share/README
new file mode 100644 (file)
index 0000000..bcbddf5
--- /dev/null
@@ -0,0 +1,5 @@
+Location for globally shared template files.  These are generally used 
+by AngularJS directives.
+
+App-specific shared templates should live within the application's
+directory.
diff --git a/Open-ILS/src/templates/staff/share/print_templates/index.tt2 b/Open-ILS/src/templates/staff/share/print_templates/index.tt2
new file mode 100644 (file)
index 0000000..0bcedf0
--- /dev/null
@@ -0,0 +1,2 @@
+[% USE CGI %]
+[% l('Print Template Not Found: [_1]', CGI.url("-path",1,"-relative",1)) %]
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2
new file mode 100644 (file)
index 0000000..fe6851a
--- /dev/null
@@ -0,0 +1,69 @@
+Welcome to {{current_location.name}}!<br/>
+A receipt of your  transaction:<hr/>
+
+<table style="width:100%"> 
+  <tr> 
+    <td>[% l('Original Balance:') %]</td> 
+    <td align="right">{{previous_balance | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Method:') %]</td> 
+    <td align="right">
+      <div ng-switch="payment_type">
+        <div ng-switch-when="cash_payment">[% l('Cash') %]</div>
+        <div ng-switch-when="check_payment">[% l('Check') %]</div>
+        <div ng-switch-when="credit_card_payment">[% l('Credit Card') %]</div>
+        <div ng-switch-when="credit_payment">[% l('Patron Credit') %]</div>
+        <div ng-switch-when="work_payment">[% l('Work') %]</div>
+        <div ng-switch-when="forgive_payment">[% l('Forgive') %]</div>
+        <div ng-switch-when="goods_payment">[% l('Goods') %]</div>
+      </div>
+    </td>
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Received:') %]</td> 
+    <td align="right">{{payment_total | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Payment Applied:') %]</td> 
+    <td align="right">{{payment_applied | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Billings Voided:') %]</td> 
+    <td align="right">{{amount_voided | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('Change Given:') %]</td> 
+    <td align="right">{{change_given | currency}}</td> 
+  </tr> 
+  <tr> 
+    <td>[% l('New Balance:') %]</td> 
+    <td align="right">{{new_balance | currency}}</td> 
+  </tr> 
+</table> 
+
+<p>[% l('Note: [_1]', '{{payment_note}}') %]</p>
+
+<p>
+[% l('Specific Bills') %]
+  <blockquote>
+    <div ng-repeat="payment in payments">
+      <table style="width:100%">
+        <tr>
+          <td>[% l('Bill # [_1]', '{{payment.xact.id}}') %]</td>
+          <td>{{payment.xact.summary.last_billing_type}}</td>
+          <td>[% l('Received: [_1]', '{{payment.amount | currency}}') %]</td>
+        </tr>
+        <tr>
+          <td colspan="5">
+            {{payment.xact.copy_barcode}} {{payment.xact.title}}
+          </td>
+        </tr>
+      </table>
+      <br/>
+    </div>
+  </blockquote>
+</p> 
+<hr/>
+<br/><br/> 
+{{current_location.shortname}} {{today | date:'short'}}
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bills_current.tt2
new file mode 100644 (file)
index 0000000..c99cb4d
--- /dev/null
@@ -0,0 +1,49 @@
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+  <div ng-repeat="xact in transactions">
+    <dt><b>Bill #{{xact.id}}</b></dt>
+    <dd>
+    <table> 
+      <tr valign="top">
+        <td>[% l('Date:') %]</td>
+        <td>{{xact.xact_start | date:'short'}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Type') %]:</td>
+        <td>{{xact.summary.xact_type}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Billing') %]:</td>
+        <td>{{xact.summary.last_billing_type}}<br/>
+            {{xact.summary.last_billing_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Billed') %]:</td>
+        <td>{{xact.summary.total_owed | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Payment') %]:</td>
+        <td>{{xact.summary.last_payment_type}}<br/>
+            {{xact.summary.last_payment_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Paid') %]:</td>
+        <td>{{xact.summary.total_paid | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td><b>[% l('Balance') %]:</b></td>
+        <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+      </tr> 
+    </table>
+    </dd>
+    <br/>
+  </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_bills_historical.tt2
new file mode 100644 (file)
index 0000000..c99cb4d
--- /dev/null
@@ -0,0 +1,49 @@
+Welcome to {{current_location.name}}!<br/>
+You have the following bills:
+<hr/>
+<dl>
+  <div ng-repeat="xact in transactions">
+    <dt><b>Bill #{{xact.id}}</b></dt>
+    <dd>
+    <table> 
+      <tr valign="top">
+        <td>[% l('Date:') %]</td>
+        <td>{{xact.xact_start | date:'short'}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Type') %]:</td>
+        <td>{{xact.summary.xact_type}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Billing') %]:</td>
+        <td>{{xact.summary.last_billing_type}}<br/>
+            {{xact.summary.last_billing_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Billed') %]:</td>
+        <td>{{xact.summary.total_owed | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Last Payment') %]:</td>
+        <td>{{xact.summary.last_payment_type}}<br/>
+            {{xact.summary.last_payment_note}}
+        </td>
+      </tr> 
+      <tr valign="top">
+        <td>[% l('Total Paid') %]:</td>
+        <td>{{xact.summary.total_paid | currency}}</td>
+      </tr> 
+      <tr valign="top">
+        <td><b>[% l('Balance') %]:</b></td>
+        <td><b>{{xact.summary.balance_owed | currency}}</b></td>
+      </tr> 
+    </table>
+    </dd>
+    <br/>
+  </div><!-- ng-repeat -->
+</dl>
+<hr/>
+{{current_location.shortname}} {{today | date:'short'}}
+<br/><br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_checkin.tt2
new file mode 100644 (file)
index 0000000..7bc56e4
--- /dev/null
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You checked in the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkin in checkins">
+      <div>{{checkin.title}}</div>
+      <span>[% l('Barcode: ') %]</span>
+      <span>{{checkin.copy_barcode}}</span>
+      <span>[% l('Call Number: ') %]</span>
+      <span>{{checkin.call_number.label || "[% l("Not Cataloged") %]"}}</span>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <br/>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_checkout.tt2
new file mode 100644 (file)
index 0000000..1f1218e
--- /dev/null
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You checked out the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in circulations">
+      <div>{{checkout.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.copy.barcode}}',
+        '{{checkout.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_pull_list.tt2
new file mode 100644 (file)
index 0000000..ce23fe4
--- /dev/null
@@ -0,0 +1,29 @@
+<table id='pull-list-template-table'>
+  <style>
+    #pull-list-template-table td,
+    #pull-list-template-table th {
+      padding: 5px;
+      border: 1px solid #000;
+    }
+  </style>
+  <thead>
+    <tr>
+      <th>[% l('Type') %]</th>
+      <th>[% l('Title') %]</th>
+      <th>[% l('Author') %]</th>
+      <th>[% l('Shelf Location') %]</th>
+      <th>[% l('Call Number') %]</th>
+      <th>[% l('Barcode/Part') %]</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr ng-repeat="hold_data in holds">
+      <td>{{hold_data.hold.hold_type}}</td>
+      <td>{{hold_data.title}}</td>
+      <td>{{hold_data.author}}</td>
+      <td>{{hold_data.copy.location.name}}</td>
+      <td>{{hold_data.volume.label}}</td>
+      <td>{{hold_data.copy.barcode}} {{hold_data.part.label}}</td>
+    </tr>
+  </tbody>
+</table>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_shelf_slip.tt2
new file mode 100644 (file)
index 0000000..f1980d7
--- /dev/null
@@ -0,0 +1,37 @@
+<div>
+  <div ng-switch on="hold.behind_desk">
+    <div ng-switch-when="t">
+      [% l('This item needs to be routed to the [_1]Private Holds Shelf[_2].',
+        '<strong>','</strong>') %]
+    </div>
+    <div ng-switch-when="f">
+      [% l('This item needs to be routed to the [_1]Public Holds Shelf[_2].',
+        '<strong>','</strong>') %]
+    </div>
+  </div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+
+  <br/>
+  <br/>
+
+  <div>[% l('Hold for patron [_1], [_2] [_3]',
+    '{{patron.family_given_name}}',
+    '{{patron.first_given_name}}',
+    '{{patron.second_given_name}}') %]</div>
+  <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+  <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+  <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+  <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Request Date: [_1]', 
+    '{{hold.request_time | date:"short"}}') %]</div>
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_hold_transit_slip.tt2
new file mode 100644 (file)
index 0000000..6b36c25
--- /dev/null
@@ -0,0 +1,34 @@
+<div>
+  <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+  <div>{{dest_location.name}}</div>
+  <div>{{dest_address.street1}}
+  <div>{{dest_address.street2}}</div>
+  <div>{{dest_address.city}},
+       {{dest_address.state}}
+       {{dest_address.post_code}}</div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+  [% l('Author: [_1]', '{{author}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Hold for patron [_1], [_2] [_3]',
+    '{{patron.family_given_name}}',
+    '{{patron.first_given_name}}',
+    '{{patron.second_given_name}}') %]</div>
+  <div>[% l('Barcode: [_1]', '{{patron.card.barcode}}') %]</div>
+  <div ng-if="hold.phone_notify">[% l('Notify by phone: [_1]', '{{hold.phone_notify}}') %]</div>
+  <div ng-if="hold.sms_notify">[% l('Notify by text: [_1]', '{{hold.sms_notify}}') %]</div>
+  <div ng-if="hold.email_notify == 't'">[% l('Notify by email: [_1]', '{{patron.email}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Request Date: [_1]', 
+    '{{hold.request_time | date:"short"}}') %]</div>
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_bib.tt2
new file mode 100644 (file)
index 0000000..8d3061c
--- /dev/null
@@ -0,0 +1,29 @@
+<div>
+  <div>[% l('Holds for record: [_1]', '{{holds[0].title}}') %]</div>
+  <hr/>
+  <style>#holds-for-bib-table td { padding: 5px; }</style>
+  <table id="holds-for-bib-table">
+    <thead>
+      <tr>
+        <th>[% l('Request Date') %]</th>
+        <th>[% l('Patron Barcode') %]</th>
+        <th>[% l('Patron Last') %]</th>
+        <th>[% l('Patron Alias') %]</th>
+        <th>[% l('Current Copy') %]</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr ng-repeat="hold in holds">
+        <td>{{hold.hold.request_time | date:'short'}}</td>
+        <td>{{hold.patron_barcode}}</td>
+        <td>{{hold.patron_last}}</td>
+        <td>{{hold.patron_alias}}</td>
+        <td>{{hold.copy.barcode}}</td>
+      </tr>
+    </tbody>
+  </table>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('Printed by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_holds_for_patron.tt2
new file mode 100644 (file)
index 0000000..492454c
--- /dev/null
@@ -0,0 +1,14 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You have the following title on hold:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="hold in holds">
+      <div>{{hold.title}}</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_items_out.tt2
new file mode 100644 (file)
index 0000000..fee903b
--- /dev/null
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You have the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="checkout in circulations">
+      <div>{{checkout.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{checkout.copy.barcode}}',
+        '{{checkout.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_patron_address.tt2
new file mode 100644 (file)
index 0000000..c1a3e37
--- /dev/null
@@ -0,0 +1,12 @@
+<div>
+  <div>
+    {{patron.first_given_name}} 
+    {{patron.second_given_name}} 
+    {{patron.family_name}}
+  </div>
+  <div>{{address.street1}}</div>
+  <div ng-if="address.street2">{{address.street2}}</div>
+  <div>
+    {{address.city}}, {{address.state}} {{address.post_code}}
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_patron_note.tt2
new file mode 100644 (file)
index 0000000..5b98683
--- /dev/null
@@ -0,0 +1,11 @@
+<h3>[% l(
+  'Pertaining to [_1], [_2] [_3] : [_4]',
+  '{{note.usr.family_name}}',
+  '{{note.usr.first_given_name}}',
+  '{{note.usr.second_given_name}}',
+  '{{note.usr.card.barcode}}') %]</h3>
+
+<p>[% l('Created on [_1]', '{{note.create_date | date:"short"}}') %]</p>
+<b>{{note.title}}</b>
+<br/>
+<p>{{note.value}}</p>
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_renew.tt2
new file mode 100644 (file)
index 0000000..8e96445
--- /dev/null
@@ -0,0 +1,17 @@
+<div>
+  <div>[% l('Welcome to [_1]', '{{current_location.name}}') %]</div>
+  <div>[% l('You renewed the following items:') %]</div>
+  <hr/>
+  <ol>
+    <li ng-repeat="renewal in circulations">
+      <div>{{renewal.title}}</div>
+      <div>[% l('Barcode: [_1] Due: [_2]', 
+        '{{renewal.copy.barcode}}',
+        '{{renewal.circ.due_date | date:"short"}}') %]</div>
+    </li>
+  </ol>
+  <hr/>
+  <div>{{current_location.shortname}} {{today | date:'short'}}</div>
+  <div>[% l('You were helped by [_1]', '{{staff.first_given_name}}') %]</div>
+<br/>
+
diff --git a/Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2 b/Open-ILS/src/templates/staff/share/print_templates/t_transit_slip.tt2
new file mode 100644 (file)
index 0000000..324ad5e
--- /dev/null
@@ -0,0 +1,21 @@
+<div>
+  <div>[% l('This item needs to be routed to [_1]', '<b>{{dest_location.shortname}}</b>') %]</div>
+  <div>{{dest_location.name}}</div>
+  <div>{{dest_address.street1}}
+  <div>{{dest_address.street2}}</div>
+  <div>{{dest_address.city}},
+       {{dest_address.state}}
+       {{dest_address.post_code}}</div>
+  <br/>
+
+  [% l('Barcode: [_1]', '{{copy.barcode}}') %]</div>
+  [% l('Title: [_1]', '{{title}}') %]</div>
+  [% l('Author: [_1]', '{{author}}') %]</div>
+
+  <br/>
+
+  <div>[% l('Slip Date: [_1]', '{{today | date:"short"}}') %]</div>
+  <div>[% l('Printed by [_1] at [_2]', 
+    '{{staff.first_given_name}}', '{{current_location.shortname}}') %]</div>
+
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_alert_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_alert_dialog.tt2
new file mode 100644 (file)
index 0000000..b71614a
--- /dev/null
@@ -0,0 +1,16 @@
+<!-- 
+  Generic alert dialog.
+  The only user action allowed is the 'OK' button.
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="ok()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-danger">[% l('Alert') %]</h4>
+  </div>
+  <div class="modal-body">{{message}}</div>
+  <div class="modal-footer">
+    <input type="submit" 
+      class="btn btn-primary" ng-click="ok()" value="[% l('OK') %]"/>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_autogrid.tt2 b/Open-ILS/src/templates/staff/share/t_autogrid.tt2
new file mode 100644 (file)
index 0000000..136252c
--- /dev/null
@@ -0,0 +1,297 @@
+
+<!-- 
+  Actions row.
+  This sits above the grid and contains the column picker, etc.
+-->
+
+<div class="eg-grid-row eg-grid-action-row">
+
+  <div class="eg-grid-primary-label">{{mainLabel}}</div>
+
+  <div class="btn-group eg-grid-menuiitem" 
+    is-open="gridMenuIsOpen" ng-if="menuLabel" dropdown>
+    <button type="button" class="btn btn-default dropdown-toggle">
+      {{menuLabel}}<span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu">
+      <li ng-repeat="item in menuItems" ng-class="{divider: item.divider}">
+        <a ng-if="!item.divider" href ng-disabled="item.disabled"
+          ng-click="item.handler()">{{item.label}}</a>
+      </li>
+    </ul>
+  </div>
+
+  <!-- if no menu label is present, present menu-items as a 
+       horizontal row of buttons -->
+  <div class="btn-group" ng-if="!menuLabel">
+    <button ng-if="!item.hidden()"
+      class="btn btn-default eg-grid-menu-item"
+      ng-disabled="item.disabled()"
+      ng-repeat="item in menuItems"
+      ng-click="item.handler(item, item.handlerData)">
+      {{item.label}}
+    </button>
+  </div>
+
+  <!-- putting a flex div here forces the remaining content to float right -->
+  <div class="flex-cell"></div>
+
+  <!-- column picker, pager, etc. -->
+  <div class="btn-group column-picker">
+
+    <!-- first page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : onFirstPage()}" 
+      ng-click="offset(0);collect()"
+      title="[% l('Start') %]">
+        <span class="glyphicon glyphicon-fast-backward"></span>
+    </button>
+
+    <!-- previous page -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : onFirstPage()}"
+      ng-click="decrementPage()"
+      title="[% l('Previous Page') %]">
+        <span class="glyphicon glyphicon-backward"></span>
+    </button>
+
+    <!-- next page -->
+    <!-- todo: paging needs a total count value to be fully functional -->
+    <button type="button" class="btn btn-default" 
+      ng-class="{disabled : !hasNextPage()}"
+      ng-click="incrementPage()"
+      title="[% l('Next Page') %]">
+        <span class="glyphicon glyphicon-forward"></span>
+    </button>
+
+    <!-- actions drop-down menu -->
+    <div class="btn-group" ng-if="actions.length" dropdown>                                                  
+      <button type="button" class="btn btn-default dropdown-toggle"
+        ng-class="{disabled : false}">
+        [% l('Actions') %] <span class="caret"></span>                       
+      </button>                                                              
+      <ul class="dropdown-menu pull-right">                                  
+        <li ng-repeat="action in actions" ng-class="{divider: action.divider}">
+          <a ng-if="!action.divider" href dropdown-toggle
+            ng-click="actionLauncher(action)">{{action.label}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridRowCountIsOpen">
+      <button type="button" title="[% ('Select Row Count') %]"
+        class="btn btn-default dropdown-toggle">
+        [% l('Rows [_1]', '{{limit()}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li ng-repeat="t in [5,10,25,50,100]">
+          <a href ng-click='offset(0);limit(t);collect()'>
+            {{t}}
+          </a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridPageSelectIsOpen">
+      <button type="button" title="[% ('Select Page') %]"
+        class="btn btn-default dropdown-toggle">
+        [% l('Page [_1]', '{{page()}}') %]
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu">
+        <li>
+          <div class="input-group">
+            <input type="text" class="form-control"
+              ng-model="pageFromUI"
+              ng-click="$event.stopPropagation()"/>
+            <span class="input-group-btn">
+              <button class="btn btn-default" type="button"
+                ng-click="goToPage(pageFromUI);pageFromUI='';">
+                [% l('Go To...') %]
+              </button>
+            </span>
+          </div>
+        </li>
+        <li role="presentation" class="divider"></li>
+        <li ng-repeat="t in [1,2,3,4,5,10,25,50,100]">
+          <a href ng-click='goToPage(t);gridPageSelectIsOpen=false;'>{{t}}</a>
+        </li>
+      </ul>
+    </div>
+
+    <div class="btn-group" dropdown is-open="gridColumnPickerIsOpen">
+      <button type="button" 
+        class="btn btn-default dropdown-toggle">
+        <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu pull-right eg-grid-column-picker">
+        <li><a href ng-click="toggleConfDisplay()">
+          <span class="glyphicon glyphicon-wrench"></span>
+          [% l('Configure Columns') %]
+        </a></li>
+        <li><a href ng-click="saveConfig()">
+          <span class="glyphicon glyphicon-floppy-save"></span>
+          [% l('Save Columns') %]
+        </a></li>
+        <li><a href ng-click="showAllColumns()">
+          <span class="glyphicon glyphicon-resize-full"></span>
+          [% l('Show All Columns') %]
+        </a></li>
+        <li><a href ng-click="hideAllColumns()">
+          <span class="glyphicon glyphicon-resize-small"></span>
+          [% l('Hide All Columns') %]
+        </a></li>
+        <li><a href ng-click="resetColumns()">
+          <span class="glyphicon glyphicon-refresh"></span>
+          [% l('Reset Columns') %]
+        </a></li>
+        <li><a ng-click="generateCSVExportURL()" 
+          download="{{csvExportFileName}}.csv" ng-href="{{csvExportURL}}">
+          <span class="glyphicon glyphicon-download"></span>
+          [% l('Download CSV') %]
+        </a></li>
+        <li><a href ng-click="printCSV()">
+          <span class="glyphicon glyphicon-print"></span>
+          [% l('Print CSV') %]
+        </a></li>
+        <li role="presentation" class="divider"></li>
+        <li ng-repeat="col in columns">
+          <a href ng-click="toggleColumnVisibility(col)">
+              <span ng-if="col.visible" 
+                class="label label-success">&#x2713;</span>
+              <span ng-if="!col.visible" 
+                class="label label-warning">&#x2717;</span>
+              <span>{{col.label}}</span>
+          </a>
+        </li>
+      </ul>
+    </div>
+  </div>
+</div>
+
+<!-- Grid -->
+<div class="eg-grid" ng-class="{'eg-grid-as-conf' : showGridConf}">
+
+  <!-- import our eg-grid-field defs -->
+  <div ng-transclude></div>
+
+  <div class="eg-grid-row eg-grid-header-row">
+    <div class="eg-grid-cell eg-grid-cell-stock">
+      <div title="[% l('Row Number Column') %]">[% l('#') %]</div>
+    </div>
+    <div class="eg-grid-cell eg-grid-cell-stock">
+      <div>
+        <input title="[% l('Row Selector Column') %]"
+          focus-me="gridControls.focusRowSelector"
+          type='checkbox' ng-model="selectAll"/> 
+      </div>
+    </div>
+    <div class="eg-grid-cell"
+        eg-grid-column-drag-dest
+        column="{{col.name}}"
+        eg-right-click="onContextMenu($event)"
+        ng-repeat="col in columns"
+        style="flex:{{col.flex}}"
+        ng-show="col.visible">
+
+        <div style="display:flex">
+          <div style="flex:1" class="eg-grid-column-move-handle">
+            <div ng-if="col.sortable">
+              <a column="{{col.name}}" href
+                eg-grid-column-drag-source
+                ng-click="quickSort(col.name)">{{col.label}}</a>
+            </div>
+            <div ng-if="!col.sortable">
+              <div column="{{col.name}}" eg-grid-column-drag-source>{{col.label}}</div>
+            </div>
+          </div>
+          <div eg-grid-column-drag-source 
+            drag-type="resize" column="{{col.name}}" 
+            class="eg-grid-column-resize-handle">&nbsp;</div>
+        </div>
+    </div>
+  </div>
+
+  <!-- Inline grid configuration row -->
+  <div class="eg-grid-row eg-grid-conf-row" ng-show="showGridConf">
+    <div class="eg-grid-cell eg-grid-cell-conf-header">
+      <div class="eg-grid-conf-cell-entry">[% l('Expand') %]</div>
+      <div class="eg-grid-conf-cell-entry">[% l('Shrink') %]</div>
+      <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">[% l('Sort') %]</div>
+    </div>
+    <div class="eg-grid-cell"
+      ng-repeat="col in columns"
+      style="flex:{{col.flex}}"
+      ng-show="col.visible">
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column wider') %]"
+          ng-click="modifyColumnFlex(col,1)">
+          <span class="glyphicon glyphicon-fast-forward"></span>
+        </a>
+      </div>
+      <div class="eg-grid-conf-cell-entry">
+        <a href="" title="[% l('Make column narrower') %]"
+          ng-click="modifyColumnFlex(col,-1)">
+          <span class="glyphicon glyphicon-fast-backward"></span>
+        </a>
+      </div>
+      <div class="eg-grid-conf-cell-entry" ng-if="!disableMultiSort">
+        <div ng-if="col.multisortable">
+          <input type='number' ng-model="col.sort"
+            title="[% l('Sort Priority / Direction') %]" style='width:2.3em'/>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="eg-grid-content-body">
+    <div ng-show="items.length == 0" 
+      class="alert alert-info">[% l('No Items To Display') %]</div>
+
+    <div class="eg-grid-row" 
+        id="eg-grid-row-{{$index + 1}}"
+        ng-repeat="item in items"
+        ng-show="items.length > 0"
+        ng-class="{'eg-grid-row-selected' : selected[indexValue(item)]}">
+      <div class="eg-grid-cell eg-grid-cell-stock"
+        ng-click="handleRowClick($event, item)" title="[% l('Row Index') %]">
+        <a href ng-show="gridControls.activateItem" 
+          ng-click="gridControls.activateItem(item)" style="font-weight:bold">
+          {{$index + offset() + 1}}
+        </a>
+        <div ng-hide="gridControls.activateItem">{{$index + offset() + 1}}</div>
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-stock">
+        <!-- ng-click=handleRowClick here has unintended 
+             consequences and is unnecessary, avoid it -->
+        <div>
+          <input type='checkbox' title="[% l('Select Row') %]"
+            ng-model="selected[indexValue(item)]"/>
+        </div>
+      </div>
+      <div class="eg-grid-cell eg-grid-cell-content"
+          ng-click="handleRowClick($event, item)"
+          ng-dblclick="gridControls.activateItem(item)"
+          ng-repeat="col in columns"
+          style="flex:{{col.flex}}"
+          ng-show="col.visible">
+
+          <!-- if the cell comes with its own template,
+               translate that content into HTML and insert it here -->
+          <span ng-if="col.template" 
+            ng-bind-html="translateCellTemplate(col, item)">
+          </span>
+
+          <!-- otherwise, simply display the item value, which may 
+               pass through datatype-specific filtering. -->
+          <span ng-if="!col.template">
+            {{itemFieldValue(item, col) | egGridValueFilter:col}}
+          </span>
+      </div>
+    </div>
+  </div>
+
+
+</div>
+
diff --git a/Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_confirm_dialog.tt2
new file mode 100644 (file)
index 0000000..45e8ca1
--- /dev/null
@@ -0,0 +1,18 @@
+<!--
+  Generic confirmation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{title}}</h4> 
+  </div>
+  <div class="modal-body">{{message}}</div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_eframe.tt2 b/Open-ILS/src/templates/staff/share/t_eframe.tt2
new file mode 100644 (file)
index 0000000..8fc95ca
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="eg-embed-frame">
+  <!-- height is calculated at render time -->
+  <iframe 
+    src="{{url}}" 
+    style="height:{{height}}px"
+    onload="egEmbedFrameLoader(this)">
+  </iframe>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_prompt_dialog.tt2
new file mode 100644 (file)
index 0000000..ce19832
--- /dev/null
@@ -0,0 +1,21 @@
+<!--
+  Generic confirmation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{message}}</h4> 
+  </div>
+  <div class="modal-body">
+    <div class="col-md-12">
+      <input type='text' ng-model="args.value" class="form-control" focus-me="focus"/>
+    </div>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/statusbar.tt2 b/Open-ILS/src/templates/staff/statusbar.tt2
new file mode 100644 (file)
index 0000000..7eef88b
--- /dev/null
@@ -0,0 +1,33 @@
+<!-- Status bar along the bottom of the page -->
+
+<div id="status-bar" 
+  class="navbar navbar-default navbar-fixed-bottom" 
+  role="navigation">
+
+  <!-- 
+    Define the status bar as a directive so it may be used globally.
+    The template is defined inline (below) to leverage i18n and 
+    so one less network fetch is required.
+  -->
+  <eg-status-bar></eg-status-bar>
+  <script type="text/ng-template" id="eg-status-bar-template">
+    <ul class="nav navbar-nav navbar-right">
+      <li>{{messages[0]}}</li>
+      <li>
+        <span 
+          ng-click="hatchConnect()"
+          title="[% l('Print/Store Connection Status') %]"
+          class="glyphicon glyphicon-transfer"
+          ng-class="{'status-bar-connected' : hatchConnected()}">
+        </span>
+      </li>
+      <li>
+        <span 
+          title="[% l('Network Connection Status') %]"
+          class="glyphicon glyphicon-signal"
+          ng-class="{'status-bar-connected' : netConnected()}">
+        </span>
+      </li>
+    </ul>    
+  </script>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2
new file mode 100644 (file)
index 0000000..135e9da
--- /dev/null
@@ -0,0 +1,57 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-3"></div><!-- offset? -->
+      <div class="col-md-6">
+        <fieldset>
+          <legend>[% l('Sign In') %]</legend>
+          <!-- 
+            login() hangs off the page $scope.
+            Values entered by the user are put into 'args', 
+            which is is autovivicated if needed.
+            The input IDs are there to match the labels.  
+            They are not referenced in the Login controller.
+          -->
+          <form ng-submit="login(args)" name="login-form" class="form-horizontal" role="form">
+            <div class="form-group">
+              <label class="col-md-4 control-label" for="login-username">[% l('Username') %]</label>
+              <div class="col-md-8">
+                <input type="text" id="login-username" class="form-control" 
+                  focus-me="focusMe" select-me="focusMe"
+                  placeholder="Username" ng-model="args.username"/>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-md-4 control-label" for="login-password">[% l('Password') %]</label>
+              <div class="col-md-8">
+                <input type="password" id="login-password" class="form-control"
+                  placeholder="Password" ng-model="args.password"/>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-md-4 control-label" 
+                for="login-workstation">[% l('Workstation') %]</label>
+              <div class="col-md-8">
+                <select class="form-control" ng-model="args.workstation"
+                  ng-options="ws for ws in workstations">
+                  <option>[% l('Select Workstation') %]</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <div class="col-md-offset-4 col-md-2">
+                <button type="submit" class="btn btn-default">[% l('Sign in') %]</button>
+              </div>
+              <div class="col-md-2">
+                <span ng-show="loginFailed" class="label label-warning">[% l('Login Failed') %]</span>
+              </div>
+            </div>
+
+          </form>
+        </fieldset>
+      </div>
+    <div class="col-md-3"></div><!-- offset? -->
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/t_splash.tt2 b/Open-ILS/src/templates/staff/t_splash.tt2
new file mode 100644 (file)
index 0000000..d259698
--- /dev/null
@@ -0,0 +1,69 @@
+<div class="container">
+  <div class="row">
+    <div class="col-md-12 text-center">
+      <img src="/xul/server/skin/media/images/portal/logo.png"/>
+    </div>
+  </div>
+  <br/>
+  <div class="row" id="splash-nav">
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Circulation and Patrons') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/forward.png"/>
+            <a target="_self" href="./circ/patron/bcsearch">[% l('Check Out Items') %]</a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/back.png"/>
+            <a target="_self" href="./circ/checkin/index">[% l('Check In Items') %]</a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/retreivepatron.png"/>
+            <a target="_self" href="./circ/patron/search">[% l('Search For Patron By Name') %]</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Item Search and Cataloging') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/bucket.png"/>
+            <a target="_self" href="./cat/bucket/record/">[% l('Record Buckets') %]</a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="col-md-4">
+      <div class="panel panel-success">
+        <div class="panel-heading">
+          <div class="panel-title text-center">[% l('Administration') %]</div>
+        </div>
+        <div class="panel-body">
+          <div>
+            <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+            <a target="_top" href="http://docs.evergreen-ils.org/">
+              [% l('Evergreen Documentation') %]
+            </a>
+          </div>
+          <div>
+            <img src="/xul/server/skin/media/images/portal/helpdesk.png"/>
+            <a target="_top" href="./admin/workstation/index">
+              [% l('Workstation Administration') %]
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</div>
index ebed5e5..d281ca2 100644 (file)
@@ -1,4 +1,11 @@
 /* staff client integration functions */
+
+// Browser staff client runs the TPAC within an iframe, whose onload
+// is not called until after the page onload is called. window.onload
+// actions are wrapped in timeouts (below) to ensure the wrapping page
+// has a chance to insert the necessary xulG, etc. functions into the
+// window.
+
 function debug(msg){dump(msg+'\n')}
 var eventCache={};
 function attachEvt(scope, name, action) {
@@ -34,107 +41,137 @@ function staff_hold_usr_barcode_changed(isload) {
         return;
     }
 
-    if(typeof xulG != 'undefined' && xulG.get_barcode_and_settings) {
-        var cur_hold_barcode = undefined;
-        var barcode = isload;
-        if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
-        var only_settings = true;
-        if(!document.getElementById('hold_usr_is_requestor').checked) {
-            if(!isload) {
-                barcode = document.getElementById('hold_usr_input').value;
-                only_settings = false;
-            }
-            if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
-                document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
-        }
-        if(barcode == undefined || barcode == '') {
-            document.getElementById('patron_name').innerHTML = '';
-            // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
-            document.getElementById('place_hold_submit').disabled = true;
-            document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
-            cur_hold_barcode = null;
-            return;
+    if (!window.xulG) return;
+
+    var cur_hold_barcode = undefined;
+    var barcode = isload;
+    if(!barcode || barcode === true) barcode = document.getElementById('staff_barcode').value;
+    var only_settings = true;
+    if(!document.getElementById('hold_usr_is_requestor').checked) {
+        if(!isload) {
+            barcode = document.getElementById('hold_usr_input').value;
+            only_settings = false;
         }
-        if(barcode == cur_hold_barcode)
-            return;
-        // No submitting until we think the barcode is valid
+        if(barcode && barcode != '' && !document.getElementById('hold_usr_is_requestor_not').checked)
+            document.getElementById('hold_usr_is_requestor_not').checked = 'checked';
+    }
+    if(barcode == undefined || barcode == '') {
+        document.getElementById('patron_name').innerHTML = '';
+        // No submitting on empty barcode, but empty barcode doesn't really count as "not found" either
         document.getElementById('place_hold_submit').disabled = true;
+        document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+        cur_hold_barcode = null;
+        return;
+    }
+    if(barcode == cur_hold_barcode)
+        return;
+    // No submitting until we think the barcode is valid
+    document.getElementById('place_hold_submit').disabled = true;
+
+    if (window.IAMBROWSER) {
+        // Browser client operates asynchronously
+        if (!xulG.get_barcode_and_settings_async) return;
+        xulG.get_barcode_and_settings_async(barcode, only_settings)
+        .then(
+            function(load_info) { // load succeeded
+                staff_hold_usr_barcode_changed2(
+                    isload, only_settings, barcode, cur_hold_barcode, load_info);
+            },
+            function() { 
+                // load failed (rejected).  Call staff_hold_usr_barcode_changed2
+                // anyway, since it handles clearing the form
+                staff_hold_usr_barcode_changed2(
+                    isload, only_settings, barcode, cur_hold_barcode, false);
+            }
+        )
+    } else {
+        // XUL version is synchronous
+        if (!xulG.get_barcode_and_settings) return;
         var load_info = xulG.get_barcode_and_settings(window, barcode, only_settings);
-        if(load_info == false || load_info == undefined) {
-            document.getElementById('patron_name').innerHTML = '';
-            document.getElementById("patron_usr_barcode_not_found").style.display = '';
-            cur_hold_barcode = null;
-            return;
-        }
-        cur_hold_barcode = load_info.barcode;
-        if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
-        if(load_info.settings['opac.default_pickup_location'])
-            document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
-        if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
-        if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
-        if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
-        if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
-            var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
-            var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
-            var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
-            var update_elements = document.getElementsByName('email_notify');
-            for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
-            update_elements = document.getElementsByName('phone_notify_checkbox');
-            for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
-            update_elements = document.getElementsByName('sms_notify_checkbox');
-            for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
-        }
-        update_elements = document.getElementsByName('phone_notify');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
-        update_elements = document.getElementsByName('sms_notify');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
-        update_elements = document.getElementsByName('sms_carrier');
-        for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
-        update_elements = document.getElementsByName('email_notify');
-        for(var i in update_elements) {
-            update_elements[i].disabled = (load_info.user_email ? false : true);
-            if(update_elements[i].disabled) update_elements[i].checked = false;
-        }
-        update_elements = document.getElementsByName('email_address');
-        for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
-        if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
-            document.getElementById('patron_name').innerHTML = load_info.patron_name;
-            document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
-        }
-        // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
-
-        // update the advanced hold options link to propagate the patron
-        // barcode if clicked.  This is needed when the patron barcode
-        // is manually entered (i.e. the staff client does not provide one).
-        var adv_link = document.getElementById('advanced_hold_link');
-        if (adv_link) { // not present on MR hold pages
-            var href = adv_link.getAttribute('href').replace(
-                /;usr_barcode=[^;\&]+|$/, 
-                ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
-            adv_link.setAttribute('href', href);
-        }
+        staff_hold_usr_barcode_changed2(isload, only_settings, barcode, cur_hold_barcode, load_info);
+    }
+}
+
+function staff_hold_usr_barcode_changed2(
+    isload, only_settings, barcode, cur_hold_barcode, load_info) {
+
+    if(load_info == false || load_info == undefined) {
+        document.getElementById('patron_name').innerHTML = '';
+        document.getElementById("patron_usr_barcode_not_found").style.display = '';
+        cur_hold_barcode = null;
+        return;
+    }
+    cur_hold_barcode = load_info.barcode;
+    if(!only_settings || (isload && isload !== true)) document.getElementById('hold_usr_input').value = load_info.barcode; // Safe at this point as we already set cur_hold_barcode
+    if(load_info.settings['opac.default_pickup_location'])
+        document.getElementById('pickup_lib').value = load_info.settings['opac.default_pickup_location'];
+    if(!load_info.settings['opac.default_phone']) load_info.settings['opac.default_phone'] = '';
+    if(!load_info.settings['opac.default_sms_notify']) load_info.settings['opac.default_sms_notify'] = '';
+    if(!load_info.settings['opac.default_sms_carrier']) load_info.settings['opac.default_sms_carrier'] = '';
+    if(load_info.settings['opac.hold_notify'] || load_info.settings['opac.hold_notify'] === '') {
+        var email = load_info.settings['opac.hold_notify'].indexOf('email') > -1;
+        var phone = load_info.settings['opac.hold_notify'].indexOf('phone') > -1;
+        var sms = load_info.settings['opac.hold_notify'].indexOf('sms') > -1;
+        var update_elements = document.getElementsByName('email_notify');
+        for(var i in update_elements) update_elements[i].checked = (email ? 'checked' : '');
+        update_elements = document.getElementsByName('phone_notify_checkbox');
+        for(var i in update_elements) update_elements[i].checked = (phone ? 'checked' : '');
+        update_elements = document.getElementsByName('sms_notify_checkbox');
+        for(var i in update_elements) update_elements[i].checked = (sms ? 'checked' : '');
+    }
+    update_elements = document.getElementsByName('phone_notify');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_phone'];
+    update_elements = document.getElementsByName('sms_notify');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_notify'];
+    update_elements = document.getElementsByName('sms_carrier');
+    for(var i in update_elements) update_elements[i].value = load_info.settings['opac.default_sms_carrier'];
+    update_elements = document.getElementsByName('email_notify');
+    for(var i in update_elements) {
+        update_elements[i].disabled = (load_info.user_email ? false : true);
+        if(update_elements[i].disabled) update_elements[i].checked = false;
+    }
+    update_elements = document.getElementsByName('email_address');
+    for(var i in update_elements) update_elements[i].textContent = load_info.user_email;
+    if(!document.getElementById('hold_usr_is_requestor').checked && document.getElementById('hold_usr_input').value) {
+        document.getElementById('patron_name').innerHTML = load_info.patron_name;
+        document.getElementById("patron_usr_barcode_not_found").style.display = 'none';
+    }
+    // Ok, now we can allow submitting again, unless this is a "true" load, in which case we likely have a blank barcode box active
 
-        if (isload !== true)
-            document.getElementById('place_hold_submit').disabled = false;
+    // update the advanced hold options link to propagate the patron
+    // barcode if clicked.  This is needed when the patron barcode
+    // is manually entered (i.e. the staff client does not provide one).
+    var adv_link = document.getElementById('advanced_hold_link');
+    if (adv_link) { // not present on MR hold pages
+        var href = adv_link.getAttribute('href').replace(
+            /;usr_barcode=[^;\&]+|$/, 
+            ';usr_barcode=' + encodeURIComponent(cur_hold_barcode));
+        adv_link.setAttribute('href', href);
     }
+
+    if (isload !== true)
+        document.getElementById('place_hold_submit').disabled = false;
 }
 window.onload = function() {
     // record details page events
-    var rec = location.href.match(/\/opac\/record\/(\d+)/);
-    if(rec && rec[1]) { 
-        runEvt('rdetail', 'recordRetrieved', rec[1]); 
-        runEvt('rdetail', 'MFHDDrawn');
-    }
-    if(location.href.match(/place_hold/)) {
-        // patron barcode may come from XUL or a CGI param
-        var patron_barcode = xulG.patron_barcode ||
-            document.getElementById('hold_usr_input').value;
-        if(patron_barcode) {
-            staff_hold_usr_barcode_changed(patron_barcode);
-        } else {
-            staff_hold_usr_barcode_changed(true);
+
+    setTimeout(function() {
+        var rec = location.href.match(/\/opac\/record\/(\d+)/);
+        if(rec && rec[1]) { 
+            runEvt('rdetail', 'recordRetrieved', rec[1]); 
+            runEvt('rdetail', 'MFHDDrawn');
         }
-    }
+        if(location.href.match(/place_hold/)) {
+            // patron barcode may come from XUL or a CGI param
+            var patron_barcode = xulG.patron_barcode ||
+                document.getElementById('hold_usr_input').value;
+            if(patron_barcode) {
+                staff_hold_usr_barcode_changed(patron_barcode);
+            } else {
+                staff_hold_usr_barcode_changed(true);
+            }
+        }
+    });
 }
 
 function rdetail_next_prev_actions(index, count, prev, next, start, end, results) {
@@ -167,6 +204,8 @@ function rdetail_next_prev_actions(index, count, prev, next, start, end, results
     ol = window.onload;
     window.onload = function() {
         if(ol) ol(); 
-        runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count)); 
+        setTimeout(function() {
+            runEvt('rdetail', 'nextPrevDrawn', Number(index), Number(count)); 
+        });
     };
 }
diff --git a/Open-ILS/web/js/ui/default/staff/Gruntfile.js b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
new file mode 100644 (file)
index 0000000..d55d71f
--- /dev/null
@@ -0,0 +1,164 @@
+module.exports = function(grunt) {
+
+  // Project configuration.
+  var config = { 
+    pkg: grunt.file.readJSON('package.json'),
+
+    // copy the files we care about from bower-fetched dependencies
+    // into our build directory
+    copy: {
+
+      js : {
+        files: [{ 
+          dest: 'build/js/', 
+          flatten: true,
+          filter: 'isFile',
+          expand : true,
+          src: [
+            'bower_components/angular/angular.min.js',
+            'bower_components/angular/angular.min.js.map',
+            'bower_components/angular-route/angular-route.min.js',
+            'bower_components/angular-route/angular-route.min.js.map',
+            'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js',
+            'bower_components/angular-hotkeys/build/hotkeys.min.js',
+          ]
+        }]
+      },
+
+      css : {
+        files : [{
+          dest : 'build/css/',
+          flatten : true,
+          filter : 'isFile',
+          expand : true,
+          src : [
+            'bower_components/angular-hotkeys/build/hotkeys.min.css',
+            'bower_components/bootstrap/dist/css/bootstrap.min.css' 
+          ]
+        }]
+      },
+
+      fonts : {
+        files : [{
+          dest : 'build/fonts/',
+          flatten : true,
+          filter : 'isFile',
+          expand : true,
+          src : [
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.eot',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.svg',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf',
+            'bower_components/bootstrap/dist/fonts/glyphicons-halflings-regular.woff'
+          ]
+        }]
+      }
+    },
+
+    // combine our CSS deps
+    // note: minification also supported, but not required (yet).
+    cssmin: {
+      combine: {
+        files: {
+          'build/css/evergreen-staff-client-deps.<%= pkg.version %>.min.css' : [
+            'build/css/hotkeys.min.css',
+            'build/css/bootstrap.min.css'
+          ]
+        }
+      }
+    },
+
+    // concatenation + minification
+    uglify: {
+      options: {
+        banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n'
+      },
+      build: {
+        src: [
+            // These are concatenated in order in the final build file.
+            // The order is important.
+            'build/js/angular.min.js',
+            'build/js/angular-route.min.js',
+            'build/js/ui-bootstrap-tpls.min.js',
+            'build/js/hotkeys.min.js',
+            // NOTE: OpenSRF must be installed
+            '/openils/lib/javascript/JSON_v1.js',
+            '/openils/lib/javascript/opensrf.js',
+            '/openils/lib/javascript/opensrf_ws.js',
+            'services/core.js',
+            'services/strings.js',
+            'services/idl.js',
+            'services/event.js',
+            'services/net.js',
+            'services/auth.js',
+            'services/pcrud.js',
+            'services/env.js',
+            'services/org.js',
+            'services/startup.js',
+            'services/hatch.js',
+            'services/print.js',
+            'services/coresvc.js',
+            'services/navbar.js',
+            'services/statusbar.js',
+            'services/ui.js',
+        ],
+        dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js'
+      }
+    },
+
+    // bare concat operation; useful for testing concat w/o minification
+    // to more easily detect if concat order is incorrect
+    concat: {
+      options: {
+       separator: ';',
+      }
+    },
+
+    exec : {
+
+      // Generate test/data/IDL2js.js for unit tests.
+      // note: the output of this script is *not* part of the final build.
+      idl2js : {
+        command : 'cd test/data && perl idl2js.pl',
+      },
+
+      // Remove the unit test IDL2js.js file.  We don't need it after testing
+      rmidl2js : {
+        command : 'rm test/data/IDL2js.js',
+      }
+    },
+
+    // unit tests configuration
+    karma : {
+      unit: {
+        configFile: 'test/karma.conf.js',
+        //background: true  // for now, visually babysit unit tests
+      }
+    }
+  };
+
+  // tell concat about our uglify build options (instead of repeating them)
+  config.concat.build = config.uglify.build;
+
+  // apply our configuration
+  grunt.initConfig(config);
+
+  // Load our modules
+  grunt.loadNpmTasks('grunt-contrib-uglify');
+  grunt.loadNpmTasks('grunt-contrib-concat');
+  grunt.loadNpmTasks('grunt-contrib-copy');
+  grunt.loadNpmTasks('grunt-contrib-cssmin');
+  grunt.loadNpmTasks('grunt-karma');
+  grunt.loadNpmTasks('grunt-exec');
+
+  // note: "grunt concat" is not requried 
+  grunt.registerTask('build', ['copy', 'cssmin', 'uglify']);
+
+  // test only, no minification
+  grunt.registerTask('test', ['copy', 'exec:idl2js', 'karma:unit', 'exec:rmidl2js']);
+
+  // note: "grunt concat" is not requried 
+  grunt.registerTask('all', ['test', 'cssmin', 'uglify']);
+
+};
+
+// vim: ts=2:sw=2:softtabstop=2
diff --git a/Open-ILS/web/js/ui/default/staff/README.install b/Open-ILS/web/js/ui/default/staff/README.install
new file mode 100644 (file)
index 0000000..62668c1
--- /dev/null
@@ -0,0 +1,93 @@
+= Building, Testing, Packaging the Browser Client =
+:Author:    Bill Erickson
+:Email:     berick@esilibrary.com
+:Date:      2014-05-07
+
+== Prerequisites ==
+
+ * http://bower.io/[Bower] 
+  ** Dependency retrieval
+ * http://jasmine.github.io/[Jasmine] 
+  ** Headless unit tests runner
+ * http://gruntjs.com/[Grunt]
+  ** Coordinating the build
+  ** Concatenation + minification of JS and CSS
+
+These are all Node.js plugins, so start by installing Node.js
+
+=== Install Node.js ===
+
+Node.js does not have have Debian Wheezy build target.  For now, I've opted
+to install from source.  For more, see also 
+https://github.com/joyent/node/wiki/installation[Node.js Installation]
+
+[source,sh]
+------------------------------------------------------------------------------
+% git clone https://github.com/joyent/node.git
+% cd node
+% git checkout -b v0.10.28 v0.10.28
+
+# set -j to number of CPU cores + 1
+% ./configure && make -j5 && sudo make install
+
+# update packages
+% sudo npm update
+------------------------------------------------------------------------------
+
+=== Install Grunt CLI ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g grunt-cli
+------------------------------------------------------------------------------
+
+=== Install Bower ===
+
+[source,sh]
+------------------------------------------------------------------------------
+% sudo npm install -g bower
+------------------------------------------------------------------------------
+
+== Building, Testing, Minification == 
+
+The remaining steps all take place within the staff JS web root:
+
+[source,sh]
+------------------------------------------------------------------------------
+% cd $EVERGREEN_ROOT/Open-ILS/web/js/ui/default/staff/
+------------------------------------------------------------------------------
+
+=== Install Project-local Dependencies ===
+
+npm inspects the 'package.json' file for dependencies and fetches them
+from the Node package network.
+
+[source,sh]
+------------------------------------------------------------------------------
+% npm install   # fetch Grunt dependencies
+% bower install # fetch JS dependencies
+------------------------------------------------------------------------------
+
+=== Running the Build Scripts ===
+
+[source,sh]
+------------------------------------------------------------------------------
+
+# build, run tests
+% grunt test
+
+# build, concat+minify
+% grunt uglify
+
+# build, run tests, concat+minify 
+% grunt all
+------------------------------------------------------------------------------
+
+== TODO ==
+
+ * Minification of app-specific JS files
+ * Integrate this into the Evergreen Makefile test and install targets
+   ** Avoid installing test, node_modules, etc. into the web dir.
+ * Support fetching JS deps (angularjs, etc.) via direct retrieval for 
+   installation without test + concat + minify (i.e. w/o requiring Node.js)?
+
diff --git a/Open-ILS/web/js/ui/default/staff/admin/user_perms.js b/Open-ILS/web/js/ui/default/staff/admin/user_perms.js
new file mode 100644 (file)
index 0000000..460e8d1
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egUserPermsEditor',
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider', 
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); 
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/admin/user_perms', {
+        templateUrl: './admin/t_user_perms_lookup',
+        controller: 'UserPermsLookupCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/user_perms/:user_id', {
+        templateUrl: 'user-perms-template',
+        controller: 'UserPermsCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : 'user-perms-template',
+        controller: 'UserPermsCtrl',
+        resolve : resolver
+    });
+}])
+
+.controller('UserPermsLookupCtrl',
+       ['$scope','$window','$location','egCore',
+function($scope , $window , $location , egCore) {
+    
+    $scope.selectMe = true; // focus text input
+    $scope.args = {};
+
+    // find the user by barcode, the jump to the editor
+    $scope.submitBarcode = function(args) {
+
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        $scope.selectMe = false;
+
+        // lookup barcode
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'actor', args.barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = args.barcode;
+                $scope.selectMe = true;
+                return;
+            }
+
+            // see if an opt-in request is needed
+            user_id = resp[0].id;
+            $location.path($location.path() + '/' + user_id);
+        });
+    }
+
+}])
+
+.controller('UserPermsCtrl',
+       ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+    var user_id = $routeParams.user_id;
+
+    var url = $location.absUrl().replace(
+        /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+    url += '?usr=' + encodeURIComponent(user_id);
+
+    // user_edit does not load the session via cookie.  It uses URL 
+    // params or xulG instead.  Pass via xulG.
+    $scope.funcs = {
+        ses : egCore.auth.token(),
+        on_patron_save : function() {
+            $scope.funcs.reload();
+        }
+    }
+
+    $scope.user_perms_url = url;
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js b/Open-ILS/web/js/ui/default/staff/admin/workstation/app.js
new file mode 100644 (file)
index 0000000..51a4240
--- /dev/null
@@ -0,0 +1,557 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egWorkstationAdmin', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider', 
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); 
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/admin/workstation/print/config', {
+        templateUrl: './admin/workstation/t_print_config',
+        controller: 'PrintConfigCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/workstation/print/templates', {
+        templateUrl: './admin/workstation/t_print_templates',
+        controller: 'PrintTemplatesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/admin/workstation/stored_prefs', {
+        templateUrl: './admin/workstation/t_stored_prefs',
+        controller: 'StoredPrefsCtrl',
+        resolve : resolver
+    });
+
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './admin/workstation/t_splash',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+}])
+
+.controller('SplashCtrl',
+       ['$scope','$window','$location','egCore','egConfirmDialog',
+function($scope , $window , $location , egCore , egConfirmDialog) {
+
+    var allWorkstations = [];
+    var permMap = {};
+    $scope.contextOrg = egCore.org.get(egCore.auth.user().ws_ou());
+
+    egCore.perm.hasPermAt('REGISTER_WORKSTATION', true)
+    .then(function(orgList) { 
+
+        // hide orgs in the context org selector where this login
+        // does not have the reg_ws perm
+        $scope.wsOrgHidden = function(id) {
+            return orgList.indexOf(id) == -1;
+        }
+        $scope.userHasRegPerm = 
+            orgList.indexOf($scope.contextOrg.id()) > -1;
+    });
+
+    // fetch the stored WS info
+    egCore.hatch.getItem('eg.workstation.all')
+    .then(function(all) {
+        allWorkstations = all || [];
+        $scope.workstations = 
+            allWorkstations.map(function(w) { return w.name });
+        return egCore.hatch.getItem('eg.workstation.default');
+    })
+    .then(function(def) { 
+        $scope.defaultWS = def;
+        $scope.activeWS = $scope.selectedWS = egCore.auth.workstation() || def;
+    });
+
+    $scope.getWSLabel = function(ws) {
+        return ws == $scope.defaultWS ? 
+            egCore.strings.$replace(egCore.strings.DEFAULT_WS_LABEL, {ws:ws}) : ws;
+    }
+
+    $scope.setDefaultWS = function() {
+        egCore.hatch.setItem(
+            'eg.workstation.default', $scope.selectedWS)
+        .then(function() { $scope.defaultWS = $scope.selectedWS });
+    }
+
+    // redirect the user to the login page using the current
+    // workstation as the workstation URL param
+    $scope.useWS = function() {
+        $window.location.href = $location
+            .path('/login')
+            .search({ws : $scope.selectedWS})
+            .absUrl();
+    }
+
+    $scope.registerWS = function() {
+        register_workstation(
+            $scope.newWSName,
+            $scope.contextOrg.shortname() + '-' + $scope.newWSName,
+            $scope.contextOrg.id()
+        );
+    }
+
+    function register_workstation(base_name, name, org_id, override) {
+
+        var method = 'open-ils.actor.workstation.register';
+        if (override) method += '.override';
+
+        egCore.net.request(
+            'open-ils.actor', method, egCore.auth.token(), name, org_id)
+
+        .then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                console.log('register returned ' + evt.toString());
+
+                if (evt.textcode == 'WORKSTATION_NAME_EXISTS' && !override) {
+                    egConfirmDialog.open(
+                        egCore.strings.WS_EXISTS, base_name, {  
+                            ok : function() {
+                                register_workstation(base_name, name, org_id, true);
+                            },
+                            cancel : function() {} 
+                        }
+                    );
+
+                } else {
+                    // TODO: provide permission error display
+                    alert(evt.toString());
+                }
+            } else if (resp) {
+                $scope.workstations.push(name);
+
+                allWorkstations.push({   
+                    id : resp,
+                    name : name,
+                    owning_lib : org_id
+                });
+
+                egCore.hatch.setItem(
+                    'eg.workstation.all', allWorkstations)
+                .then(function() {
+                    if (allWorkstations.length == 1) {
+                        // first one registerd, also mark it as the default
+                        $scope.selectedWS = name;
+                        $scope.setDefaultWS();
+                    }
+                });
+            }
+        });
+    }
+
+    $scope.wsOrgChanged = function(org) {
+        $scope.contextOrg = org;
+    }
+
+    // ---------------------
+    // Hatch Configs
+    $scope.hatchURL = egCore.hatch.hatchURL();
+    $scope.hatchRequired = 
+        egCore.hatch.getLocalItem('eg.hatch.required');
+
+    $scope.updateHatchRequired = function() {
+        egCore.hatch.setLocalItem(
+            'eg.hatch.required', $scope.hatchRequired);
+    }
+
+    $scope.updateHatchURL = function() {
+        egCore.hatch.setLocalItem(
+            'eg.hatch.url', $scope.hatchURL);
+    }
+}])
+
+.controller('PrintConfigCtrl',
+       ['$scope','egCore',
+function($scope , egCore) {
+    console.log('PrintConfigCtrl');
+
+    $scope.actionPending = false;
+    $scope.isTestView = false;
+
+    $scope.setContext = function(ctx) { 
+        $scope.context = ctx; 
+        $scope.isTestView = false;
+        $scope.actionPending = false;
+    }
+    $scope.setContext('default');
+
+    $scope.getPrinterByAttr = function(attr, value) {
+        var printer;
+        angular.forEach($scope.printers, function(p) {
+            if (p[attr] == value) printer = p;
+        });
+        return printer;
+    }
+
+    $scope.currentPrinter = function() {
+        if ($scope.printConfig && $scope.printConfig[$scope.context]) {
+            return $scope.getPrinterByAttr(
+                'name', $scope.printConfig[$scope.context].printer
+            );
+        }
+    }
+
+    // fetch info on all remote printers
+    egCore.hatch.getPrinters()
+    .then(function(printers) { 
+        $scope.printers = printers;
+        $scope.defaultPrinter = 
+            $scope.getPrinterByAttr('is-default', true);
+    })
+    .then(function() { return egCore.hatch.getPrintConfig() })
+    .then(function(config) {
+        $scope.printConfig = config;
+
+        var pname = '';
+        if ($scope.defaultPrinter) {
+            pname = $scope.defaultPrinter.name;
+
+        } else if ($scope.printers.length == 1) {
+            // if the OS does not report a default printer, but only
+            // one printer is available, treat it as the default.
+            pname = $scope.printers[0].name;
+        }
+
+        // apply the default printer to every context which has
+        // no printer configured.
+        angular.forEach(
+            ['default','receipt','label','mail','offline'],
+            function(ctx) {
+                if (!$scope.printConfig[ctx]) {
+                    $scope.printConfig[ctx] = {
+                        context : ctx,
+                        printer : pname
+                    }
+                }
+            }
+        );
+    });
+
+    $scope.printerConfString = function() {
+        if ($scope.printConfigError) return $scope.printConfigError;
+        if (!$scope.printConfig) return;
+        if (!$scope.printConfig[$scope.context]) return;
+        return JSON.stringify(
+            $scope.printConfig[$scope.context], undefined, 2);
+    }
+
+    $scope.resetConfig = function() {
+        $scope.actionPending = true;
+        $scope.printConfigError = null;
+        $scope.printConfig[$scope.context] = {
+            context : $scope.context
+        }
+        
+        if ($scope.defaultPrinter) {
+            $scope.printConfig[$scope.context].printer = 
+                $scope.defaultPrinter.name;
+        }
+
+        egCore.hatch.setPrintConfig($scope.printConfig)
+        .finally(function() {$scope.actionPending = false});
+    }
+
+    $scope.configurePrinter = function() {
+        $scope.printConfigError = null;
+        $scope.actionPending = true;
+        egCore.hatch.configurePrinter(
+            $scope.context,
+            $scope.printConfig[$scope.context].printer
+        )
+        .then(
+            function(config) {$scope.printConfig = config},
+            function(error) {$scope.printConfigError = error}
+        )
+        .finally(function() {$scope.actionPending = false});
+    }
+
+    $scope.setPrinter = function(name) {
+        $scope.printConfig[$scope.context].printer = name;
+    }
+
+    // for testing
+    $scope.setContentType = function(type) { $scope.contentType = type }
+
+    $scope.testPrint = function(withDialog) {
+        if ($scope.contentType == 'text/plain') {
+            egCore.print.print({
+                context : $scope.context, 
+                content_type : $scope.contentType, 
+                content : $scope.textPrintContent,
+                show_dialog : withDialog
+            });
+        } else {
+            egCore.print.print({
+                context : $scope.context,
+                content_type : $scope.contentType, 
+                content : $scope.htmlPrintContent, 
+                scope : {
+                    value1 : 'Value One', 
+                    value2 : 'Value Two',
+                    date_value : '2015-02-04T14:04:34-0400'
+                },
+                show_dialog : withDialog
+            });
+        }
+    }
+
+    $scope.setContentType('text/plain');
+
+}])
+
+.controller('PrintTemplatesCtrl',
+       ['$scope','$q','egCore',
+function($scope , $q , egCore) {
+
+    $scope.print = {
+        template_name : 'bills_current',
+        template_output : ''
+    };
+
+    // print preview scope data
+    // TODO: consider moving the template-specific bits directly
+    // into the templates or storing template- specific script files
+    // alongside the templates.
+    // NOTE: A lot of this data can be shared across templates.
+    var seed_user = {
+        first_given_name : 'Slow',
+        second_given_name : 'Joe',
+        family_name : 'Jones',
+        card : {
+            barcode : '30393830393'
+        }
+    }
+    var seed_addr = {
+        street1 : '123 Apple Rd',
+        street2 : 'Suite B',
+        city : 'Anywhere',
+        state : 'XX',
+        country : 'US',
+        post_code : '12345'
+    }
+
+    var seed_record = {
+        title : 'Traveling Pants!!',
+        author : 'Jane Jones',
+        isbn : '1231312123'
+    };
+
+    var seed_copy = {
+        barcode : '33434322323'
+    }
+
+    var one_hold = {
+        behind_desk : 'f',
+        phone_notify : '111-222-3333',
+        sms_notify : '111-222-3333',
+        email_notify : 'user@example.org',
+        request_time : new Date().toISOString()
+    }
+
+
+    $scope.preview_scope = {
+        //bills
+        transactions : [
+            {
+                id : 1,
+                xact_start : new Date().toISOString(),
+                summary : {
+                    xact_type : 'circulation',
+                    last_billing_type : 'Overdue materials',
+                    total_owed : 1.50,
+                    last_payment_note : 'Test Note 1',
+                    total_paid : 0.50,
+                    balance_owed : 1.00
+                }
+            }, {
+                id : 2,
+                xact_start : new Date().toISOString(),
+                summary : {
+                    xact_type : 'circulation',
+                    last_billing_type : 'Overdue materials',
+                    total_owed : 2.50,
+                    last_payment_note : 'Test Note 2',
+                    total_paid : 0.50,
+                    balance_owed : 2.00
+                }
+            }
+        ],
+
+        circulations : [
+            {   
+                due_date : new Date().toISOString(), 
+                target_copy : seed_copy,
+                title : seed_record.title
+            },
+        ],
+
+        previous_balance : 8.45,
+        payment_total : 2.00,
+        payment_applied : 2.00,
+        new_balance : 6.45,
+        amount_voided : 0,
+        change_given : 0,
+        payment_type : 'cash_payment',
+        payment_note : 'Here is a payment note',
+        note : {
+            create_date : new Date().toISOString(), 
+            title : 'Test Note Title',
+            usr : seed_user,
+            value : 'This patron is super nice!'
+        },
+
+        transit : {
+            dest : {
+                name : 'Library X',
+                shortname : 'LX',
+                holds_address : seed_addr
+            },
+            target_copy : seed_copy
+        },
+        title : seed_record.title,
+        author : seed_record.author,
+        patron : egCore.idl.toHash(egCore.auth.user()),
+        address : seed_addr,
+        hold : one_hold,
+        holds : [
+            {hold : one_hold, title : 'Some Title 1', author : 'Some Author 1'},
+            {hold : one_hold, title : 'Some Title 2', author : 'Some Author 2'},
+            {hold : one_hold, title : 'Some Title 3', author : 'Some Author 3'}
+        ]
+    }
+
+    $scope.preview_scope.payments = [
+        {amount : 1.00, xact : $scope.preview_scope.transactions[0]}, 
+        {amount : 1.00, xact : $scope.preview_scope.transactions[1]}
+    ]
+    $scope.preview_scope.payments[0].xact.title = 'Hali Bote Azikaban de tao fan';
+    $scope.preview_scope.payments[0].xact.copy_barcode = '334343434';
+    $scope.preview_scope.payments[1].xact.title = seed_record.title;
+    $scope.preview_scope.payments[1].xact.copy_barcode = seed_copy.barcode;
+
+    // today, staff, current_location, etc.
+    egCore.print.fleshPrintScope($scope.preview_scope);
+
+    $scope.template_changed = function() {
+        $scope.print.load_failed = false;
+        egCore.print.getPrintTemplate($scope.print.template_name)
+        .then(
+            function(html) { 
+                $scope.print.template_content = html;
+                console.log('set template content');
+            },
+            function() {
+                $scope.print.template_content = '';
+                $scope.print.load_failed = true;
+            }
+        );
+    }
+
+    $scope.save_locally = function() {
+        egCore.hatch.storePrintTemplate(
+            $scope.print.template_name,
+            $scope.print.template_content
+        );
+    }
+
+    $scope.template_changed(); // load the default
+}])
+
+// 
+.directive('egPrintTemplateOutput', ['$compile',function($compile) {
+    return function(scope, element, attrs) {
+        scope.$watch(
+            function(scope) {
+                return scope.$eval(attrs.content);
+            },
+            function(value) {
+                // create an isolate scope and copy the print context
+                // data into the new scope.
+                // TODO: see also print security concerns in egHatch
+                var result = element.html(value);
+                var context = scope.$eval(attrs.context);
+                var print_scope = scope.$new(true);
+                angular.forEach(context, function(val, key) {
+                    print_scope[key] = val;
+                })
+                $compile(element.contents())(print_scope);
+            }
+        );
+    };
+}])
+
+.controller('StoredPrefsCtrl',
+       ['$scope','$q','egCore','egConfirmDialog',
+function($scope , $q , egCore , egConfirmDialog) {
+    console.log('StoredPrefsCtrl');
+
+    $scope.setContext = function(ctx) {
+        $scope.context = ctx;
+    }
+    $scope.setContext('local');
+
+    // grab the edit perm
+    $scope.userHasDeletePerm = false;
+    egCore.perm.hasPermHere('ADMIN_WORKSTATION')
+    .then(function(bool) { $scope.userHasDeletePerm = bool });
+
+    // fetch the keys
+
+    function refreshKeys() {
+        $scope.keys = {local : [], remote : []};
+
+        egCore.hatch.getRemoteKeys().then(
+            function(keys) { $scope.keys.remote = keys.sort() })
+    
+        // local calls are non-async
+        $scope.keys.local = egCore.hatch.getLocalKeys();
+    }
+    refreshKeys();
+
+    $scope.selectKey = function(key) {
+        $scope.currentKey = key;
+        $scope.currentKeyContent = null;
+
+        if ($scope.context == 'local') {
+            $scope.currentKeyContent = egCore.hatch.getLocalItem(key);
+        } else {
+            egCore.hatch.getRemoteItem(key)
+            .then(function(content) {
+                $scope.currentKeyContent = content
+            });
+        }
+    }
+
+    $scope.getCurrentKeyContent = function() {
+        return JSON.stringify($scope.currentKeyContent, null, 2);
+    }
+
+    $scope.removeKey = function(key) {
+        egConfirmDialog.open(
+            egCore.strings.PREFS_REMOVE_KEY_CONFIRM, '',
+            {   deleteKey : key,
+                ok : function() {
+                    if ($scope.context == 'local') {
+                        egCore.hatch.removeLocalItem(key);
+                        refreshKeys();
+                    } else {
+                        egCore.hatch.removeItem(key)
+                        .then(function() { refreshKeys() });
+                    }
+                },
+                cancel : function() {} // user canceled, nothing to do
+            }
+        );
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/app.js b/Open-ILS/web/js/ui/default/staff/app.js
new file mode 100644 (file)
index 0000000..5c155a9
--- /dev/null
@@ -0,0 +1,125 @@
+/**
+ * App to drive the base page. 
+ * Login Form
+ * Splash Page
+ */
+
+angular.module('egHome', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod'])
+
+.config(
+       ['$routeProvider','$locationProvider',
+function($routeProvider , $locationProvider) {
+    $locationProvider.html5Mode(true);
+
+    /**
+     * Route resolvers allow us to run async commands
+     * before the page controller is instantiated.
+     */
+    var resolver = {delay : ['egCore', 
+        function(egCore) {return egCore.startup.go()}]};
+
+    $routeProvider.when('/login', {
+        templateUrl: './t_login',
+        controller: 'LoginCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './t_splash',
+        controller : 'SplashCtrl',
+        resolve : resolver
+    });
+}])
+
+/**
+ * Login controller.  
+ * Reads the login form and submits the login request
+ */
+.controller('LoginCtrl', 
+    /* inject services into our controller.  Spelling them
+     * out like this allows the auto-magic injector to work
+     * even if the code has been minified */
+           ['$scope','$location','$window','egCore',
+    function($scope , $location , $window , egCore) {
+        $scope.focusMe = true;
+
+        // if the user is already logged in, jump to splash page
+        if (egCore.auth.user()) $location.path('/');
+
+        egCore.hatch.getItem('eg.workstation.all')
+        .then(function(all) {
+            if (all && all.length) {
+                $scope.workstations = all.map(function(a) { return a.name });
+
+                if (ws = $location.search().ws) {
+                    // user requested a workstation via URL
+                    var match = all.filter(
+                        function(w) {return ws == w.name} )[0];
+
+                    if (match) {
+                        // requested WS registered on this client
+                        $scope.args = {workstation : match.name};
+                    } else {
+                        // the requested WS is not registered on this client
+                        $scope.wsNotRegistered = true;
+                    }
+                } else {
+                    // no workstation requested; use the default
+                    egCore.hatch.getItem('eg.workstation.default')
+                    .then(function(ws) {
+                        $scope.args = {workstation : ws}
+                    });
+                }
+            } 
+        })
+
+        $scope.login = function(args) {
+            $scope.loginFailed = false;
+
+            if (!args) args = {}; // see FF note below
+
+            if (!args.username) {
+                /* 
+                 Issues with form autofill / auto-complete                          
+                 https://github.com/angular/angular.js/issues/1460                  
+                 http://timothy.userapp.io/post/63412334209/form-autocomplete-and-remember-password-with-angularjs
+                 For now, since FF will save the values, we should 
+                 honor them, even if it's hacky. */
+                args.username = document.getElementById("login-username").value;
+                args.password = document.getElementById("login-password").value;
+            }
+
+            if (! (args.username && args.password) ) return;
+
+            args.type = 'staff';
+            egCore.auth.login(args).then(
+
+                function() { 
+                    // after login, send the user back to the originally
+                    // requested page or, if none, the home page.
+                    // TODO: this is a little hinky because it causes 2 
+                    // redirects if no route_to is defined.  Improve.
+                    $window.location.href = 
+                        $location.search().route_to || 
+                        $location.path('/').absUrl()
+                },
+                function() {
+                    $scope.args.password = '';
+                    $scope.loginFailed = true;
+                    $scope.focusMe = true;
+                }
+            );
+        }
+    }
+])
+
+/**
+ * Splash page dynamic content.
+ */
+.controller('SplashCtrl', ['$scope',
+    function($scope) {
+        console.log('SplashCtrl');
+    }
+]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/bower.json b/Open-ILS/web/js/ui/default/staff/bower.json
new file mode 100644 (file)
index 0000000..8c2ed1f
--- /dev/null
@@ -0,0 +1,30 @@
+{
+  "name": "evergreen-staff-client",
+  "version": "0.0.1",
+  "authors": [
+    "Bill Erickson <berick@esilibrary.com>"
+  ],
+  "description": "Evergreen HTML Staff Client",
+  "keywords": [
+    "evergreen"
+  ],
+  "license": "GPL",
+  "homepage": "http://evergreen-ils.org",
+  "ignore": [
+    "**/.*",
+    "node_modules",
+    "bower_components",
+    "test",
+    "tests"
+  ],
+  "devDependencies": {
+    "bootstrap": "~3.1.1",
+    "angular": "~1.2.16",
+    "angular-route": "~1.2.16",
+    "angular-mocks": "~1.2.16",
+    "angular-bootstrap": "~0.11.0"
+  },
+  "dependencies": {
+    "angular-hotkeys": "chieffancypants/angular-hotkeys#~1.2.0"
+  }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js
new file mode 100644 (file)
index 0000000..de59a45
--- /dev/null
@@ -0,0 +1,535 @@
+/**
+ * Catalog Record 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('egCatRecordBuckets', 
+    ['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/record/search/:id', {
+        templateUrl: './cat/bucket/record/t_search',
+        controller: 'SearchCtrl',
+        resolve : resolver
+    });
+    
+    $routeProvider.when('/cat/bucket/record/search', {
+        templateUrl: './cat/bucket/record/t_search',
+        controller: 'SearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/pending/:id', {
+        templateUrl: './cat/bucket/record/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/pending', {
+        templateUrl: './cat/bucket/record/t_pending',
+        controller: 'PendingCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/view/:id', {
+        templateUrl: './cat/bucket/record/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/bucket/record/view', {
+        templateUrl: './cat/bucket/record/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/cat/bucket/record/view'});
+})
+
+/**
+ * bucketSvc allows us to communicate between the search,
+ * 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
+        queryString : '', // last run query
+        queryRecords : [], // last run query results
+        currentBucket : null, // currently viewed bucket
+
+        // per-page list collections
+        searchList  : [],
+        pendingList : [],
+        viewList  : [],
+
+        // fetches all staff/biblio 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(), 
+                'biblio', 'staff_client'
+            ).then(function(buckets) { self.allBuckets = buckets });
+        },
+
+        createBucket : function(name, desc) {
+            var deferred = $q.defer();
+            var bucket = new egCore.idl.cbreb();
+            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(), 'biblio', 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(), 'biblio', 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(), 'biblio', 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.detachRecord = function(itemId) {
+        var deferred = $q.defer();
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.item.delete',
+            egCore.auth.token(), 'biblio', 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(), 'biblio', 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('RecordBucketCtrl',
+       ['$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/record/' + 
+                $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.cbrebi();
+                item.bucket(bucketSvc.currentBucket.id());
+                item.target_biblio_record_entry(rec.id);
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.container.item.create', 
+                    egCore.auth.token(), 'biblio', 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/record/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/record/' + $scope.tab + '/' + id);
+                }
+            );
+        });
+    }
+
+    $scope.openEditBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/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/record/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/record/view');
+            });
+        });
+    }
+
+    // retrieves the requested bucket by ID
+    $scope.openSharedBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/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);
+        });
+    }
+
+    // opens the record export dialog
+    $scope.openExportBucketDialog = function() {
+        $modal.open({
+            templateUrl: './cat/bucket/record/t_bucket_export',
+            controller : 
+                ['$scope', '$modalInstance', function($scope, $modalInstance) {
+                $scope.args = {format : 'XML', encoding : 'UTF-8'}; // defaults
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result.then(function (args) {
+            if (!args) return;
+            args.containerid = bucketSvc.currentBucket.id();
+
+            var url = '/exporter?containerid=' + args.containerid + 
+                '&format=' + args.format + '&encoding=' + args.encoding;
+
+            if (args.holdings) url += '&holdings=1';
+
+            // TODO: improve auth cookie handling so this isn't necessary.
+            // today the cookie path is too specific (/eg/staff) for non-staff
+            // UIs to access it.  See services/auth.js
+            url += '&ses=' + egCore.auth.token(); 
+
+            $timeout(function() { $window.open(url) });
+        });
+    }
+}])
+
+.controller('SearchCtrl',
+       ['$scope','$routeParams','egCore','bucketSvc',
+function($scope,  $routeParams,  egCore , bucketSvc) {
+
+    $scope.setTab('search');
+    $scope.focusMe = true;
+    var idQueryHash = {};
+
+    function generateQuery() {
+        if (bucketSvc.queryRecords.length)
+            return {id : bucketSvc.queryRecords};
+        else 
+            return null;
+    }
+
+    $scope.gridControls = {
+        setQuery : function() {return generateQuery()},
+        setSort : function() {return ['id']}
+    }
+
+    // add selected items directly to the pending list
+    $scope.addToPending = function(recs) {
+        angular.forEach(recs, function(rec) {
+            if (bucketSvc.pendingList.filter( // remove dupes
+                function(r) {return r.id == rec.id}).length) return;
+            bucketSvc.pendingList.push(rec);
+        });
+    }
+
+    $scope.search = function() {
+        $scope.searchList = [];
+        $scope.searchInProgress = true;
+        bucketSvc.queryRecords = [];
+
+        egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.multiclass.query', {   
+                limit : 500 // meh
+            }, bucketSvc.queryString, true
+        ).then(function(resp) {
+            bucketSvc.queryRecords = 
+                resp.ids.map(function(id){return id[0]});
+            $scope.gridControls.setQuery(generateQuery());
+        })['finally'](function() {
+            $scope.searchInProgress = false;
+        });
+    }
+
+    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);
+    }
+}])
+
+.controller('PendingCtrl',
+       ['$scope','$routeParams','bucketSvc','egGridDataProvider',
+function($scope,  $routeParams,  bucketSvc , egGridDataProvider) {
+    $scope.setTab('pending');
+
+    var provider = egGridDataProvider.instance({});
+    provider.get = function(offset, count) {
+        return provider.arrayNotifier(
+            bucketSvc.pendingList, offset, count);
+    }
+    $scope.gridDataProvider = provider;
+
+    $scope.resetPendingList = function() {
+        bucketSvc.pendingList = [];
+    }
+    
+
+    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);
+    }
+}])
+
+.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_biblio_record_entry()}
+                );
+                if (ids.length) {
+                    $scope.gridControls.setQuery({id : ids});
+                } else {
+                    $scope.gridControls.setQuery({});
+                }
+            }
+        );
+    }
+
+    $scope.detachRecords = function(records) {
+        var promises = [];
+        angular.forEach(records, function(rec) {
+            var item = bucketSvc.currentBucket.items().filter(
+                function(i) {
+                    return (i.target_biblio_record_entry() == rec.id)
+                }
+            );
+            if (item.length)
+                promises.push(bucketSvc.detachRecord(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 });
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
new file mode 100644 (file)
index 0000000..8c06ad5
--- /dev/null
@@ -0,0 +1,209 @@
+/**
+ * TPAC Frame App
+ *
+ * currently, this app doesn't use routes for each sub-ui, because 
+ * reloading the catalog each time is sloooow.  better so far to 
+ * swap out divs w/ ng-if / ng-show / ng-hide as needed.
+ *
+ */
+
+angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/cat/catalog/index', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    // create some catalog page-specific mappings
+    $routeProvider.when('/cat/catalog/record/:record_id', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    // create some catalog page-specific mappings
+    $routeProvider.when('/cat/catalog/record/:record_id/:record_tab', {
+        templateUrl: './cat/catalog/t_catalog',
+        controller: 'CatalogCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/cat/catalog/index'});
+})
+
+
+/**
+ * */
+.controller('CatalogCtrl',
+       ['$scope','$routeParams','$location','$q','egCore','egHolds',
+        'egGridDataProvider','egHoldGridActions',
+function($scope , $routeParams , $location , $q , egCore , egHolds, 
+         egGridDataProvider , egHoldGridActions) {
+
+    // set record ID on page load if available...
+    $scope.record_id = $routeParams.record_id;
+
+    // also set it when the iframe changes to a new record
+    $scope.handle_page = function(url) {
+
+        if (!url || url == 'about:blank') {
+            // nothing loaded.  If we already have a record ID, leave it.
+            return;
+        }
+
+        var match = url.match(/\/+opac\/+record\/+(\d+)/);
+        if (match) {
+            $scope.record_id = match[1];
+
+            // force the record_id to show up in the page.  
+            // not sure why a $digest isn't occuring here.
+            try { $scope.$apply() } catch(E) {}
+        } else {
+            delete $scope.record_id;
+        }
+    }
+
+    // xulG catalog handlers
+    $scope.handlers = { }
+
+    // ------------------------------------------------------------------
+    // Holds 
+    var provider = egGridDataProvider.instance({});
+    $scope.hold_grid_data_provider = provider;
+    $scope.grid_actions = egHoldGridActions;
+    $scope.hold_grid_controls = {};
+
+    var hold_ids = []; // current list of holds
+    function fetchHolds(offset, count) {
+        var ids = hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                return hold_data;
+            }
+        );
+    }
+
+    provider.get = function(offset, count) {
+        if ($scope.record_tab != 'holds') return $q.when();
+        var deferred = $q.defer();
+        hold_ids = []; // no caching ATM
+
+        // fetch the IDs
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.holds.retrieve_all_from_title',
+            egCore.auth.token(), $scope.record_id, 
+            {pickup_lib : $scope.pickup_ou.id()}
+        ).then(
+            function(hold_data) {
+                angular.forEach(hold_data, function(list, type) {
+                    hold_ids = hold_ids.concat(list);
+                });
+                fetchHolds(offset, count).then(
+                    deferred.resolve, null, deferred.notify);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $scope.detail_hold_id = h.hold.id();
+        }
+    }
+
+    $scope.list_view = function(items) {
+         $scope.detail_hold_id = null;
+    }
+
+    // refresh the list of record holds when the pickup lib is changed.
+    $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+    $scope.pickup_ou_changed = function(org) {
+        $scope.pickup_ou = org;
+        provider.refresh();
+    }
+
+    $scope.print_holds = function() {
+        var holds = [];
+        angular.forEach($scope.hold_grid_controls.allItems(), function(item) {
+            holds.push({
+                hold : egCore.idl.toHash(item.hold),
+                patron_last : item.patron_last,
+                patron_alias : item.patron_alias,
+                patron_barcode : item.patron_barcode,
+                copy : egCore.idl.toHash(item.copy),
+                volume : egCore.idl.toHash(item.volume),
+                title : item.mvr.title(),
+                author : item.mvr.author()
+            });
+        });
+
+        egCore.print.print({
+            context : 'receipt', 
+            template : 'holds_for_bib', 
+            scope : {holds : holds}
+        });
+    }
+
+    $scope.mark_hold_transfer_dest = function() {
+        egCore.hatch.setLocalItem(
+            'eg.circ.hold.title_transfer_target', $scope.record_id);
+    }
+
+    // UI presents this option as "all holds"
+    $scope.transfer_holds_to_marked = function() {
+        var hold_ids = $scope.hold_grid_controls.allItems().map(
+            function(hold_data) {return hold_data.hold.id()});
+        egHolds.transfer_to_marked_title(hold_ids);
+    }
+
+    // ------------------------------------------------------------------
+    // Initialize the selected tab
+
+    function init_cat_url() {
+        // Set the initial catalog URL.  This only happens once.
+        // The URL is otherwise generated through user navigation.
+        if ($scope.catalog_url) return; 
+
+        var url = $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+        // A record ID in the path indicates a request for the record-
+        // specific page.
+        if ($routeParams.record_id) {
+            url = url.replace(/advanced/, '/record/' + $scope.record_id);
+        }
+
+        $scope.catalog_url = url;
+    }
+
+    $scope.set_record_tab = function(tab) {
+        $scope.record_tab = tab;
+
+        switch(tab) {
+
+            case 'catalog':
+                init_cat_url();
+                break;
+
+            case 'holds':
+                $scope.detail_hold_record_id = $scope.record_id; 
+                // refresh the holds grid
+                provider.refresh();
+                break;
+        }
+    }
+
+    var tab = $routeParams.record_tab || 'catalog';
+    $scope.set_record_tab(tab);
+
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/app.js b/Open-ILS/web/js/ui/default/staff/cat/item/app.js
new file mode 100644 (file)
index 0000000..82ed923
--- /dev/null
@@ -0,0 +1,540 @@
+/**
+ * Item Display
+ */
+
+angular.module('egItemStatus', 
+    ['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()}};
+
+    // search page shows the list view by default
+    $routeProvider.when('/cat/item/search', {
+        templateUrl: './cat/item/t_list',
+        controller: 'ListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/item/:id', {
+        templateUrl: './cat/item/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/cat/item/:id/:tab', {
+        templateUrl: './cat/item/t_view',
+        controller: 'ViewCtrl',
+        resolve : resolver
+    });
+
+    // default page / bucket view
+    $routeProvider.otherwise({redirectTo : '/cat/item/search'});
+})
+
+.factory('itemSvc', 
+       ['egCore',
+function(egCore) {
+
+    var service = {
+        copies : [], // copy barcode search results
+        index : 0 // search grid index
+    };
+
+    service.flesh = {   
+        flesh : 3, 
+        flesh_fields : {
+            acp : ['call_number','location','status','location'],
+            acn : ['record','prefix','suffix'],
+            bre : ['simple_record','creator','editor']
+        },
+        select : { 
+            // avoid fleshing MARC on the bre
+            // note: don't add simple_record.. not sure why
+            bre : ['id','tcn_value','creator','editor'],
+        } 
+    }
+
+    // resolved with the last received copy
+    service.fetch = function(barcode, id, noListDupes) {
+        var promise;
+
+        if (barcode) {
+            promise = egCore.pcrud.search('acp', 
+                {barcode : barcode, deleted : 'f'}, service.flesh);
+        } else {
+            promise = egCore.pcrud.retrieve('acp', id, service.flesh);
+        }
+
+        var lastRes;
+        return promise.then(
+            function() {return lastRes},
+            null, // error
+
+            // notify reads the stream of copies, one at a time.
+            function(copy) {
+
+                var flatCopy;
+                if (noListDupes) {
+                    // use the existing copy if possible
+                    flatCopy = service.copies.filter(
+                        function(c) {return c.id == copy.id()})[0];
+                }
+
+                if (!flatCopy) {
+                    flatCopy = egCore.idl.toHash(copy, true);
+                    flatCopy.index = service.index++;
+                    service.copies.unshift(flatCopy);
+                }
+
+                return lastRes = {
+                    copy : copy, 
+                    index : flatCopy.index
+                }
+            }
+        );
+    }
+
+    return service;
+}])
+
+/**
+ * Search bar along the top of the page.
+ * Parent scope for list and detail views
+ */
+.controller('SearchCtrl', 
+       ['$scope','$location','egCore','egGridDataProvider','itemSvc',
+function($scope , $location , egCore , egGridDataProvider , itemSvc) {
+    $scope.args = {}; // search args
+
+    // sub-scopes (search / detail-view) apply their version 
+    // of retrieval function to $scope.context.search
+    // and display toggling via $scope.context.toggleDisplay
+    $scope.context = {
+        selectBarcode : true
+    };
+
+    $scope.toggleView = function($event) {
+        $scope.context.toggleDisplay();
+        $event.preventDefault(); // avoid form submission
+    }
+}])
+
+/**
+ * List view - grid stuff
+ */
+.controller('ListCtrl', 
+       ['$scope','$q','$location','$timeout','egCore','egGridDataProvider','itemSvc',
+function($scope , $q , $location , $timeout , egCore , egGridDataProvider , itemSvc) {
+    $scope.context.page = 'list';
+
+    /*
+    var provider = egGridDataProvider.instance();
+    provider.get = function(offset, count) {
+    }
+    */
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            //return provider.arrayNotifier(itemSvc.copies, offset, count);
+            return this.arrayNotifier(itemSvc.copies, offset, count);
+        }
+    });
+
+    // If a copy was just displayed in the detail view, ensure it's
+    // focused in the list view.
+    var selected = false;
+    var copyGrid = $scope.gridControls = {
+        itemRetrieved : function(item) {
+            if (selected || !itemSvc.copy) return;
+            if (itemSvc.copy.id() == item.id) {
+                copyGrid.selectItems([item.index]);
+                selected = true;
+            }
+        }
+    };
+
+    $scope.$watch('barcodesFromFile', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.args.barcode = '';
+            var barcodes = [];
+
+            angular.forEach(newVal.split(/\n/), function(line) {
+                if (!line) return;
+                // scrub any trailing spaces or commas from the barcode
+                line = line.replace(/(.*?)($|\s.*|,.*)/,'$1');
+                barcodes.push(line);
+            });
+
+            itemSvc.fetch(barcodes).then(
+                function() {
+                    copyGrid.refresh();
+                    copyGrid.selectItems([itemSvc.copies[0].index]);
+                }
+            );
+        }
+    });
+
+    $scope.context.search = function(args) {
+        if (!args.barcode) return;
+        $scope.context.itemNotFound = false;
+        itemSvc.fetch(args.barcode).then(function(res) {
+            if (res) {
+                copyGrid.refresh();
+                copyGrid.selectItems([res.index]);
+                $scope.args.barcode = '';
+            } else {
+                $scope.context.itemNotFound = true;
+            }
+            $scope.context.selectBarcode = true;
+        })
+    }
+
+    $scope.context.toggleDisplay = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item) 
+            $location.path('/cat/item/' + item.id);
+    }
+
+    $scope.context.show_triggered_events = function() {
+        var item = copyGrid.selectedItems()[0];
+        if (item) 
+            $location.path('/cat/item/' + item.id + '/triggered_events');
+    }
+
+}])
+
+/**
+ * Detail view -- shows one copy
+ */
+.controller('ViewCtrl', 
+       ['$scope','$q','$location','$routeParams','egCore','itemSvc','egBilling',
+function($scope , $q , $location , $routeParams , egCore , itemSvc , egBilling) {
+    var copyId = $routeParams.id;
+    $scope.tab = $routeParams.tab || 'summary';
+    $scope.context.page = 'detail';
+
+    // use the cached record info
+    if (itemSvc.copy)
+        $scope.summaryRecord = itemSvc.copy.call_number().record();
+
+    function loadCopy(barcode) {
+        $scope.context.itemNotFound = false;
+
+        // Avoid re-fetching the same copy while jumping tabs.
+        // In addition to being quicker, this helps to avoid flickering
+        // of the top panel which is always visible in the detail view.
+        //
+        // 'barcode' represents the loading of a new item - refetch it
+        // regardless of whether it matches the current item.
+        if (!barcode && itemSvc.copy && itemSvc.copy.id() == copyId) {
+            $scope.copy = itemSvc.copy;
+            $scope.recordId = itemSvc.copy.call_number().record().id();
+            return $q.when();
+        }
+
+        delete $scope.copy;
+        delete itemSvc.copy;
+
+        var deferred = $q.defer();
+        itemSvc.fetch(barcode, copyId, true).then(function(res) {
+            $scope.context.selectBarcode = true;
+
+            if (!res) {
+                copyId = null;
+                $scope.context.itemNotFound = true;
+                deferred.reject(); // avoid propagation of data fetch calls
+                return;
+            }
+
+            var copy = res.copy;
+            itemSvc.copy = copy;
+
+
+            $scope.copy = copy;
+            $scope.recordId = copy.call_number().record().id();
+            $scope.summaryRecord = itemSvc.copy.call_number().record();
+            $scope.args.barcode = '';
+
+            // locally flesh org units
+            copy.circ_lib(egCore.org.get(copy.circ_lib()));
+            copy.call_number().owning_lib(
+                egCore.org.get(copy.call_number().owning_lib()));
+
+            var r = copy.call_number().record();
+            if (r.owner()) r.owner(egCore.org.get(r.owner())); 
+
+            // make boolean for auto-magic true/false display
+            angular.forEach(
+                ['ref','opac_visible','holdable','floating','circulate'],
+                function(field) { copy[field](Boolean(copy[field]() == 't')) }
+            );
+
+            // finally, if this is a different copy, redirect.
+            // Note that we flesh first since the copy we just
+            // fetched will be used after the redirect.
+            if (copyId && copyId != copy.id()) {
+                // if a new barcode is scanned in the detail view,
+                // update the url to match the ID of the new copy
+                $location.path('/cat/item/' + copy.id() + '/' + $scope.tab);
+                deferred.reject(); // avoid propagation of data fetch calls
+                return;
+            }
+            copyId = copy.id();
+
+            deferred.resolve();
+        });
+
+        return deferred.promise;
+    }
+
+    // if loadPrev load the two most recent circulations
+    function loadCurrentCirc(loadPrev) {
+        delete $scope.circ;
+        delete $scope.circ_summary;
+        delete $scope.prev_circ_summary;
+        if (!copyId) return;
+        
+        egCore.pcrud.search('circ', 
+            {target_copy : copyId},
+            {   flesh : 2,
+                flesh_fields : {
+                    circ : [
+                        'usr',
+                        'workstation',                                         
+                        'checkin_workstation',                                 
+                        'duration_rule',                                       
+                        'max_fine_rule',                                       
+                        'recurring_fine_rule'   
+                    ],
+                    au : ['card']
+                },
+                order_by : {circ : 'xact_start desc'}, 
+                limit :  1
+            }
+
+        ).then(null, null, function(circ) {
+            $scope.circ = circ;
+
+            // load the chain for this circ
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.renewal_chain.retrieve_by_circ.summary',
+                egCore.auth.token(), $scope.circ.id()
+            ).then(function(summary) {
+                $scope.circ_summary = summary.summary;
+            });
+
+            if (!loadPrev) return;
+
+            // load the chain for the previous circ, plus the user
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.prev_renewal_chain.retrieve_by_circ.summary',
+                egCore.auth.token(), $scope.circ.id()
+
+            ).then(null, null, function(summary) {
+                $scope.prev_circ_summary = summary.summary;
+
+                egCore.pcrud.retrieve('au', summary.usr,
+                    {flesh : 1, flesh_fields : {au : ['card']}})
+
+                .then(function(user) {
+                    $scope.prev_circ_usr = user;
+                });
+            });
+        });
+    }
+
+    var maxHistory;
+    function fetchMaxCircHistory() {
+        if (maxHistory) return $q.when(maxHistory);
+        return egCore.org.settings(
+            'circ.item_checkout_history.max')
+        .then(function(set) {
+            maxHistory = set['circ.item_checkout_history.max'] || 4;
+            return maxHistory;
+        });
+    }
+
+    $scope.addBilling = function(circ) {
+        egBilling.showBillDialog({
+            xact_id : circ.id(),
+            patron : circ.usr()
+        });
+    }
+
+    function loadCircHistory() {
+        $scope.circ_list = [];
+
+        var copy_org = 
+            itemSvc.copy.call_number().id() == -1 ?
+            itemSvc.copy.circ_lib().id() :
+            itemSvc.copy.call_number().owning_lib().id()
+
+        // there is an extra layer of permissibility over circ
+        // history views
+        egCore.perm.hasPermAt('VIEW_COPY_CHECKOUT_HISTORY', true)
+        .then(function(orgIds) {
+
+            if (orgIds.indexOf(copy_org) == -1) {
+                console.log('User is not allowed to view circ history');
+                return $q.when(0);
+            }
+
+            return fetchMaxCircHistory();
+
+        }).then(function(count) {
+
+            egCore.pcrud.search('circ', 
+                {target_copy : copyId},
+                {   flesh : 2,
+                    flesh_fields : {
+                        circ : [
+                            'usr',
+                            'workstation',                                         
+                            'checkin_workstation',                                 
+                            'recurring_fine_rule'   
+                        ],
+                        au : ['card']
+                    },
+                    order_by : {circ : 'xact_start desc'}, 
+                    limit :  count
+                }
+
+            ).then(null, null, function(circ) {
+
+                // flesh circ_lib locally
+                circ.circ_lib(egCore.org.get(circ.circ_lib()));
+                circ.checkin_lib(egCore.org.get(circ.checkin_lib()));
+                $scope.circ_list.push(circ);
+            });
+        });
+    }
+
+
+    function loadCircCounts() {
+
+        delete $scope.circ_counts;
+        $scope.total_circs = 0;
+        $scope.total_circs_this_year = 0;
+        $scope.total_circs_prev_year = 0;
+        if (!copyId) return;
+
+        egCore.pcrud.search('circbyyr', 
+            {copy : copyId}, null, {atomic : true})
+
+        .then(function(counts) {
+            $scope.circ_counts = counts;
+
+            angular.forEach(counts, function(count) {
+                $scope.total_circs += Number(count.count());
+            });
+
+            var this_year = counts.filter(function(c) {
+                return c.year() == new Date().getFullYear();
+            });
+
+            $scope.total_circs_this_year = 
+                this_year.length ? this_year[0].count() : 0;
+
+            var prev_year = counts.filter(function(c) {
+                return c.year() == new Date().getFullYear() - 1;
+            });
+
+            $scope.total_circs_prev_year = 
+                prev_year.length ? prev_year[0].count() : 0;
+
+        });
+    }
+
+    function loadHolds() {
+        delete $scope.hold;
+        if (!copyId) return;
+
+        egCore.pcrud.search('ahr', 
+            {   current_copy : copyId, 
+                cancel_time : null, 
+                fulfillment_time : null,
+                capture_time : {'<>' : null}
+            }, {
+                flesh : 2,
+                flesh_fields : {
+                    ahr : ['requestor', 'usr'],
+                    au  : ['card']
+                }
+            }
+        ).then(null, null, function(hold) {
+            $scope.hold = hold;
+            hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+            if (hold.current_shelf_lib()) {
+                hold.current_shelf_lib(
+                    egCore.org.get(hold.current_shelf_lib()));
+            }
+            hold.behind_desk(Boolean(hold.behind_desk() == 't'));
+        });
+    }
+
+    function loadTransits() {
+        delete $scope.transit;
+        delete $scope.hold_transit;
+        if (!copyId) return;
+
+        egCore.pcrud.search('atc', 
+            {target_copy : copyId},
+            {order_by : {atc : 'source_send_time DESC'}}
+
+        ).then(null, null, function(transit) {
+            $scope.transit = transit;
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+        })
+    }
+
+
+    // we don't need all data on all tabs, so fetch what's needed when needed.
+    function loadTabData() {
+        switch($scope.tab) {
+            case 'summary':
+                loadCurrentCirc();
+                loadCircCounts();
+                break;
+
+            case 'circs':
+                loadCurrentCirc(true);
+                break;
+
+            case 'circ_list':
+                loadCircHistory();
+                break;
+
+            case 'holds':
+                loadHolds()
+                loadTransits();
+                break;
+
+            case 'triggered_events':
+                var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+                url += '?copy_id=' + encodeURIComponent(copyId);
+                $scope.triggered_events_url = url;
+                $scope.funcs = {};
+        }
+    }
+
+    $scope.context.toggleDisplay = function() {
+        $location.path('/cat/item/search');
+    }
+
+    // handle the barcode scan box, which will replace our current copy
+    $scope.context.search = function(args) {
+        loadCopy(args.barcode).then(loadTabData);
+    }
+
+    $scope.context.show_triggered_events = function() {
+        $location.path('/cat/item/' + copyId + '/triggered_events');
+    }
+
+    loadCopy().then(loadTabData);
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js b/Open-ILS/web/js/ui/default/staff/cat/item/missing_pieces.js
new file mode 100644 (file)
index 0000000..06fad21
--- /dev/null
@@ -0,0 +1,123 @@
+angular.module('egItemMissingPieces',
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('MissingPiecesCtrl',
+       ['$scope','$q','$window','$location','egCore','egConfirmDialog','egAlertDialog','egCirc',
+function($scope , $q , $window , $location , egCore , egConfirmDialog , egAlertDialog , egCirc) {
+    
+    $scope.selectMe = true; // focus text input
+    $scope.args = {};
+
+    function get_copy(barcode) {
+
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'asset', barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return $q.reject();
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = barcode;
+                $scope.selectMe = true;
+                return $q.reject();
+            }
+
+            return egCore.pcrud.search('acp', {id : resp[0].id}, {
+                flesh : 3, 
+                flesh_fields : {
+                    acp : ['call_number'],
+                    acn : ['record'],
+                    bre : ['simple_record']
+                },
+                select : { 
+                    // avoid fleshing MARC on the bre
+                    // note: don't add simple_record.. not sure why
+                    bre : ['id']
+                } 
+            })
+        })
+    }
+
+    function mark_missing_pieces(copy) {
+
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_MARK_MISSING_TITLE,
+            egCore.strings.CONFIRM_MARK_MISSING_BODY, {
+            barcode : copy.barcode(), 
+            title : copy.call_number().record().simple_record().title()
+
+        }).result.then(function() {
+
+            // kick off mark missing
+            return egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.mark_item_missing_pieces',
+                egCore.auth.token(), copy.id()
+            )
+
+        }).then(function(resp) {
+            var evt = egCore.evt.parse(resp); // should always produce event
+
+            if (evt.textcode == 'ACTION_CIRCULATION_NOT_FOUND') {
+                return egAlertDialog.open(
+                    egCore.strings.CIRC_NOT_FOUND, {barcode : copy.barcode()});
+            }
+
+            var payload = evt.payload;
+
+            // TODO: open copy editor inline?  new tab?
+
+            // print the missing pieces slip
+            var promise = $q.when();
+            if (payload.slip) {
+                // wait for completion, since it may spawn a confirm dialog
+                promise = egCore.print.print({
+                    context : 'default', 
+                    content_type : 'text/html',
+                    content : payload.slip.template_output().data()
+                });
+            }
+
+            if (payload.letter) {
+                $scope.letter = payload.letter.template_output().data();
+            }
+
+            // apply patron penalty
+            if (payload.circ) {
+                promise.then(function() {
+                    egCirc.create_penalty(payload.circ.usr())
+                });
+            }  
+
+        });
+    }
+
+    $scope.print_letter = function() {
+        egCore.print.print({
+            context : 'mail',
+            content_type : 'text/plain',
+            content : $scope.letter
+        });
+    }
+
+    // find the item by barcode, then proceed w/ missing pieces
+    $scope.submitBarcode = function(args) {
+
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        $scope.selectMe = false;
+        $scope.letter = null;
+
+        get_copy(args.barcode).then(mark_missing_pieces);
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js b/Open-ILS/web/js/ui/default/staff/cat/item/replace_barcode/app.js
new file mode 100644 (file)
index 0000000..33d1cb6
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * Item Display
+ */
+
+angular.module('egItemReplaceBarcode', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+
+.controller('ReplaceItemBarcodeCtrl',
+       ['$scope','egCore',
+function($scope , egCore) {
+    egCore.startup.go();
+
+    $scope.focusBarcode = true;
+
+    $scope.updateBarcode = function() {
+        $scope.copyNotFound = false;
+        $scope.updateOK = false;
+
+        egCore.pcrud.search('acp', 
+            {deleted : 'f', barcode : $scope.barcode1})
+        .then(function(copy) {
+
+            if (!copy) {
+                $scope.focusBarcode = true;
+                $scope.copyNotFound = true;
+                return;
+            }
+
+            $scope.copyId = copy.id();
+            copy.barcode($scope.barcode2);
+
+            egCore.pcrud.update(copy).then(function(stat) {
+                $scope.updateOK = stat;
+                $scope.focusBarcode = true;
+            });
+        });
+    }
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/cat/services/record.js b/Open-ILS/web/js/ui/default/staff/cat/services/record.js
new file mode 100644 (file)
index 0000000..6975b27
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * Simple directive for rending the HTML view of a bib record.
+ *
+ * <eg-record-html record-id="myRecordIdScopeVariable"></eg-record-id>
+ *
+ * The value of myRecordIdScopeVariable is watched internally and the 
+ * record is updated to match.
+ */
+angular.module('egCoreMod')
+
+.directive('egRecordHtml', function() {
+    return {
+        restrict : 'AE',
+        scope : {recordId : '='},
+        link : function(scope, element, attrs) {
+            scope.element = angular.element(element);
+
+            // kill refs to destroyed DOM elements
+            element.bind("$destroy", function() {
+                delete scope.element;
+            });
+        },
+        controller : 
+                   ['$scope','egCore',
+            function($scope , egCore) {
+
+                function loadRecordHtml() {
+                    egCore.net.request(
+                        'open-ils.search',
+                        'open-ils.search.biblio.record.html',
+                        $scope.recordId
+                    ).then(function(html) {
+                        if (!html) return;
+
+                        // Remove those pesky non-i8n labels / actions.
+                        // Note: for printing, use the browser print page
+                        // option.  The end result is the same.
+                        html = html.replace(
+                            /<button onclick="window.print(.*?)<\/button>/,'');
+                        html = html.replace(/<title>(.*?)<\/title>/,'');
+
+                        // remove reference to nonexistant CSS file
+                        html = html.replace(/<link(.*?)\/>/,'');
+
+                        $scope.element.html(html);
+                    });
+                }
+
+                $scope.$watch('recordId', 
+                    function(newVal, oldVal) {
+                        if (newVal && newVal !== oldVal) {
+                            loadRecordHtml();
+                        }
+                    }
+                );
+
+                if ($scope.recordId) 
+                    loadRecordHtml();
+            }
+        ]
+    }
+})
+
+/*
+ * A record='foo' attribute is required as a storage location of the 
+ * retrieved record
+ */
+.directive('egRecordSummary', function() {
+    return {
+        restrict : 'AE',
+        scope : {
+            recordId : '=',
+            record : '='
+        },
+        templateUrl : './cat/share/t_record_summary',
+        controller : 
+                   ['$scope','egCore',
+            function($scope , egCore) {
+
+                function loadRecord() {
+                    egCore.pcrud.retrieve('bre', $scope.recordId, {
+                        flesh : 1,
+                        flesh_fields : {
+                            bre : ['simple_record','creator','editor']
+                        }
+                    }).then(function(rec) {
+                        rec.owner(egCore.org.get(rec.owner()));
+                        $scope.record = rec;
+                    });
+                }
+
+                $scope.$watch('recordId', 
+                    function(newVal, oldVal) {
+                        if (newVal && newVal !== oldVal) {
+                            loadRecord();
+                        }
+                    }
+                );
+
+
+                if ($scope.recordId) 
+                    loadRecord();
+            }
+        ]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/circ/checkin/app.js b/Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
new file mode 100644 (file)
index 0000000..79b1fd7
--- /dev/null
@@ -0,0 +1,316 @@
+angular.module('egCheckinApp', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/checkin/checkin', {
+        templateUrl: './circ/checkin/t_checkin',
+        controller: 'CheckinCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/checkin/capture', {
+        templateUrl: './circ/checkin/t_checkin',
+        controller: 'CheckinCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/checkin/checkin'});
+})
+
+.factory('checkinSvc', [function() {
+    var service = {};
+    service.checkins = [];
+    return service;
+}])
+
+
+/**
+ * Manages checkin
+ */
+.controller('CheckinCtrl',
+       ['$scope','$q','$window','$location','egCore','checkinSvc','egGridDataProvider','egCirc',
+function($scope , $q , $window , $location , egCore , checkinSvc , egGridDataProvider , egCirc)  {
+
+    $scope.focusMe = true;
+    $scope.checkins = checkinSvc.checkins;
+    var today = new Date();
+    $scope.checkinArgs = {backdate : today}
+    $scope.using_hatch = egCore.hatch.usingHatch();
+    $scope.modifiers = {};
+    $scope.fine_total = 0;
+    $scope.is_capture = $location.path().match(/capture$/);
+    var suppress_popups = false;
+    $scope.grid_persist_key = $scope.is_capture ? 
+        'circ.checkin.capture' : 'circ.checkin.checkin';
+
+    egCore.org.settings([
+        'ui.circ.suppress_checkin_popups' // add other settings as needed
+    ]).then(function(set) {
+        suppress_popups = set['ui.circ.suppress_checkin_popups'];
+    });
+
+    // checkin & hold capture modifiers
+    var modifiers = [
+        'void_overdues', 
+        'clear_expired',
+        'hold_as_transit',
+        'manual_float',
+        'no_precat_alert',
+        'retarget_holds',
+        'retarget_holds_all'
+    ];
+
+    if ($scope.is_capture) {
+        // in hold capture mode, some values are forced, regardless
+        // of stored preferences.
+        $scope.modifiers.noop = false;
+        $scope.modifiers.auto_print_holds_transits = true;
+    } else {
+        modifiers.push('noop'); // AKA suppress holds and transits
+        modifiers.push('auto_print_holds_transits');
+    }
+
+    // set modifiers from stored preferences
+    angular.forEach(modifiers, function(mod) {
+        egCore.hatch.getItem('eg.circ.checkin.' + mod)
+        .then(function(val) { if (val) $scope.modifiers[mod] = true });
+    });
+
+    // set / unset a checkin modifier
+    // when set, store the preference
+    $scope.toggle_mod = function(mod) {
+        if ($scope.modifiers[mod]) {
+            $scope.modifiers[mod] = false;
+            egCore.hatch.removeItem('eg.circ.checkin.' + mod);
+        } else {
+            $scope.modifiers[mod] = true;
+            egCore.hatch.setItem('eg.circ.checkin.' + mod, true);
+        }
+    }
+
+
+    // ensure the backdate is not in the future
+    // note: input type=date max=foo not yet supported anywhere
+    $scope.$watch('checkinArgs.backdate', function(newval) {
+        if (newval && newval > today) 
+            $scope.checkinArgs.backdate = today;
+    });
+
+    $scope.is_backdate = function() {
+        return $scope.checkinArgs.backdate < today;
+    }
+
+    var checkinGrid = $scope.gridControls = {};
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.checkins, offset, count);
+        }
+    });
+
+    // turns the various inputs (form args, modifiers, etc.) into
+    // checkin params and options.
+    function compile_checkin_args(args) {
+        var params = angular.copy(args);
+
+        if (params.backdate) {
+            params.backdate = 
+                params.backdate.toISOString().replace(/T.*/,'');
+
+            // a backdate of 'today' is not really a backdate
+            if (params.backdate == $scope.max_backdate)
+                delete params.backdate;
+        }
+
+        angular.forEach(['noop','void_overdues',
+                'clear_expired','hold_as_transit','manual_float'],
+            function(opt) {
+                if ($scope.modifiers[opt]) params[opt] = true;
+            }
+        );
+
+        if ($scope.modifiers.retarget_holds) {
+            if ($scope.modifiers.retarget_holds_all) {
+                params.retarget_mode = 'retarget.all';
+            } else {
+                params.retarget_mode = 'retarget';
+            }
+        }
+
+        var options = {
+            check_barcode : $scope.strict_barcode,
+            no_precat_alert : $scope.modifiers.no_precat_alert,
+            auto_print_holds_transits : 
+                $scope.modifiers.auto_print_holds_transits,
+            suppress_popups : suppress_popups
+        };
+
+        return {params : params, options: options};
+    }
+
+    $scope.checkin = function(args) {
+
+        var compiled = compile_checkin_args(args);
+        args.copy_barcode = ''; // reset UI for next scan
+        $scope.focusMe = true;
+        delete $scope.alert;
+        delete $scope.billable_amount;
+        delete $scope.billable_barcode;
+
+        var params = compiled.params;
+        var options = compiled.options;
+
+        if (!params.copy_barcode) return;
+        delete $scope.alert;
+
+        var row_item = {
+            index : checkinSvc.checkins.length,
+            copy_barcode : params.copy_barcode
+        };
+
+        // track the item in the grid before sending the request
+        checkinSvc.checkins.unshift(row_item);
+        checkinGrid.refresh();
+
+        egCirc.checkin(params, options).then(
+        function(final_resp) {
+
+            row_item.evt = final_resp.evt;
+            angular.forEach(final_resp.data, function(val, key) {
+                row_item[key] = val;
+            });
+
+            if (row_item.mbts) {
+                var amt = Number(row_item.mbts.balance_owed());
+                if (amt != 0) {
+                    $scope.billable_barcode = row_item.copy_barcode;
+                    $scope.billable_amount = amt;
+                    $scope.fine_total = 
+                        ($scope.fine_total * 100 + amt * 100) / 100;
+                }
+            }
+
+            if (final_resp.evt.textcode == 'NO_CHANGE') {
+                $scope.alert = 
+                    {already_checked_in : final_resp.evt.copy_barcode};
+            }
+
+            if ($scope.trim_list && checkinSvc.checkins.length > 20)
+                checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+        },
+        function() {
+            // Checkin was rejected somewhere along the way.
+            // Remove the copy from the grid since there was no action.
+            // note: since checkins are unshifted onto the array, the
+            // index value does not (generally) match the array position.
+            var pos = -1;
+            angular.forEach(checkinSvc.checkins, function(ci, idx) {
+                if (ci.index == row_item.index) pos = idx;
+            });
+            checkinSvc.checkin.splice(pos, 1);
+
+        })['finally'](function() {
+
+            // when all is said and done, refresh the grid and refocus
+            checkinGrid.refresh();
+            $scope.focusMe = true;
+        });
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {checkins : []}
+
+        if (checkinSvc.checkins.length == 0) return $q.when();
+
+        angular.forEach(checkinSvc.checkins, function(checkin) {
+
+            var checkin = {
+                copy : egCore.idl.toHash(checkin.acp) || {},
+                call_number : egCore.idl.toHash(checkin.acn) || {},
+                copy_barcode : checkin.copy_barcode,
+                title : checkin.title,
+                author : checkin.author
+            }
+
+            print_data.checkins.push(checkin);
+        });
+
+        return egCore.print.print({
+            template : 'checkin', 
+            scope : print_data,
+            show_dialog : $scope.show_print_dialog
+        });
+    }
+
+
+    // --- context menu actions
+    //
+    $scope.fetchLastCircPatron = function(items) {
+        var checkin = items[0];
+        if (!checkin || !checkin.acp) return;
+
+        egCirc.last_copy_circ(checkin.acp.id())
+        .then(function(circ) {
+
+            if (circ) {
+                // jump to the patron UI (separate app)
+                $window.location.href = $location
+                    .path('/circ/patron/' + circ.usr() + '/checkout')
+                    .absUrl();
+                return;
+            }
+
+            $scope.alert = {item_never_circed : checkin.acp.barcode()};
+        });
+    }
+
+    $scope.showBackdateDialog = function(items) {
+        var circ_ids = [];
+
+        angular.forEach(items, function(item) {
+            if (item.circ) circ_ids.push(item.circ.id());
+        });
+
+        if (circ_ids.length) {
+            egCirc.backdate_dialog(circ_ids).then(function(result) {
+                angular.forEach(items, function(item) {
+                    item.circ.checkin_time(result.backdate);
+                })
+            });
+            // TODO: support grid row styling
+            checkinGrid.refresh();
+        }
+    }
+
+    $scope.showMarkDamaged = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+
+        if (copy_ids.length) {
+            egCirc.mark_damaged(copy_ids).then(function() {
+                // update grid items?
+            });
+        }
+    }
+
+    $scope.abortTransit = function(items) {
+        var transit_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.transit) transit_ids.push(item.transit.id());
+        });
+
+        egCirc.abort_transits(transit_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/holds/app.js b/Open-ILS/web/js/ui/default/staff/circ/holds/app.js
new file mode 100644 (file)
index 0000000..21e9ffc
--- /dev/null
@@ -0,0 +1,315 @@
+angular.module('egHoldsApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/holds/shelf', {
+        templateUrl: './circ/holds/t_shelf',
+        controller: 'HoldsShelfCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/shelf/:hold_id', {
+        templateUrl: './circ/holds/t_shelf',
+        controller: 'HoldsShelfCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/pull', {
+        templateUrl: './circ/holds/t_pull',
+        controller: 'HoldsPullListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/holds/pull/:hold_id', {
+        templateUrl: './circ/holds/t_pull',
+        controller: 'HoldsPullListCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/holds/shelf'});
+})
+
+.factory('holdUiSvc', function() {
+    return {
+        holds : [] // cache
+    }
+})
+
+.controller('HoldsShelfCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egHoldGridActions','egCirc','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egHoldGridActions , egCirc , egGridDataProvider)  {
+    $scope.detail_hold_id = $routeParams.hold_id;
+
+    var hold_ids = [];
+    var holds = [];
+    var clear_mode = false;
+    $scope.gridControls = {};
+    $scope.grid_actions = egHoldGridActions;
+
+    function fetch_holds(offset, count) {
+        var ids = hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                holds.push(hold_data);
+                return hold_data; // to the grid
+            }
+        );
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function refresh_page() {
+        holds = [];
+        hold_ids = [];
+        provider.refresh();
+    }
+    // called after any egHoldGridActions action occurs
+    $scope.grid_actions.refresh = refresh_page;
+
+    provider.get = function(offset, count) {
+
+        // see if we have the requested range cached
+        if (holds[offset]) {
+            return provider.arrayNotifier(patronSvc.holds, offset, count);
+        }
+
+        // see if we have the holds IDs for this range already loaded
+        if (hold_ids[offset]) {
+            return fetch_holds(offset, count);
+        }
+
+        var deferred = $q.defer();
+        hold_ids = [];
+        holds = [];
+
+        var method = 'open-ils.circ.captured_holds.id_list.on_shelf.retrieve.authoritative.atomic';
+        if (clear_mode) 
+            method = 'open-ils.circ.captured_holds.id_list.expired_on_shelf_or_wrong_shelf.retrieve.atomic';
+
+        egCore.net.request(
+            'open-ils.circ', method,
+            egCore.auth.token(), $scope.pickup_ou.id()
+
+        ).then(function(ids) {
+            if (!ids.length) { 
+                deferred.resolve(); 
+                return; 
+            }
+
+            hold_ids = ids;
+            fetch_holds(offset, count)
+            .then(deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+    // re-draw the grid when user changes the org selector
+    $scope.pickup_ou = egCore.org.get(egCore.auth.user().ws_ou());
+    $scope.$watch('pickup_ou', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) 
+            refresh_page();
+    });
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/holds/shelf/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/holds/shelf');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+    // manage active vs. clearable holds display
+    var clearing = false; // true if actively clearing holds (below)
+    $scope.is_clearing = function() { return clearing };
+    $scope.active_mode = function() {return !clear_mode}
+    $scope.clear_mode = function() {return clear_mode}
+    $scope.show_clearable = function() { clear_mode = true; refresh_page() }
+    $scope.show_active = function() { clear_mode = false; refresh_page() }
+    $scope.disable_clear = function() { return clearing || !clear_mode }
+
+    // udpate the in-grid hold with the clear-shelf cached response info.
+    function handle_clear_cache_resp(resp) {
+        if (!angular.isArray(resp)) resp = [resp];
+        angular.forEach(resp, function(info) {
+            if (info.action) {
+                var grid_item = holds.filter(function(item) {
+                    return item.hold.id() == info.hold_details.id
+                })[0];
+
+                // there will be no grid item if the hold is off-page
+                if (grid_item) {
+                    grid_item.post_clear = 
+                        egCore.strings['CLEAR_SHELF_ACTION_' + info.action];
+                }
+            }
+        });
+    }
+
+    $scope.clear_holds = function() {
+        clearing = true;
+        $scope.clear_progress = {max : 0, value : 0};
+
+        // we want to see all processed holds, so (effectively) remove
+        // the grid limit.
+        $scope.gridControls.setLimit(1000); 
+
+        // initiate clear shelf and grab cache key
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.clear_shelf.process',
+            egCore.auth.token(), $scope.pickup_ou.id()
+
+        // request responses from the clear shelf cache
+        ).then(
+            
+            // clear shelf done; fetch the cached results.
+            function(resp) {
+                clearing = false;
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.hold.clear_shelf.get_cache',
+                    egCore.auth.token(), resp.cache_key
+                ).then(null, null, handle_clear_cache_resp);
+            }, 
+
+            null,
+
+            // handle streamed clear_shelf progress updates
+            function(resp) {
+                if (resp.maximum) 
+                    $scope.clear_progress.max = resp.maximum;
+                if (resp.progress)
+                    $scope.clear_progress.value = resp.progress;
+            }
+
+        );
+    }
+
+    refresh_page();
+
+}])
+
+.controller('HoldsPullListCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egHolds','egCirc','egGridDataProvider','egHoldGridActions','holdUiSvc',
+function($scope , $q , $routeParams , $window , $location , egCore , egHolds , egCirc , egGridDataProvider , egHoldGridActions , holdUiSvc)  {
+    $scope.detail_hold_id = $routeParams.hold_id;
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    $scope.grid_actions = egHoldGridActions;
+    $scope.grid_actions.refresh = function() {
+        holdUiSvc.holds = [];
+        provider.refresh();
+    }
+
+    provider.get = function(offset, count) {
+
+        if (holdUiSvc.holds[offset]) {
+            return provider.arrayNotifier(holdUiSvc.holds, offset, count);
+        }
+
+        var deferred = $q.defer();
+        var recv_index = 0;
+
+        // fetch the IDs
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold_pull_list.fleshed.stream',
+            egCore.auth.token(), count, offset
+        ).then(
+            deferred.resolve, null, 
+            function(hold_data) {
+                egHolds.local_flesh(hold_data);
+                holdUiSvc.holds[offset + recv_index++] = hold_data;
+                deferred.notify(hold_data);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/holds/pull/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/holds/pull');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+    // By default, this action is hidded from the UI, but leaving it
+    // here in case it's needed in the future
+    $scope.print_list_alt = function() {
+        var url = '/opac/extras/circ/alt_holds_print.html';
+        var win = $window.open(url, '_blank');
+        win.ses = function() {return egCore.auth.token()};
+        win.open();
+        win.focus();
+    }
+
+    $scope.print_list_progress = null;
+    $scope.print_full_list = function() {
+        var print_holds = [];
+        $scope.print_list_loading = true;
+        $scope.print_list_progress = 0;
+
+        // collect the full list of holds
+        egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold_pull_list.fleshed.stream',
+            egCore.auth.token(), 10000, 0
+        ).then(
+            function() {
+                console.debug('printing ' + print_holds.length + ' holds');
+
+                // holds fetched, send to print
+                egCore.print.print({
+                    context : 'default', 
+                    template : 'hold_pull_list', 
+                    scope : {holds : print_holds}
+                });
+            },
+            null, 
+            function(hold_data) {
+                $scope.print_list_progress++;
+                egHolds.local_flesh(hold_data);
+                print_holds.push(hold_data);
+                hold_data.title = hold_data.mvr.title();
+                hold_data.author = hold_data.mvr.author();
+                hold_data.hold = egCore.idl.toHash(hold_data.hold);
+                hold_data.copy = egCore.idl.toHash(hold_data.copy);
+                hold_data.volume = egCore.idl.toHash(hold_data.volume);
+                hold_data.part = egCore.idl.toHash(hold_data.part);
+            }
+        ).finally(function() {
+            $scope.print_list_loading = false;
+            $scope.print_list_progress = null;
+        });
+    }
+
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js b/Open-ILS/web/js/ui/default/staff/circ/in_house_use/app.js
new file mode 100644 (file)
index 0000000..06a1889
--- /dev/null
@@ -0,0 +1,123 @@
+angular.module('egInHouseUseApp', 
+    ['ngRoute', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+
+})
+
+.controller('InHouseUseCtrl',
+       ['$scope','egCore','egGridDataProvider','egConfirmDialog',
+function($scope,  egCore,  egGridDataProvider , egConfirmDialog) {
+
+    var countCap;
+    var countMax;
+
+    egCore.startup.go().then(function() {
+
+        // grab our non-cat types after startup
+        egCore.pcrud.search('cnct', 
+            {owning_lib : 
+                egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+            null, {atomic : true}
+        ).then(function(list) { 
+            egCore.env.absorbList(list, 'cnct');
+            $scope.nonCatTypes = list 
+        });
+
+        // org settings for max and warning in-house-use counts
+        
+        egCore.org.settings([
+            'ui.circ.in_house_use.entry_cap',
+            'ui.circ.in_house_use.entry_warn'
+        ]).then(function(set) {
+            countWarn = set['ui.circ.in_house_use.entry_warn'] || 20;
+            $scope.countMax = countMax = 
+                set['ui.circ.in_house_use.entry_cap'] || 99;
+        });
+    });
+
+    $scope.useFocus = true;
+    $scope.args = {noncat_type : 'barcode', num_uses : 1};
+    var checkouts = [];
+
+    var provider = egGridDataProvider.instance({});
+    provider.get = function(offset, count) {
+        return provider.arrayNotifier(checkouts, offset, count);
+    }
+    $scope.gridDataProvider = provider;
+
+    // currently selected non-cat type
+    $scope.selectedNcType = function() {
+        if (!egCore.env.cnct) return null; // too soon
+        var type = egCore.env.cnct.map[$scope.args.noncat_type];
+        return type ? type.name() : null;
+    }
+
+    $scope.checkout = function(args) {
+        $scope.copyNotFound = false;
+
+        var coArgs = {
+            count : args.num_uses,
+            'location' : egCore.auth.user().ws_ou()
+        };
+
+        if (args.noncat_type == 'barcode') {
+
+            egCore.pcrud.search('acp',
+                {barcode : args.barcode, deleted : 'f'},
+                {   flesh : 3, 
+                    flesh_fields : {
+                        acp : ['call_number','location'],
+                        acn : ['record'],
+                        bre : ['simple_record']
+                    },
+                    select : { bre : ['id'] } // avoid fleshing MARC
+                }
+            ).then(function(copy) {
+
+                if (!copy) {
+                    $scope.copyNotFound = true;
+                    return;
+                }
+
+                coArgs.copyid = copy.id();
+
+                performCheckout(
+                    'open-ils.circ.in_house_use.create',
+                    coArgs, {copy:copy}
+                );
+            });
+
+        } else {
+            coArgs.non_cat_type = args.noncat_type;
+            performCheckout(
+                'open-ils.circ.non_cat_in_house_use.create',
+                coArgs, {title : $scope.selectedNcType()}
+            );
+        }
+    }
+
+    function performCheckout(method, args, data) {
+
+        // FIXME: make this API stream
+        egCore.net.request(
+            'open-ils.circ', method, egCore.auth.token(), args
+
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+
+            var item = {num_uses : resp.length};
+            item.copy = data.copy;
+            item.title = data.title || 
+                data.copy.call_number().record().simple_record().title();
+            item.index = checkouts.length;
+
+            checkouts.unshift(item);
+            provider.refresh();
+        });
+    }
+
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/app.js b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js
new file mode 100644 (file)
index 0000000..c607e29
--- /dev/null
@@ -0,0 +1,1494 @@
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', 
+    'egCoreMod', 'egUiMod', 'egGridMod', 'egUserMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    // data loaded at startup which only requires an authtoken goes
+    // here. this allows the requests to be run in parallel instead of
+    // waiting until startup has completed.
+    var resolver = {delay : ['egCore','egUser', function(egCore , egUser) {
+
+        // fetch the org settings we care about during egStartup
+        // and toss them into egCore.env as egCore.env.aous[name] = value.
+        // note: only load settings here needed by all tabs; load tab-
+        // specific settings from within their respective controllers
+        egCore.env.classLoaders.aous = function() {
+            return egCore.org.settings([
+                'circ.obscure_dob',
+                'ui.circ.show_billing_tab_on_bills',
+                'circ.patron_expires_soon_warning',
+                'ui.circ.items_out.lost',
+                'ui.circ.items_out.longoverdue',
+                'ui.circ.items_out.claimsreturned'
+            ]).then(function(settings) { 
+                // local settings are cached within egOrg.  Caching them
+                // again in egEnv just simplifies the syntax for access.
+                egCore.env.aous = settings;
+            });
+        }
+
+        // local stat cats are displayed in the summary bar on each page.
+        egCore.env.classLoaders.actsc = function() {
+            return egCore.pcrud.search('actsc', 
+                {owner : egCore.org.ancestors(
+                    egCore.auth.user().ws_ou(), true)},
+                {}, {atomic : true}
+            ).then(function(cats) {
+                egCore.env.absorbList(cats, 'actsc');
+            });
+        }
+
+        egCore.env.loadClasses.push('aous');
+        egCore.env.loadClasses.push('actsc');
+
+        // app-globally modify the default flesh fields for 
+        // fleshed user retrieval.
+        if (egUser.defaultFleshFields.indexOf('profile') == -1) {
+            egUser.defaultFleshFields = egUser.defaultFleshFields.concat([
+                'profile',
+                'net_access_level',
+                'ident_type',
+                'ident_type2',
+                'cards'
+            ]);
+        }
+
+        return egCore.startup.go()
+    }]};
+
+    $routeProvider.when('/circ/patron/search', {
+        templateUrl: './circ/patron/t_search',
+        controller: 'PatronSearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/bcsearch', {
+        templateUrl: './circ/patron/t_bcsearch',
+        controller: 'PatronBarcodeSearchCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/credentials', {
+        templateUrl: './circ/patron/t_credentials',
+        controller: 'PatronVerifyCredentialsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/last', {
+        templateUrl: './circ/patron/t_last_patron',
+        controller: 'PatronFetchLastCtrl',
+        resolve : resolver
+    });
+
+    // the following require a patron ID
+
+    $routeProvider.when('/circ/patron/:id/alerts', {
+        templateUrl: './circ/patron/t_alerts',
+        controller: 'PatronAlertsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/checkout', {
+        templateUrl: './circ/patron/t_checkout',
+        controller: 'PatronCheckoutCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/items_out', {
+        templateUrl: './circ/patron/t_items_out',
+        controller: 'PatronItemsOutCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds', {
+        templateUrl: './circ/patron/t_holds',
+        controller: 'PatronHoldsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds/create', {
+        templateUrl: './circ/patron/t_holds_create',
+        controller: 'PatronHoldsCreateCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/holds/:hold_id', {
+        templateUrl: './circ/patron/t_holds',
+        controller: 'PatronHoldsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/hold/:hold_id', {
+        templateUrl: './circ/patron/t_hold_details',
+        controller: 'PatronHoldDetailsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bills', {
+        templateUrl: './circ/patron/t_bills',
+        controller: 'PatronBillsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bill/:xact_id', {
+        templateUrl: './circ/patron/t_xact_details',
+        controller: 'XactDetailsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/bill_history/:history_tab', {
+        templateUrl: './circ/patron/t_bill_history',
+        controller: 'BillHistoryCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/messages', {
+        templateUrl: './circ/patron/t_messages',
+        controller: 'PatronMessagesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/edit', {
+        templateUrl: './circ/patron/t_edit',
+        controller: 'PatronEditCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/credentials', {
+        templateUrl: './circ/patron/t_credentials',
+        controller: 'PatronVerifyCredentialsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/notes', {
+        templateUrl: './circ/patron/t_notes',
+        controller: 'PatronNotesCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/triggered_events', {
+        templateUrl: './circ/patron/t_triggered_events',
+        controller: 'PatronTriggeredEventsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/edit_perms', {
+        templateUrl: './circ/patron/t_edit_perms',
+        controller: 'PatronPermsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/group', {
+        templateUrl: './circ/patron/t_group',
+        controller: 'PatronGroupCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/:id/stat_cats', {
+        templateUrl: './circ/patron/t_stat_cats',
+        controller: 'PatronStatCatsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/search'});
+})
+
+/**
+ * Patron service
+ */
+.factory('patronSvc',
+       ['$q','$timeout','$location','egCore','egUser','$locale',
+function($q , $timeout , $location , egCore,  egUser , $locale) {
+
+    var service = {
+        // cached patron search results
+        patrons : [],
+
+        // currently selected patron object
+        current : null, 
+
+        // patron circ stats (overdues, fines, holds)
+        patron_stats : null,
+
+        // event types manually overridden, which should always be
+        // overridden for checkouts to this patron for this instance of
+        // the interface.
+        checkout_overrides : {},
+    };
+
+    // when we change the default patron, we need to clear out any
+    // data collected on that patron
+    service.resetPatronLists = function() {
+        service.checkouts = [];
+        service.items_out = []
+        service.items_out_ids = [];
+        service.holds = [];
+        service.hold_ids = [];
+        service.checkout_overrides = {};
+        service.patron_stats = null;
+        service.hasAlerts = false;
+        service.alertsShown = false;
+        service.patronExpired = false;
+        service.patronExpiresSoon = false;
+        service.retrievedWithInactive = false;
+    }
+    service.resetPatronLists();  // initialize
+
+    // shortcut to force-reload the current primary
+    service.refreshPrimary = function() {
+        if (!service.current) return $q.when();
+        return service.setPrimary(service.current.id(), null, true);
+    }
+
+    // sets the primary display user, fetching data as necessary.
+    service.setPrimary = function(id, user, force) {
+        var user_id = id ? id : (user ? user.id() : null);
+
+        console.debug('setting primary user to: ' + user_id);
+
+        if (!user_id) return $q.reject();
+
+        // when loading a new patron, update the last patron setting
+        if (!service.current || service.current.id() != user_id)
+            egCore.hatch.setLocalItem('eg.circ.last_patron', user_id);
+
+        // avoid running multiple retrievals for the same patron, which
+        // can happen during dbl-click by maintaining a single running
+        // data retrieval promise
+        if (service.primaryUserPromise) {
+            if (service.primaryUserId == user_id) {
+                return service.primaryUserPromise.promise;
+            } else {
+                service.primaryUserPromise = null;
+            }
+        }
+
+        service.primaryUserPromise = $q.defer();
+        service.primaryUserId = user_id;
+
+        service.getPrimary(id, user, force)
+        .then(function() {
+            var p = service.primaryUserPromise;
+            service.primaryUserId = null;
+            // clear before resolution just to be safe.
+            service.primaryUserPromise = null;
+            p.resolve();
+        });
+
+        return service.primaryUserPromise.promise;
+    }
+
+    service.getPrimary = function(id, user, force) {
+
+        if (user) {
+            if (!force && service.current && 
+                service.current.id() == user.id()) {
+                if (service.patron_stats) {
+                    return $q.when();
+                } else {
+                    return service.fetchUserStats();
+                }
+            }
+
+            service.resetPatronLists();
+            service.current = user;
+            service.localFlesh(user);
+            return service.fetchUserStats();
+
+        } else if (id) {
+            if (!force && service.current && service.current.id() == id) {
+                if (service.patron_stats) {
+                    return $q.when();
+                } else {
+                    return service.fetchUserStats();
+                }
+            }
+
+            service.resetPatronLists();
+
+            return egUser.get(id).then(
+                function(user) {
+                    service.current = user;
+                    service.localFlesh(user);
+                    return service.fetchUserStats();
+                },
+                function(err) {
+                    console.error(
+                        "unable to fetch user "+id+': '+js2JSON(err))
+                }
+            );
+        } else {
+
+            // reset with no patron
+            service.resetPatronLists();
+            service.current = null;
+            service.patron_stats = null;
+            return $q.when();
+        }
+    }
+
+    // flesh some additional user fields locally
+    service.localFlesh = function(user) {
+        if (!angular.isObject(typeof user.home_ou()))
+            user.home_ou(egCore.org.get(user.home_ou()));
+
+        angular.forEach(
+            user.standing_penalties(),
+            function(penalty) {
+                if (!angular.isObject(penalty.org_unit()))
+                    penalty.org_unit(egCore.org.get(penalty.org_unit()));
+            }
+        );
+
+        // stat_cat_entries == stat_cat_entry_user_map
+        angular.forEach(user.stat_cat_entries(), function(map) {
+            if (angular.isObject(map.stat_cat())) return;
+            // At page load, we only retrieve org-visible stat cats.
+            // For the common case, ignore entries for remote stat cats.
+            var cat = egCore.env.actsc.map[map.stat_cat()];
+            if (cat) {
+                map.stat_cat(cat);
+                cat.owner(egCore.org.get(cat.owner()));
+            }
+        });
+    }
+
+    // resolves to true if the patron account has expired or will
+    // expire soon, based on YAOUS circ.patron_expires_soon_warning
+    // note: returning a promise is no longer strictly necessary
+    // (no more async activity) if the calling function is changed too.
+    service.testExpire = function() {
+
+        var expire = Date.parse(service.current.expire_date());
+        if (expire < new Date()) {
+            return $q.when(service.patronExpired = true);
+        }
+
+        var soon = egCore.env.aous['circ.patron_expires_soon_warning'];
+        if (Number(soon)) {
+            var preExpire = new Date();
+            preExpire.setDate(preExpire.getDate() + Number(soon));
+            if (expire < preExpire) 
+                return $q.when(service.patronExpiresSoon = true);
+        }
+
+        return $q.when(false);
+    }
+
+    // resolves to true if there is any aspect of the patron account
+    // which should produce a message in the alerts panel
+    service.checkAlerts = function() {
+
+        if (service.hasAlerts) // already checked
+            return $q.when(true); 
+
+        var deferred = $q.defer();
+        var p = service.current;
+
+        if (service.alert_penalties.length ||
+            p.alert_message() ||
+            p.active() == 'f' ||
+            p.barred() == 't' ||
+            service.patron_stats.holds.ready) {
+
+            service.hasAlerts = true;
+        }
+
+        // see if the user was retrieved with an inactive card
+        if (bc = $location.search().card) {
+            var card = p.cards().filter(
+                function(c) { return c.barcode() == bc })[0];
+
+            if (card && card.active() == 'f') {
+                service.hasAlerts = true;
+                service.retrievedWithInactive = true;
+            }
+        }
+
+        // regardless of whether we know of alerts, we still need 
+        // to test/fetch the expire data for display
+        service.testExpire().then(function(bool) {
+            if (bool) service.hasAlerts = true;
+            deferred.resolve(service.hasAlerts);
+        });
+
+        return deferred.promise;
+    }
+
+    service.fetchGroupFines = function() {
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.usergroup.members.balance_owed',
+            egCore.auth.token(), service.current.usrgroup()
+        ).then(function(list) {
+            var total = 0;
+            angular.forEach(list, function(u) { 
+                total += 100 * Number(u.balance_owed)
+            });
+            service.patron_stats.fines.group_balance_owed = total / 100;
+        });
+    }
+
+    service.getUserStats = function(id) {
+        return egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.opac.vital_stats.authoritative', 
+            egCore.auth.token(), id
+        ).then(
+            function(stats) {
+                // force numeric to ensure correct boolean handling in templates
+                stats.fines.balance_owed = Number(stats.fines.balance_owed);
+                stats.checkouts.overdue = Number(stats.checkouts.overdue);
+                stats.checkouts.claims_returned = 
+                    Number(stats.checkouts.claims_returned);
+                stats.checkouts.lost = Number(stats.checkouts.lost);
+                stats.checkouts.out = Number(stats.checkouts.out);
+                stats.checkouts.total_out = 
+                    stats.checkouts.out + stats.checkouts.overdue;
+                return stats;
+            }
+        );
+    }
+
+
+    // grab additional circ info
+    service.fetchUserStats = function() {
+        return service.getUserStats(service.current.id())
+        .then(function(stats) {
+            service.patron_stats = stats
+            service.alert_penalties = service.current.standing_penalties()
+                .filter(function(pen) { 
+                return pen.standing_penalty().staff_alert() == 't' 
+            });
+
+            service.summary_stat_cats = [];
+            angular.forEach(service.current.stat_cat_entries(), 
+                function(map) {
+                    if (angular.isObject(map.stat_cat()) &&
+                        map.stat_cat().usr_summary() == 't') {
+                        service.summary_stat_cats.push(map);
+                    }
+                }
+            );
+
+            return service.fetchGroupFines();
+        });
+    }
+
+    // Avoid using parens [e.g. (1.23)] to indicate negative numbers, 
+    // which is the Angular default.
+    // http://stackoverflow.com/questions/17441254/why-angularjs-currency-filter-formats-negative-numbers-with-parenthesis
+    // FIXME: This change needs to be moved into a project-wide collection
+    // of locale overrides.
+    $locale.NUMBER_FORMATS.PATTERNS[1].negPre = '-';
+    $locale.NUMBER_FORMATS.PATTERNS[1].negSuf = '';
+
+    return service;
+}])
+
+/**
+ * Manages tabbed patron view.
+ * This is the parent scope of all patron tab scopes.
+ *
+ * */
+.controller('PatronCtrl',
+       ['$scope','$q','$location','$filter','egCore','egUser','patronSvc',
+function($scope,  $q,  $location , $filter,  egCore,  egUser,  patronSvc) {
+
+    // returns true if a redirect occurs
+    function redirectToAlertPanel() {
+
+        $scope.alert_penalties = 
+            function() {return patronSvc.alert_penalties}
+
+        if (patronSvc.alertsShown) return false;
+        patronSvc.alertsShown = true;
+
+        // if the patron has any unshown alerts, show them now
+        if (patronSvc.hasAlerts && 
+            !$location.path().match(/alerts$/)) {
+
+            $location
+                .path('/circ/patron/' + patronSvc.current.id() + '/alerts')
+                .search('card', null);
+            return true;
+        }
+
+        // no alert required.  If the patron has fines and the show-bills
+        // OUS is applied, direct to the bills page.
+        if ($scope.patron_stats().fines.balance_owed > 0 // TODO: != 0 ?
+            && egCore.env.aous['ui.circ.show_billing_tab_on_bills']
+            && !$location.path().match(/bills$/)) {
+
+            $location
+                .path('/circ/patron/' + patronSvc.current.id() + '/bills')
+                .search('card', null);
+
+            return true;
+        }
+
+        return false;
+    }
+
+    // called after each route-specified controller is instantiated.
+    // this doubles as a way to inform the top-level controller that
+    // egStartup.go() has completed, which means we are clear to 
+    // fetch the patron, etc.
+    $scope.initTab = function(tab, patron_id) {
+        console.log('init tab ' + tab);
+        $scope.tab = tab;
+        $scope.aous = egCore.env.aous;
+
+        if (patron_id) {
+            $scope.patron_id = patron_id;
+            return patronSvc.setPrimary($scope.patron_id)
+            .then(function() {return patronSvc.checkAlerts()})
+            .then(redirectToAlertPanel);
+        }
+        return $q.when();
+    }
+
+    $scope.patron = function() { return patronSvc.current }
+    $scope.patron_stats = function() { return patronSvc.patron_stats }
+    $scope.summary_stat_cats = function() { return patronSvc.summary_stat_cats }
+
+    $scope.print_address = function(addr) {
+        egCore.print.print({
+            context : 'default', 
+            template : 'patron_address', 
+            scope : {
+                patron : egCore.idl.toHash(patronSvc.current),
+                address : egCore.idl.toHash(addr)
+            }
+        });
+    }
+
+    $scope.toggle_expand_summary = function() {
+        if ($scope.collapsePatronSummary) {
+            $scope.collapsePatronSummary = false;
+            egCore.hatch.removeItem('eg.circ.patron.summary.collapse');
+        } else {
+            $scope.collapsePatronSummary = true;
+            egCore.hatch.setItem('eg.circ.patron.summary.collapse', true);
+        }
+    }
+    
+    // always expand the patron summary in the search UI, regardless
+    // of stored preference.
+    $scope.collapse_summary = function() {
+        return $scope.tab != 'search' && $scope.collapsePatronSummary;
+    }
+
+    egCore.hatch.getItem('eg.circ.patron.summary.collapse')
+    .then(function(val) {$scope.collapsePatronSummary = Boolean(val)});
+}])
+
+.controller('PatronBarcodeSearchCtrl',
+       ['$scope','$location','egCore','egConfirmDialog','egUser','patronSvc',
+function($scope , $location , egCore , egConfirmDialog , egUser , patronSvc) {
+    $scope.selectMe = true; // focus text input
+    patronSvc.setPrimary(); // clear the default user
+
+    // jump to the patron checkout UI
+    function loadPatron(user_id) {
+        $location
+        .path('/circ/patron/' + user_id + '/checkout')
+        .search('card', $scope.args.barcode);
+    }
+
+    // create an opt-in=yes response for the loaded user
+    function createOptIn(user_id) {
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.org_unit_opt_in.create',
+            egCore.auth.token(), user_id).then(function(resp) {
+                if (evt = egCore.evt.parse(resp)) return alert(evt);
+                loadPatron(user_id);
+            }
+        );
+    }
+
+    $scope.submitBarcode = function(args) {
+        $scope.bcNotFound = null;
+        if (!args.barcode) return;
+
+        // blur so next time it's set to true it will re-apply select()
+        $scope.selectMe = false;
+
+        var user_id;
+
+        // lookup barcode
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(), 
+            'actor', args.barcode)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt); // FIXME
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.bcNotFound = args.barcode;
+                $scope.selectMe = true;
+                return;
+            }
+
+            // see if an opt-in request is needed
+            user_id = resp[0].id;
+            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 == 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
+                egConfirmDialog.open(
+                    egCore.strings.OPT_IN_DIALOG, '',
+                    {   org : egCore.org.get(user.home_ou()),
+                        user : user,
+                        ok : function() { createOptIn(user.id()) },
+                        cancel : function() {}
+                    }
+                );
+            })
+        });
+    }
+}])
+
+
+/**
+ * Manages patron search
+ */
+.controller('PatronSearchCtrl',
+       ['$scope','$q','$routeParams','$timeout','$window','$location','egCore',
+       '$filter','egUser', 'patronSvc','egGridDataProvider',
+function($scope,  $q,  $routeParams,  $timeout,  $window,  $location,  egCore,
+        $filter,  egUser,  patronSvc , egGridDataProvider) {
+
+    $scope.initTab('search');
+    $scope.focusMe = true;
+    $scope.searchArgs = {
+        // default to searching globally
+        home_ou : egCore.org.tree()
+    };
+
+    $scope.gridControls = {
+        activateItem : function(item) {
+            $location.path('/circ/patron/' + item.id() + '/checkout');
+        },
+        selectedItems : function() {return []}
+    }
+
+    // Handle URL-encoded searches
+    if ($location.search().search) {
+        patronSvc.urlSearch = {search : JSON2js($location.search().search)};
+
+        // why the double-JSON encoded sort?
+        patronSvc.urlSearch.sort = 
+            JSON2js(patronSvc.urlSearch.search.search_sort);
+        delete patronSvc.urlSearch.search.search_sort;
+    }
+
+    var propagate;
+    if (patronSvc.lastSearch) {
+        propagate = patronSvc.lastSearch.search;
+    } else if (patronSvc.urlSearch) {
+        propagate = patronSvc.urlSearch.search;
+    }
+
+    if (propagate) {
+        // populate the search form with our cached / preexisting search info
+        angular.forEach(propagate, function(val, key) {
+            $scope.searchArgs[key] = val.value;
+        });
+    }
+
+    var provider = egGridDataProvider.instance({});
+
+    $scope.$watch(
+        function() {return $scope.gridControls.selectedItems()},
+        function(list) {
+            if (list[0]) 
+                patronSvc.setPrimary(null, list[0]);
+        },
+        true
+    );
+        
+    provider.get = function(offset, count) {
+        var deferred = $q.defer();
+
+        var fullSearch;
+        if (patronSvc.urlSearch) {
+            fullSearch = patronSvc.urlSearch;
+            // enusre the urlSearch only runs once.
+            delete patronSvc.urlSearch;
+
+        } else {
+
+            var search = compileSearch($scope.searchArgs);
+            if (Object.keys(search) == 0) return $q.when();
+
+            var home_ou = search.home_ou;
+            delete search.home_ou;
+            var inactive = search.inactive;
+            delete search.inactive;
+
+            fullSearch = {
+                search : search,
+                sort : compileSort(),
+                inactive : inactive,
+                home_ou : home_ou,
+            };
+        }
+
+        fullSearch.count = count;
+        fullSearch.offset = offset;
+
+        if (patronSvc.lastSearch) {
+            // search repeated, return the cached results
+            if (angular.equals(fullSearch, patronSvc.lastSearch)) {
+                console.log('patron search returning ' + 
+                    patronSvc.patrons.length + ' cached results');
+                
+                // notify has to happen after returning the promise
+                $timeout(
+                    function() {
+                        angular.forEach(patronSvc.patrons, function(user) {
+                            deferred.notify(user);
+                        });
+                        deferred.resolve();
+                    }
+                );
+                return deferred.promise;
+            }
+        }
+
+        patronSvc.lastSearch = fullSearch;
+
+        if (fullSearch.search.id) {
+            // search by user id performs a direct ID lookup
+            var userId = fullSearch.search.id.value;
+            $timeout(
+                function() {
+                    egUser.get(userId).then(function(user) {
+                        patronSvc.localFlesh(user);
+                        patronSvc.patrons = [user];
+                        deferred.notify(user);
+                        deferred.resolve();
+                    });
+                }
+            );
+            return deferred.promise;
+        }
+
+        patronSvc.patrons = [];
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.search.advanced.fleshed',
+            egCore.auth.token(), 
+            fullSearch.search, 
+            fullSearch.count,
+            fullSearch.sort,
+            fullSearch.inactive,
+            fullSearch.home_ou,
+            egUser.defaultFleshFields,
+            fullSearch.offset
+
+        ).then(
+            function() { deferred.resolve() },
+            null, // onerror
+            function(user) {
+                patronSvc.localFlesh(user); // inline
+                patronSvc.patrons.push(user);
+                deferred.notify(user);
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    $scope.patronSearchGridProvider = provider;
+
+    if (egCore.env.pgt) {
+        $scope.profiles = egCore.env.pgt.list;
+    } else {
+        egCore.pcrud.search('pgt', {parent : null}, 
+            {flesh : -1, flesh_fields : {pgt : ['children']}}
+        ).then(
+            function(tree) {
+                egCore.env.absorbTree(tree, 'pgt')
+                $scope.profiles = egCore.env.pgt.list;
+            }
+        );
+    }
+
+    // determine the tree depth of the profile group
+    $scope.pgt_depth = function(grp) {
+        var d = 0;
+        while (grp = egCore.env.pgt.map[grp.parent()]) d++;
+        return d;
+    }
+
+    $scope.applyShowExtras = function($event, bool) {
+        if (bool) {
+            $scope.showExtras = true;
+            egCore.hatch.setItem('eg.circ.patron.search.show_extras', true);
+        } else {
+            $scope.showExtras = false;
+            egCore.hatch.removeItem('eg.circ.patron.search.show_extras');
+        }
+        $event.preventDefault();
+    }
+
+    egCore.hatch.getItem('eg.prefs.circ.patron.search.showExtras')
+    .then(function(val) {$scope.showExtras = val});
+
+    // map form arguments into search params
+    function compileSearch(args) {
+        var search = {};
+        angular.forEach(args, function(val, key) {
+            if (!val) return;
+            if (key == 'profile' && args.profile) {
+                search.profile = {value : args.profile.id(), group : 0};
+            } else if (key == 'home_ou' && args.home_ou) {
+                search.home_ou = args.home_ou.id(); // passed separately
+            } else if (key == 'inactive') {
+                search.inactive = val;
+            } else {
+                search[key] = {value : val, group : 0};
+            }
+            if (key.match(/phone|ident/)) {
+                search[key].group = 2;
+            } else {
+                if (key.match(/street|city|state|post_code/)) {
+                    search[key].group = 1;
+                } else if (key == 'card') {
+                    search[key].group = 3
+                }
+            }
+        });
+
+        return search;
+    }
+
+    function compileSort() {
+
+        if (!provider.sort.length) {
+            return [ // default
+                "family_name ASC",
+                "first_given_name ASC",
+                "second_given_name ASC",
+                "dob DESC"
+            ];
+        }
+
+        var sort = [];
+        angular.forEach(
+            provider.sort,
+            function(sortdef) {
+                if (angular.isObject(sortdef)) {
+                    var name = Object.keys(sortdef)[0];
+                    var dir = sortdef[name];
+                    sort.push(name + ' ' + dir);
+                } else {
+                    sort.push(sortdef);
+                }
+            }
+        );
+
+        return sort;
+    }
+
+    // search form submit action; tells the results grid to
+    // refresh itself.
+    $scope.search = function(args) { // args === $scope.searchArgs
+        if (args && Object.keys(args).length) 
+            $scope.gridControls.refresh();
+    }
+
+    // TODO: move this into the (forthcoming) grid row activate action
+    $scope.onPatronDblClick = function($event, user) {
+        $location.path('/circ/patron/' + user.id() + '/checkout');
+    }
+
+    if (patronSvc.urlSearch) {
+        // force the grid to load the url-based search on page load
+        provider.refresh();
+    }
+   
+}])
+
+/**
+ * Manages messages
+ */
+.controller('PatronMessagesCtrl',
+       ['$scope','$q','$routeParams','egCore','$modal','patronSvc','egCirc',
+function($scope , $q , $routeParams,  egCore , $modal , patronSvc , egCirc) {
+    $scope.initTab('messages', $routeParams.id);
+    var usr_id = $routeParams.id;
+
+    // setup date filters
+    var start = new Date(); // now - 1 year
+    start.setFullYear(start.getFullYear() - 1),
+    $scope.dates = {
+        start_date : start,
+        end_date : new Date()
+    }
+
+    function date_range() {
+        var start = $scope.dates.start_date.toISOString().replace(/T.*/,'');
+        var end = $scope.dates.end_date.toISOString().replace(/T.*/,'');
+        var today = new Date().toISOString().replace(/T.*/,'');
+        if (end == today) end = 'now';
+        return [start, end];
+    }
+
+    // grid queries
+   
+    var activeGrid = $scope.activeGridControls = {
+        setSort : function() {
+            return ['set_date'];
+        },
+        setQuery : function() {
+            return {
+                usr : usr_id,
+                '-or' : [
+                    {stop_date : null},
+                    {stop_date : {'>' : 'now'}}
+                ]
+            }
+        }
+    }
+
+    var archiveGrid = $scope.archiveGridControls = {
+        setSort : function() {
+            return ['set_date'];
+        },
+        setQuery : function() {
+            return {
+                usr : usr_id, 
+                stop_date : {'<=' : 'now'},
+                set_date : {between : date_range()}
+            };
+        }
+    };
+
+    $scope.removePenalty = function(selected) {
+        // the grid stores flattened penalties.  Fetch penalty objects first
+
+        var ids = selected.map(function(s){ return s.id });
+        egCore.pcrud.search('ausp', 
+            {id : ids}, {}, 
+            {atomic : true, authoritative : true}
+
+        // then delete them
+        ).then(function(penalties) {
+            return egCore.pcrud.remove(penalties);
+
+        // then refresh the grid
+        }).then(function() {
+            activeGrid.refresh();
+        });
+    }
+
+    $scope.archivePenalty = function(selected) {
+        // the grid stores flattened penalties.  Fetch penalty objects first
+
+        var ids = selected.map(function(s){ return s.id });
+        egCore.pcrud.search('ausp', 
+            {id : ids}, {}, 
+            {atomic : true, authoritative : true}
+
+        // then delete them
+        ).then(function(penalties) {
+            angular.forEach(penalties, function(p){ p.stop_date('now') });
+            return egCore.pcrud.update(penalties);
+
+        // then refresh the grid
+        }).then(function() {
+            activeGrid.refresh();
+            archiveGrid.refresh();
+        });
+    }
+
+    // leverage egEnv for caching
+    function fetchPenaltyTypes() {
+        if (egCore.env.csp) 
+            return $q.when(egCore.env.csp.list);
+        return egCore.pcrud.search(
+            // id <= 100 are reserved for system use
+            'csp', {id : {'>': 100}}, {}, {atomic : true})
+        .then(function(penalties) {
+            egCore.env.absorbList(penalties, 'csp');
+            return penalties;
+        });
+    }
+
+    $scope.createPenalty = function() {
+        egCirc.create_penalty(usr_id).then(function() {
+            activeGrid.refresh();
+            // force a refresh of the user, since they may now
+            // have blocking penalties, etc.
+            patronSvc.setPrimary(patronSvc.current.id(), null, true);
+        });
+    }
+
+    $scope.editPenalty = function(selected) {
+        if (selected.length == 0) return;
+
+        // grab the penalty from the user object
+        var penalty = patronSvc.current.standing_penalties().filter(
+            function(p) {return p.id() == selected[0].id})[0];
+
+        egCirc.edit_penalty(penalty).then(function() {
+            activeGrid.refresh();
+            // force a refresh of the user, since they may now
+            // have blocking penalties, etc.
+            patronSvc.setPrimary(patronSvc.current.id(), null, true);
+        });
+    }
+}])
+
+
+/**
+ * Link to patron edit UI
+ */
+.controller('PatronEditCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams,  $location , egCore , patronSvc) {
+    $scope.initTab('edit', $routeParams.id);
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+    url += '?usr=' + encodeURIComponent($routeParams.id);
+
+    $scope.funcs = {
+        on_save : function() {
+            patronSvc.refreshPrimary();
+        }
+    }
+
+    $scope.patron_edit_url = url;
+}])
+
+/**
+ * Credentials tester
+ */
+.controller('PatronVerifyCredentialsCtrl',
+       ['$scope','$routeParams','$location','egCore',
+function($scope,  $routeParams , $location , egCore) {
+    $scope.verified = null;
+    $scope.focusMe = true;
+
+    // called with a patron, pre-populate the form args
+    $scope.initTab('other', $routeParams.id).then(
+        function() {
+            if ($scope.patron()) {
+                $scope.prepop = true;
+                $scope.username = $scope.patron().usrname();
+                $scope.barcode = $scope.patron().card().barcode();
+            }
+        }
+    );
+
+    // verify login credentials
+    $scope.verify = function() {
+        $scope.verified = null;
+        $scope.notFound = false;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.verify_user_password',
+            egCore.auth.token(), $scope.barcode,
+            $scope.username, hex_md5($scope.password || '')
+
+        ).then(function(resp) {
+            $scope.focusMe = true;
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt);
+            } else if (resp == 1) {
+                $scope.verified = true;
+            } else {
+                $scope.verified = false;
+            }
+        });
+    }
+
+    // load the main patron UI for the provided username or barcode
+    $scope.load = function($event) {
+        $scope.notFound = false;
+        $scope.verified = null;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.retrieve_id_by_barcode_or_username',
+            egCore.auth.token(), $scope.barcode, $scope.username
+
+        ).then(function(resp) {
+
+            if (Number(resp)) {
+                $location.path('/circ/patron/' + resp + '/checkout');
+                return;
+            }
+
+            // something went wrong...
+            $scope.focusMe = true;
+            if (evt = egCore.evt.parse(resp)) {
+                if (evt.textcode == 'ACTOR_USR_NOT_FOUND') {
+                    $scope.notFound = true;
+                    return;
+                }
+                return alert(evt);
+            } else {
+                alert(resp);
+            }
+        });
+
+        // load() button sits within the verify form.  
+        // avoid submitting the verify() form action on load()
+        $event.preventDefault();
+    }
+}])
+
+.controller('PatronAlertsCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams , $location , egCore , patronSvc) {
+
+    $scope.initTab('other', $routeParams.id)
+    .then(function() {
+        $scope.patronExpired = patronSvc.patronExpired;
+        $scope.patronExpiresSoon = patronSvc.patronExpiresSoon;
+        $scope.retrievedWithInactive = patronSvc.retrievedWithInactive;
+    });
+
+}])
+
+.controller('PatronNotesCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc','$modal',
+function($scope,  $routeParams , $location , egCore , patronSvc , $modal) {
+    $scope.initTab('other', $routeParams.id);
+    var usr_id = $routeParams.id;
+
+    // fetch the notes
+    function refreshPage() {
+        $scope.notes = [];
+        egCore.pcrud.search('aun', 
+            {usr : usr_id}, 
+            {flesh : 1, flesh_fields : {aun : ['creator']}}, 
+            {authoritative : true})
+        .then(null, null, function(note) {
+            $scope.notes.push(note);
+        });
+    }
+
+    // open the new-note dialog and create the note
+    $scope.newNote = function() {
+        $modal.open({
+            templateUrl: './circ/patron/t_new_note_dialog',
+            controller: 
+                ['$scope', '$modalInstance',
+            function($scope, $modalInstance) {
+                $scope.focusNote = true;
+                $scope.args = {};
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }],
+        }).result.then(
+            function(args) {
+                if (!args.value) return;
+                var note = new egCore.idl.aun();
+                note.usr(usr_id);
+                note.title(args.title);
+                note.value(args.value);
+                note.pub(args.pub ? 't' : 'f');
+                note.creator(egCore.auth.user().id());
+                egCore.pcrud.create(note).then(function() {refreshPage()});
+            }
+        );
+    }
+
+    // delete the selected note
+    $scope.deleteNote = function(note) {
+        egCore.pcrud.remove(note).then(function() {refreshPage()});
+    }
+
+    // print the selected note
+    $scope.printNote = function(note) {
+        var hash = egCore.idl.toHash(note);
+        hash.usr = egCore.idl.toHash($scope.patron());
+        egCore.print.print({
+            context : 'default', 
+            template : 'patron_note', 
+            scope : {note : hash}
+        });
+    }
+
+    // perform the initial note fetch
+    refreshPage();
+}])
+
+.controller('PatronGroupCtrl',
+       ['$scope','$routeParams','$q','$window','$location','egCore',
+        'patronSvc','$modal','egPromptDialog','egConfirmDialog',
+function($scope,  $routeParams , $q , $window , $location , egCore ,
+         patronSvc , $modal , egPromptDialog , egConfirmDialog) {
+
+    var usr_id = $routeParams.id;
+
+    $scope.totals = {owed : 0, total_out : 0, overdue : 0}
+
+    var grid = $scope.gridControls = {
+        activateItem : function(item) {
+            $location.path('/circ/patron/' + item.id + '/checkout');
+        },
+        itemRetrieved : function(item) {
+
+            if (item.id == patronSvc.current.id()) {
+                item.stats = patronSvc.patron_stats;
+
+            } else {
+                // flesh stats for other group members
+                patronSvc.getUserStats(item.id).then(function(stats) {
+                    item.stats = stats;
+                    $scope.totals.total_out += stats.checkouts.total_out; 
+                    $scope.totals.overdue += stats.checkouts.overdue; 
+                });
+            }
+        },
+        setSort : function() {
+            return ['create_date'];
+        }
+    }
+
+    $scope.initTab('other', $routeParams.id)
+    .then(function(redirect) {
+        // if we are redirecting to the alerts page, avoid updating the
+        // grid query.
+        if (redirect) return;
+        // let initTab() fetch the user first so we can know the usrgroup
+
+        grid.setQuery({
+            usrgroup : patronSvc.current.usrgroup(),
+            deleted : 'f'
+        });
+        $scope.totals.owed = patronSvc.patron_stats.fines.group_balance_owed;
+    });
+
+    $scope.removeFromGroup = function(selected) {
+        var promises = [];
+        angular.forEach(selected, function(user) {
+            console.debug('removing user ' + user.id + ' from group');
+
+            promises.push(
+                egCore.net.request(
+                    'open-ils.actor',
+                    'open-ils.actor.usergroup.new',
+                    egCore.auth.token(), user.id, true
+                )
+            );
+        });
+
+        $q.all(promises).then(function() {grid.refresh()});
+    }
+
+    function addUserToGroup(user) {
+        user.usrgroup(patronSvc.current.usrgroup());
+        user.ischanged(true);
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.patron.update',
+            egCore.auth.token(), user
+
+        ).then(function() {grid.refresh()});
+    }
+
+    // fetch each user ("selected" has flattened users)
+    // update the usrgroup, then update the user object
+    // After all updates are complete, refresh the grid.
+    function moveUsersToGroup(target_user, selected) {
+        var promises = [];
+
+        angular.forEach(selected, function(user) {
+            promises.push(
+                egCore.pcrud.retrieve('au', user.id)
+                .then(function(u) {
+                    u.usrgroup(target_user.usrgroup());
+                    u.ischanged(true);
+                    return egCore.net.request(
+                        'open-ils.actor',
+                        'open-ils.actor.patron.update',
+                        egCore.auth.token(), u
+                    );
+                })
+            );
+        });
+
+        $q.all(promises).then(function() {grid.refresh()});
+    }
+
+    function showMoveToGroupConfirm(barcode, selected) {
+
+        // find the user
+        egCore.pcrud.search('ac', {barcode : barcode})
+
+        // fetch the fleshed user
+        .then(function(card) {
+
+            if (!card) return; // TODO: warn user
+
+            egCore.pcrud.retrieve('au', card.usr())
+            .then(function(user) {
+                user.card(card);
+                $modal.open({
+                    templateUrl: './circ/patron/t_move_to_group_dialog',
+                    controller: [
+                                '$scope','$modalInstance',
+                        function($scope , $modalInstance) {
+                            $scope.user = user;
+                            $scope.outbound = Boolean(selected);
+                            $scope.ok = 
+                                function(count) { $modalInstance.close() }
+                            $scope.cancel = 
+                                function () { $modalInstance.dismiss() }
+                        }
+                    ]
+                }).result.then(function() {
+                    if (selected) {
+                        moveUsersToGroup(user, selected);
+                    } else {
+                        addUserToGroup(user);
+                    }
+                });
+            });
+        });
+    }
+
+    // selected == move selected patrons to another patron's group
+    // !selected == patron from a different group moves into our group
+    function moveToGroup(selected) {
+        egPromptDialog.open(
+            egCore.strings.GROUP_ADD_USER, '',
+            {ok : function(value) {
+                if (value) 
+                    showMoveToGroupConfirm(value, selected);
+            }}
+        );
+    }
+
+    $scope.moveToGroup = function() { moveToGroup() };
+    $scope.moveToAnotherGroup = function(selected) { moveToGroup(selected) };
+
+    $scope.cloneUser = function(selected) {
+        if (!selected.length) return;
+        var url = $location.absUrl().replace(
+            /\/patron\/.*/, 
+            '/patron/register/clone/' + selected[0].id);
+        $window.open(url, '_blank').focus();
+    }
+
+    $scope.retrieveSelected = function(selected) {
+        if (!selected.length) return;
+        var url = $location.absUrl().replace(
+            /\/patron\/.*/, 
+            '/patron/' + selected[0].id + '/checkout');
+        $window.open(url, '_blank').focus();
+    }
+
+}])
+
+.controller('PatronStatCatsCtrl',
+       ['$scope','$routeParams','$q','egCore','patronSvc',
+function($scope,  $routeParams , $q , egCore , patronSvc) {
+    $scope.initTab('other', $routeParams.id)
+    .then(function(redirect) {
+        // Entries for org-visible stat cats are fleshed.  Any others
+        // have to be fleshed within.
+
+        var to_flesh = {};
+        angular.forEach(patronSvc.current.stat_cat_entries(), 
+            function(entry) {
+                if (!angular.isObject(entry.stat_cat())) {
+                    to_flesh[entry.stat_cat()] = entry;
+                }
+            }
+        );
+
+        if (!Object.keys(to_flesh).length) return;
+
+        egCore.pcrud.search('actsc', {id : Object.keys(to_flesh)})
+        .then(null, null, function(cat) { // stream
+            cat.owner(egCore.org.get(cat.owner())); // owner flesh
+            to_flesh[cat.id()].stat_cat(cat);
+        });
+    });
+}])
+
+.controller('PatronFetchLastCtrl',
+       ['$scope','$location','egCore',
+function($scope , $location , egCore) {
+
+    var id = egCore.hatch.getLocalItem('eg.circ.last_patron');
+    if (id) return $location.path('/circ/patron/' + id + '/checkout');
+
+    $scope.no_last = true;
+}])
+
+.controller('PatronTriggeredEventsCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope,  $routeParams,  $location , egCore , patronSvc) {
+    $scope.initTab('other', $routeParams.id);
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/event_log');
+    url += '?patron_id=' + encodeURIComponent($routeParams.id);
+
+    $scope.triggered_events_url = url;
+    $scope.funcs = {};
+}])
+
+.controller('PatronPermsCtrl',
+       ['$scope','$routeParams','$window','$location','egCore',
+function($scope , $routeParams , $window , $location , egCore) {
+    $scope.initTab('other', $routeParams.id);
+
+    var url = $location.absUrl().replace(
+        /\/eg\/staff.*/, '/xul/server/patron/user_edit.xhtml');
+
+    url += '?usr=' + encodeURIComponent($routeParams.id);
+
+    // user_edit does not load the session via cookie.  It uses URL 
+    // params or xulG instead.  Pass via xulG.
+    $scope.funcs = {
+        ses : egCore.auth.token(),
+        on_patron_save : function() {
+            $scope.funcs.reload();
+        }
+    }
+
+    $scope.user_perms_url = url;
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js b/Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
new file mode 100644 (file)
index 0000000..5b83989
--- /dev/null
@@ -0,0 +1,731 @@
+
+/* Billing Service */
+
+angular.module('egPatronApp')
+
+.factory('billSvc', 
+       ['$q','egCore','patronSvc',
+function($q , egCore , patronSvc) {
+
+    var service = {};
+
+    // fetch org unit settings specific to the bills display
+    service.fetchBillSettings = function() {
+        if (service.settings) return $q.when(service.settings);
+        return egCore.org.settings(
+            ['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']
+        ).then(function(s) {return service.settings = s});
+    }
+
+    // user billing summary
+    service.fetchSummary = function() {
+        return egCore.pcrud.retrieve(
+            'mous', service.userId, {}, {authoritative : true})
+        .then(function(summary) {return service.summary = summary})
+    }
+
+    service.applyPayment = function(type, payments, note) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.payment',
+            egCore.auth.token(), {
+                userid : service.userId,
+                note : note || '', 
+                payment_type : type,
+                payments : payments,
+                patron_credit : 0
+            },
+            patronSvc.current.last_xact_id()
+        ).then(function(resp) {
+            console.debug('payments: ' + js2JSON(resp));
+            if (evt = egCore.evt.parse(resp)) 
+                return alert(evt);
+
+            // payment API returns the update xact id so we can track it
+            // for future payments without having to refresh the user.
+            patronSvc.current.last_xact_id(resp.last_xact_id);
+            return resp.payments;
+        });
+    }
+
+    service.fetchBills = function(xact_id) {
+        var bills = [];
+        return egCore.pcrud.search('mb',
+            {xact : xact_id}, null,
+            {authoritative : true}
+        ).then(
+            function() {return bills},
+            null,
+            function(bill) {bills.push(bill); return bill}
+        );
+    }
+
+    // TODO: no longer needed?
+    service.fetchPayments = function(xact_id) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.payment.retrieve.all.authoritative',
+            egCore.auth.token(), xact_id
+        );
+    }
+
+    service.voidBills = function(bill_ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.billing.void',
+            [egCore.auth.token()].concat(bill_ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    service.updateBillNotes = function(note, ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.billing.note.edit',
+            [egCore.auth.token(), note].concat(ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    service.updatePaymentNotes = function(note, ids) {
+        return egCore.net.requestWithParamList(
+            'open-ils.circ',
+            'open-ils.circ.money.payment.note.edit',
+            [egCore.auth.token(), note].concat(ids)
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) return alert(evt);
+            return resp;
+        });
+    }
+
+    return service;
+}])
+
+
+/**
+ * Manages Bills
+ */
+.controller('PatronBillsCtrl',
+       ['$scope','$q','$routeParams','egCore','egConfirmDialog','$location',
+        'egGridDataProvider','billSvc','patronSvc','egPromptDialog','$modal',
+        'egBilling',
+function($scope , $q , $routeParams , egCore , egConfirmDialog , $location,
+         egGridDataProvider , billSvc , patronSvc , egPromptDialog , $modal,
+         egBilling) {
+
+    $scope.initTab('bills', $routeParams.id);
+    billSvc.userId = $routeParams.id;
+
+    // set up some defaults
+    $scope.payment_amount = 0;
+    $scope.session_voided = 0;
+    $scope.payment_type = 'cash_payment';
+    $scope.focus_payment = true;
+    $scope.annotate_payment = false;
+    $scope.receipt_count = 1;
+    $scope.receipt_on_pay = false;
+
+    // pre-define list-returning funcs in case we access them
+    // before the grid instantiates
+    $scope.gridControls = {
+        focusRowSelector : false,
+        selectedItems : function(){return []},
+        allItems : function(){return []},
+        itemRetrieved : function(item) {
+            item.payment_pending = 0;
+        },
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setQuery : function() {    
+            return {
+                usr : billSvc.userId, 
+                xact_finish : null,
+                'summary.balance_owed' : {'<>' : 0}
+            }
+        }, 
+        setSort : function() {
+            return ['xact_start']; 
+        }
+    }
+
+    billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+
+    // given a payment amount, determines how much of that is applied
+    // to selected transactions and how much is left over (change).
+    function pending_payment_info() {
+        var amt = $scope.payment_amount || 0;
+        if (amt >= $scope.owed_selected()) {
+            return {
+                payment : $scope.owed_selected(),
+                change : amt - $scope.owed_selected()
+            }
+        } 
+        return {payment : amt, change : 0};
+    }
+
+    // calculates amount owed, billed, and paid for selected items
+    // TODO: move me to service
+    function selected_payment_info() {
+        var info = {owed : 0, billed : 0, paid : 0};
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            info.owed   += Number(item['summary.balance_owed']) * 100;
+            info.billed += Number(item['summary.total_owed']) * 100;
+            info.paid   += Number(item['summary.total_paid']) * 100;
+        });
+        info.owed /= 100;
+        info.billed /= 100;
+        info.paid /= 100;
+        return info;
+    }
+
+    $scope.pending_payment = function() {
+        return pending_payment_info().payment;
+    }
+    $scope.pending_change = function() {
+        return pending_payment_info().change;
+    }
+    $scope.owed_selected = function() {
+        return selected_payment_info().owed; 
+    }
+    $scope.billed_selected = function() {
+        return selected_payment_info().billed;
+    }
+    $scope.paid_selected = function() {
+        return selected_payment_info().paid;
+    }
+    $scope.refunds_available = function() {
+        var amount = 0;
+        angular.forEach($scope.gridControls.allItems(), function(item) {
+            if (item['summary.balance_owed'] < 0) 
+                amount += item['summary.balance_owed'] * 100;
+        });
+        return -(amount / 100);
+    }
+
+    // update the item.payment_pending value each time the user
+    // selects different transactions to pay against.
+    $scope.$watch(
+        function() {return $scope.gridControls.selectedItems()},
+        function() {updatePendingColumn()},
+        true
+    );
+
+    // update the item.payment_pending for each (selected) 
+    // transaction any time the user-entered payment amount is modified
+    $scope.$watch('payment_amount', updatePendingColumn);
+
+    // updates the value of the payment_pending column in the grid.
+    // This has to be managed manually since the display value in the grid
+    // is derived from the value on the stored item and not the contents
+    // of our local scope variables.
+    function updatePendingColumn() {
+        // reset all to zero..
+        angular.forEach($scope.gridControls.allItems(), 
+            function(item) {item.payment_pending = 0});
+
+        var payment_amount = $scope.pending_payment();
+
+        var selected = $scope.gridControls.selectedItems();
+        for (var i = 0; i < selected.length; i++) { // for/break
+            var item = selected[i];
+            var owed = Number(item['summary.balance_owed']);
+
+            if (payment_amount > owed) {
+                // pending payment exceeds balance of current item.
+                // pay the entire item.
+                item.payment_pending = owed;
+                payment_amount -= owed;
+
+            } else {
+                // balance owed on the current item matches or exceeds
+                // the pending payment.  Apply the full remainder of
+                // the payment to this item.. and we're done.
+                item.payment_pending = payment_amount;
+                break;
+            }
+        }
+    }
+
+    // builds payment arrays ([xact_id, ammount]) for all transactions
+    // which have a pending payment amount.
+    function generatePayments() {
+        var payments = [];
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            if (item.payment_pending == 0) return;
+            payments.push([item.id, item.payment_pending]);
+        });
+        return payments;
+    }
+
+    function refreshDisplay() {
+        patronSvc.fetchUserStats();
+        billSvc.fetchSummary().then(function(s) {$scope.summary = s});
+        $scope.payment_amount = 0;
+        $scope.gridControls.refresh();
+    }
+
+    // generates payments, collects user note if needed, and sends payment
+    // to server.
+    function sendPayment(note) {
+        var make_payments = generatePayments();
+        billSvc.applyPayment(
+            $scope.payment_type, make_payments, note)
+        .then(function(payment_ids) {
+
+            if ($scope.receipt_on_pay) {
+                printReceipt(
+                    $scope.payment_type, payment_ids, make_payments, note);
+            }
+
+            refreshDisplay();
+        })
+    }
+
+    function printReceipt(type, payment_ids, payments_made, note) {
+        var payment_blobs = [];
+        angular.forEach(payments_made, function(payment) {
+            var xact_id = payment[0];
+
+            // find the original transaction in the grid..
+            var xact = $scope.gridControls.allItems().filter(
+                function(item) {return item.id == xact_id})[0];
+
+            payment_blobs.push({
+                xact : egCore.idl.flatToNestedHash(xact),
+                amount : payment[1]
+            });
+        });
+
+        console.log(js2JSON(payment_blobs[0]));
+
+        // page data not yet refreshed, capture data from current scope
+        var print_data = {
+            payment_note : note,
+            previous_balance : Number($scope.summary.balance_owed()),
+            payment_total : Number($scope.payment_amount),
+            payment_applied : $scope.pending_payment(),
+            amount_voided : Number($scope.session_voided),
+            change_given : $scope.pending_change(),
+            payments : payment_blobs,
+            current_location : egCore.idl.toHash(
+                egCore.org.get(egCore.auth.user().ws_ou()))
+        }
+
+        print_data.new_balance = (
+            print_data.previous_balance * 100 - 
+            print_data.payment_applied * 100) / 100;
+
+        for (var i = 0; i < $scope.receipt_count; i++) {
+            egCore.print.print({
+                context : 'receipt', 
+                template : 'bill_payment', 
+                scope : print_data
+            });
+        }
+    }
+
+    $scope.showHistory = function() {
+        $location.path('/circ/patron/' + 
+            patronSvc.current.id() + '/bill_history/transactions');
+    }
+    
+    // For now, only adds billing to first selected item.
+    // Could do batches later if needed
+    $scope.addBilling = function(all) {
+        if (all[0]) {
+            egBilling.showBillDialog({
+                xact : egCore.idl.flatToNestedHash(all[0]),
+                patron : $scope.patron()
+            }).then(refreshDisplay);
+        }
+    }
+
+    $scope.showBillDialog = function($event) {
+        egBilling.showBillDialog({
+            patron : $scope.patron()
+        }).then(refreshDisplay);
+    }
+
+    // Select refunds adds all refunds to the existing selection.
+    // It does not /only/ select refunds
+    $scope.selectRefunds = function() {
+        var ids = $scope.gridControls.selectedItems().map(
+            function(i) { return i.id });
+        angular.forEach($scope.gridControls.allItems(), function(item) {
+            if (Number(item['summary.balance_owed']) < 0)
+                ids.push(item.id);
+        });
+        $scope.gridControls.selectItems(ids);
+    }
+
+    // -------------
+    // determine on initial page load when all of the grid rows should
+    // be selected.
+    var selectOnLoad = true;
+    billSvc.fetchBillSettings().then(function(s) {
+        if (s['ui.circ.billing.uncheck_bills_and_unfocus_payment_box']) {
+            $scope.focus_payment = false; // de-focus the payment box
+            $scope.gridControls.focusRowSelector = true;
+            selectOnLoad = false;
+            // if somehow the grid finishes rendering before our settings 
+            // arrive, manually de-select everything.
+            $scope.gridControls.selectItems([]);
+        }
+    });
+
+    $scope.gridControls.allItemsRetrieved = function() {
+        if (selectOnLoad) {
+            selectOnLoad = false; // only for initial controller load.
+            // select all non-refund items
+            $scope.gridControls.selectItems( 
+                $scope.gridControls.allItems()
+                .filter(function(i) {return i['summary.balance_owed'] > 0})
+                .map(function(i){return i.id})
+            );
+        }
+    }
+    // -------------
+
+
+    $scope.printBills = function(selected) {
+        if (!selected.length) return;
+        // bills print receipt assumes nested hashes, but our grid
+        // stores flattener data.  Fetch the selected xacts as
+        // fleshed pcrud objects and hashify.  
+        // (Consider an alternate approach..)
+        var ids = selected.map(function(t){ return t.id });
+        var xacts = [];
+        egCore.pcrud.search('mbt', 
+            {id : ids},
+            {flesh : 1, flesh_fields : {'mbt' : ['summary']}},
+            {authoritative : true}
+        ).then(
+            function() {
+                egCore.print.print({
+                    context : 'receipt', 
+                    template : 'bills_current', 
+                    scope : {   
+                        transactions : xacts,
+                        current_location : egCore.idl.toHash(
+                            egCore.org.get(egCore.auth.user().ws_ou()))
+                    }
+                });
+            }, 
+            null, 
+            function(xact) {
+                xacts.push(egCore.idl.toHash(xact));
+            }
+        );
+    }
+
+    $scope.applyPayment = function() {
+        if ($scope.annotate_payment) {
+            egPromptDialog.open(
+                egCore.strings.ANNOTATE_PAYMENT_MSG, '',
+                {ok : function(value) {sendPayment(value)}}
+            );
+        } else {
+            sendPayment();
+        }
+    }
+
+    $scope.voidAllBillings = function(items) {
+        angular.forEach(items, function(item) {
+
+            billSvc.fetchBills(item.id).then(function(bills) {
+                var bill_ids = [];
+                var cents = 0;
+                angular.forEach(bills, function(b) {
+                    if (b.voided() != 't') {
+                        cents += b.amount() * 100;
+                        bill_ids.push(b.id())
+                    }
+                });
+
+                $scope.session_voided = 
+                    ($scope.session_voided * 100 + cents) / 100;
+
+                if (bill_ids.length == 0) {
+                    // TODO: warn
+                    return;
+                }
+
+                // TODO: alert of pending voiding
+
+                billSvc.voidBills(bill_ids).then(function() {
+                    refreshDisplay();
+                });
+            });
+        });
+    }
+
+    // note this is functionally equivalent to selecting a neg. transaction
+    // then clicking Apply Payment -- this just adds a speed bump (ditto
+    // the XUL client).
+    $scope.refundXact = function(all) {
+        var items = all.filter(function(item) {
+            return item['summary.balance_owed'] < 0
+        });
+
+        if (items.length == 0) return;
+
+        var ids = items.map(function(item) {return item.id});
+            
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_REFUND_PAYMENT, '', 
+            {   xactIds : ''+ids,
+                ok : function() {
+                    // reset the received payment amount.  this ensures
+                    // we're not mingling payments with refunds.
+                    $scope.payment_amount = 0;
+                }
+            }
+        );
+    }
+
+    // direct the user to the transaction details page
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0].id);
+    }
+
+    $scope.activateBill = function(xact) {
+        $scope.showFullDetails([xact]);
+    }
+
+}])
+
+/**
+ * Displays details of a single transaction
+ */
+.controller('XactDetailsCtrl',
+       ['$scope','$q','$routeParams','egCore','egGridDataProvider','patronSvc','billSvc','egPromptDialog','egBilling',
+function($scope,  $q , $routeParams , egCore , egGridDataProvider , patronSvc , billSvc , egPromptDialog , egBilling) {
+
+    $scope.initTab('bills', $routeParams.id);
+    var xact_id = $routeParams.xact_id;
+
+    var xactGrid = $scope.xactGridControls = {
+        setQuery : function() { return {xact : xact_id} },
+        setSort : function() { return ['billing_ts'] }
+    }
+
+    var paymentGrid = $scope.paymentGridControls = {
+        setQuery : function() { return {xact : xact_id} },
+        setSort : function() { return ['payment_ts'] }
+    }
+
+    // -- actions
+    $scope.voidBillings = function(bill_list) {
+        var bill_ids = [];
+        angular.forEach(bill_list, function(b) {
+            if (b.voided != 't') bill_ids.push(b.id);
+        });
+
+        if (bill_ids.length == 0) {
+            // TODO: warn
+            return;
+        }
+
+        billSvc.voidBills(bill_ids).then(function() {
+
+            // refresh bills and summary data
+            // note: no need to update payments
+            patronSvc.fetchUserStats();
+
+            egBilling.fetchXact(xact_id).then(function(xact) {
+                $scope.xact = xact
+            });
+
+            xactGrid.refresh();
+        });
+    }
+
+    // batch-edit billing and payment notes, depending on 'type'
+    function editNotes(selected, type) {
+        var notes = selected.map(function(b){ return b.note }).join(',');
+        var ids = selected.map(function(b){ return b.id });
+
+        // show the note edit prompt
+        egPromptDialog.open(
+            egCore.strings.EDIT_BILL_PAY_NOTE, notes, {
+                ids : ''+ids,
+                ok : function(value) {
+
+                    var func = 'updateBillNotes';
+                    if (type == 'payment') func = 'updatePaymentNotes';
+
+                    billSvc[func](value, ids).then(function() {
+                        if (type == 'payment') {
+                            paymentGrid.refresh();
+                        } else {
+                            xactGrid.refresh();
+                        }
+                    });
+                }
+            }
+        );
+    }
+
+    $scope.editBillNotes = function(selected) {
+        editNotes(selected, 'bill');
+    }
+
+    $scope.editPaymentNotes = function(selected) {
+        editNotes(selected, 'payment');
+    }
+
+    // -- retrieve our data
+    egBilling.fetchXact(xact_id).then(function(xact) {
+        $scope.xact = xact;
+
+        // set the title.  only needs to be done on initial page load
+        if (xact.circulation()) {
+            if (xact.circulation().target_copy().call_number().id() == -1) {
+                $scope.title = xact.circulation().target_copy().dummy_title();
+            } else  {
+                // TODO: shared bib service?
+                $scope.title = xact.circulation().target_copy()
+                    .call_number().record().simple_record().title();
+                $scope.title_id = xact.circulation().target_copy()
+                    .call_number().record().id();
+            }
+        }
+    });
+}])
+
+
+.controller('BillHistoryCtrl',
+       ['$scope','$q','$routeParams','egCore','patronSvc','billSvc','egPromptDialog','$location',
+function($scope,  $q , $routeParams , egCore , patronSvc , billSvc , egPromptDialog , $location) {
+
+    $scope.initTab('bills', $routeParams.id);
+    billSvc.userId = $routeParams.id;
+    $scope.bill_tab = $routeParams.history_tab;
+    $scope.totals = {};
+
+    var start = new Date(); // now - 1 year
+    start.setFullYear(start.getFullYear() - 1),
+    $scope.dates = {
+        xact_start : start,
+        xact_finish : new Date()
+    }
+
+    $scope.date_range = function() {
+        var start = $scope.dates.xact_start.toISOString().replace(/T.*/,'');
+        var end = $scope.dates.xact_finish.toISOString().replace(/T.*/,'');
+        var today = new Date().toISOString().replace(/T.*/,'');
+        if (end == today) end = 'now';
+        return [start, end];
+    }
+}])
+
+
+.controller('BillXactHistoryCtrl',
+       ['$scope','$q','egCore','patronSvc','billSvc','egPromptDialog','$location','egBilling',
+function($scope,  $q , egCore , patronSvc , billSvc , egPromptDialog , $location , egBilling) {
+
+    $scope.gridControls = {
+        selectedItems : function(){return []},
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setQuery : function() {
+            // open-ils.actor.user.transactions.history.have_bill_or_payment
+            return {
+                '-or' : [
+                    {'summary.balance_owed' : {'<>' : 0}},
+                    {'summary.last_payment_ts' : {'<>' : null}}
+                ],
+                xact_start : {between : $scope.date_range()},
+                usr : billSvc.userId
+            }
+        }
+    }
+
+
+    // TODO; move me to service
+    function selected_payment_info() {
+        var info = {owed : 0, billed : 0, paid : 0};
+        angular.forEach($scope.gridControls.selectedItems(), function(item) {
+            info.owed   += Number(item['summary.balance_owed']) * 100;
+            info.billed += Number(item['summary.total_owed']) * 100;
+            info.paid   += Number(item['summary.total_paid']) * 100;
+        });
+        info.owed /= 100;
+        info.billed /= 100;
+        info.paid /= 100;
+        return info;
+    }
+
+    $scope.totals.selected_billed = function() {
+        return selected_payment_info().billed;
+    }
+    $scope.totals.selected_paid = function() {
+        return selected_payment_info().paid;
+    }
+
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0].id);
+    }
+
+    // For now, only adds billing to first selected item.
+    // Could do batches later if needed
+    $scope.addBilling = function(all) {
+        if (all[0]) {
+            egBilling.showBillDialog({
+                xact : egCore.idl.flatToNestedHash(all[0]),
+                patron : $scope.patron()
+            }).then(function() { 
+                $scope.gridControls.refresh();
+                patronSvc.fetchUserStats();
+            })
+        }
+    }
+}])
+
+.controller('BillPaymentHistoryCtrl',
+       ['$scope','$q','egCore','patronSvc','billSvc','$location',
+function($scope,  $q , egCore , patronSvc , billSvc , $location) {
+
+    $scope.gridControls = {
+        selectedItems : function(){return []},
+        activateItem : function(item) {
+            $scope.showFullDetails([item]);
+        },
+        setSort : function() {
+            return [{'payment_ts' : 'DESC'}, 'id'];
+        },
+        setQuery : function() {
+            return {
+                'payment_ts' : {between : $scope.date_range()},
+                'xact.usr' : billSvc.userId
+            }
+        }
+    }
+
+    $scope.showFullDetails = function(all) {
+        if (all[0]) 
+            $location.path('/circ/patron/' + 
+                patronSvc.current.id() + '/bill/' + all[0]['xact.id']);
+    }
+
+    $scope.totals.selected_paid = function() {
+        var paid = 0;
+        angular.forEach($scope.gridControls.selectedItems(), function(payment) {
+            paid += Number(payment.amount) * 100;
+        });
+        return paid / 100;
+    }
+}])
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js b/Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js
new file mode 100644 (file)
index 0000000..5ee1bc5
--- /dev/null
@@ -0,0 +1,201 @@
+/**
+ * Checkout items to patrons
+ */
+
+angular.module('egPatronApp').controller('PatronCheckoutCtrl',
+
+       ['$scope','$q','$modal','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','$location','$timeout','egCirc',
+
+function($scope , $q , $modal , $routeParams , egCore , egUser , patronSvc , 
+         egGridDataProvider , $location , $timeout , egCirc) {
+
+    $scope.initTab('checkout', $routeParams.id);
+    $scope.focusMe = true;
+    $scope.checkouts = patronSvc.checkouts;
+    $scope.checkoutArgs = {
+        noncat_type : 'barcode',
+        due_date : new Date()
+    };
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.checkouts, offset, count);
+        }
+    });
+
+    $scope.disable_checkout = function() {
+        return (
+            !patronSvc.current ||
+            patronSvc.current.active() == 'f' ||
+            patronSvc.current.deleted() == 't' ||
+            patronSvc.current.card().active() == 'f'
+        );
+    }
+
+    $scope.using_hatch = egCore.hatch.usingHatch();
+
+    // avoid multiple, in-flight attempts on the same barcode
+    var pending_barcodes = {};
+
+    var printOnComplete = true;
+    egCore.org.settings([
+        'circ.staff_client.do_not_auto_attempt_print'
+    ]).then(function(settings) { 
+        printOnComplete = !Boolean(
+            settings['circ.staff_client.do_not_auto_attempt_print']);
+    });
+
+    egCirc.get_noncat_types().then(function(list) {
+        $scope.nonCatTypes = list;
+    });
+
+    $scope.selectedNcType = function() {
+        if (!egCore.env.cnct) return null; // too soon
+        var type = egCore.env.cnct.map[$scope.checkoutArgs.noncat_type];
+        return type ? type.name() : null;
+    }
+
+    $scope.checkout = function(args) {
+        var params = angular.copy(args);
+        params.patron_id = patronSvc.current.id();
+
+        if (args.sticky_date) {
+            params.due_date = args.due_date.toISOString();
+        } else {
+            delete params.due_date;
+        }
+        delete params.sticky_date;
+
+        if (params.noncat_type == 'barcode') {
+            if (!args.copy_barcode) return;
+
+            args.copy_barcode = ''; // reset UI input
+            params.noncat_type = ''; // "barcode"
+
+            if (pending_barcodes[params.copy_barcode]) {
+                console.log(
+                    "Skipping checkout of redundant barcode " 
+                    + params.copy_barcode
+                );
+                return;
+            }
+
+            pending_barcodes[params.copy_barcode] = true;
+            send_checkout(params);
+
+        } else {
+            egCirc.noncat_dialog(params).then(function() {
+                send_checkout(params)
+            });
+        }
+
+        $scope.focusMe; // return focus to barcode input
+    }
+
+    function send_checkout(params) {
+
+        params.noncat_type = params.noncat ? params.noncat_type : '';
+
+        // populate the grid row before we send the request so that the
+        // order of actions is maintained and so the user gets an 
+        // immediate reaction to their barcode input action.
+        var row_item = {
+            index : $scope.checkouts.length,
+            copy_barcode : params.copy_barcode,
+            noncat_type : params.noncat_type
+        };
+
+        $scope.checkouts.unshift(row_item);
+        $scope.gridDataProvider.refresh();
+
+        var options = {check_barcode : $scope.strict_barcode};
+
+        egCirc.checkout(params, options).then(
+            function(co_resp) {
+                // update stats locally so we don't have to fetch them w/
+                // each checkout.
+                patronSvc.patron_stats.checkouts.out++;
+                patronSvc.patron_stats.checkouts.total_out++;
+
+                // copy the response event into the original grid row item
+                // note: angular.copy clobbers the destination
+                row_item.evt = co_resp.evt;
+                angular.forEach(co_resp.data, function(val, key) {
+                    row_item[key] = val;
+                });
+                munge_checkout_resp(co_resp, row_item);
+            },
+            function() {
+                // Circ was rejected somewhere along the way.
+                // Remove the copy from the grid since there was no action.
+                // note: since checkouts are unshifted onto the array, the
+                // index value does not (generally) match the array position.
+                var pos = -1;
+                angular.forEach($scope.checkouts, function(co, idx) {
+                    if (co.index == row_item.index) pos = idx;
+                });
+                $scope.checkouts.splice(pos, 1);
+                $scope.gridDataProvider.refresh();
+            }
+
+        )['finally'](function() {
+
+            // regardless of the outcome of the circ, remove the 
+            // barcode from the pending list.
+            if (params.copy_barcode)
+                delete pending_barcodes[params.copy_barcode];
+        });
+    }
+
+    // add some checkout-specific additions for display
+    function munge_checkout_resp(co_resp, row_item) {
+        var params = co_resp.params;
+        if (params.noncat) {
+            row_item.title = egCore.env.cnct.map[params.noncat_type].name();
+            row_item.noncat_count = params.noncat_count;
+            row_item.circ = new egCore.idl.circ();
+            row_item.circ.due_date(co_resp.evt.payload.noncat_circ.duedate());
+        }
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {circulations : []}
+
+        if ($scope.checkouts.length == 0) return $q.when();
+
+        angular.forEach($scope.checkouts, function(co) {
+            if (co.circ) {
+                print_data.circulations.push({
+                    circ : egCore.idl.toHash(co.circ),
+                    copy : egCore.idl.toHash(co.acp),
+                    call_number : egCore.idl.toHash(co.acn),
+                    title : co.title,
+                    author : co.author
+                })
+            };
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'checkout', 
+            scope : print_data,
+            show_dialog : $scope.show_print_dialog
+        });
+    }
+
+    // Redirect the user to the barcode entry page to load a new patron.
+    // If configured to do so, print the receipt first
+    $scope.done = function() {
+        if (printOnComplete) {
+
+            $scope.print_receipt().then(function() {
+                $location.path('/circ/patron/bcsearch');
+            });
+
+        } else {
+            $location.path('/circ/patron/bcsearch');
+        }
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/holds.js b/Open-ILS/web/js/ui/default/staff/circ/patron/holds.js
new file mode 100644 (file)
index 0000000..c42a162
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * List of patron holds
+ */
+
+angular.module('egPatronApp').controller('PatronHoldsCtrl',
+
+       ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','egHolds','$window','$location','egCirc','egHoldGridActions',
+function($scope,  $q,  $routeParams,  egCore,  egUser,  patronSvc,  
+        egGridDataProvider , egHolds , $window , $location , egCirc, egHoldGridActions) {
+
+    $scope.initTab('holds', $routeParams.id);
+    $scope.holds_display = 'main';
+    $scope.detail_hold_id = $routeParams.hold_id;
+    $scope.grid_actions = egHoldGridActions;
+
+    function refresh_all() {
+        patronSvc.refreshPrimary();
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh() 
+    }
+    $scope.grid_actions.refresh = refresh_all;
+
+    $scope.show_main_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.holds_display = 'main';
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh();
+    }
+
+    $scope.show_alt_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.holds_display = 'alt';
+        patronSvc.holds = [];
+        patronSvc.hold_ids = [];
+        provider.refresh();
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function fetchHolds(offset, count) {
+        var ids = patronSvc.hold_ids.slice(offset, offset + count);
+        return egHolds.fetch_holds(ids).then(null, null,
+            function(hold_data) { 
+                patronSvc.holds.push(hold_data);
+                return hold_data;
+            }
+        );
+    }
+
+    provider.get = function(offset, count) {
+
+        // see if we have the requested range cached
+        if (patronSvc.holds[offset]) {
+            return provider.arrayNotifier(patronSvc.holds, offset, count);
+        }
+
+        // see if we have the holds IDs for this range already loaded
+        if (patronSvc.hold_ids[offset]) {
+            return fetchHolds(offset, count);
+        }
+
+        var deferred = $q.defer();
+        patronSvc.hold_ids = [];
+
+        var method = 'open-ils.circ.holds.id_list.retrieve.authoritative';
+        if ($scope.holds_display == 'alt')
+            method = 'open-ils.circ.holds.canceled.id_list.retrieve.authoritative';
+
+        egCore.net.request(
+            'open-ils.circ', method,
+            egCore.auth.token(), $scope.patron_id
+
+        ).then(function(hold_ids) {
+            if (!hold_ids.length) { deferred.resolve(); return; }
+
+            patronSvc.hold_ids = hold_ids;
+            fetchHolds(offset, count)
+            .then(deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+    $scope.print = function() {
+        var holds = [];
+        angular.forEach(patronSvc.holds, function(item) {
+            holds.push({
+                hold : egCore.idl.toHash(item.hold),
+                copy : egCore.idl.toHash(item.copy),
+                volume : egCore.idl.toHash(item.volume),
+                title : item.mvr.title(),
+                author : item.mvr.author()
+            });
+        });
+
+        egCore.print.print({
+            context : 'receipt', 
+            template : 'holds_for_patron', 
+            scope : {holds : holds}
+        });
+    }
+
+    $scope.detail_view = function(action, user_data, items) {
+        if (h = items[0]) {
+            $location.path('/circ/patron/' + 
+                $scope.patron_id + '/holds/' + h.hold.id());
+        }
+    }
+
+    $scope.list_view = function(items) {
+        $location.path('/circ/patron/' + $scope.patron_id + '/holds');
+    }
+
+    $scope.place_hold = function() {
+        $location.path($location.path() + '/create');
+    }
+
+    // when the detail hold is fetched (and updated), update the bib
+    // record summary display record id.
+    $scope.set_hold = function(hold_data) {
+        $scope.detail_hold_record_id = hold_data.mvr.doc_id();
+    }
+
+}])
+
+
+.controller('PatronHoldsCreateCtrl',
+       ['$scope','$routeParams','$location','egCore','patronSvc',
+function($scope , $routeParams , $location , egCore , patronSvc) {
+
+    $scope.handlers = {
+        opac_hold_placed : function() {
+            // FIXME: this isn't getting called.. not sure why
+            patronSvc.fetchUserStats(); // update hold counts
+        }
+    }
+
+    $scope.initTab('holds', $routeParams.id).then(function(isAlert) {
+        if (isAlert) return;
+        // not guarenteed to have a barcode until init fetches the user
+        $scope.handlers.patron_barcode = patronSvc.current.card().barcode();
+    });
+
+    $scope.catalog_url = 
+        $location.absUrl().replace(/\/staff.*/, '/opac/advanced');
+
+    $scope.handle_page = function(url) {
+    }
+
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js b/Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js
new file mode 100644 (file)
index 0000000..fa68501
--- /dev/null
@@ -0,0 +1,406 @@
+/**
+ * List of patron items checked out
+ */
+
+angular.module('egPatronApp')
+
+.controller('PatronItemsOutCtrl',
+       ['$scope','$q','$routeParams','egCore','egUser','patronSvc',
+        'egGridDataProvider','$modal','egCirc','egConfirmDialog','egBilling',
+function($scope,  $q,  $routeParams,  egCore , egUser,  patronSvc , 
+         egGridDataProvider , $modal , egCirc , egConfirmDialog , egBilling) {
+    $scope.initTab('items_out', $routeParams.id);
+
+    // cache of circ objects for grid display
+    patronSvc.items_out = [];
+
+    // main list of checked out items
+    $scope.main_list = [];
+
+    // list of alt circs (lost, etc.) and/or check-in with fines circs
+    $scope.alt_list = []; 
+
+    // these are fetched during startup (i.e. .configure())
+    // By default, show lost/lo/cr items in the alt list
+    var display_lost = Number(
+        egCore.env.aous['ui.circ.items_out.lost']) || 2;
+    var display_lo = Number(
+        egCore.env.aous['ui.circ.items_out.longoverdue']) || 2;
+    var display_cr = Number(
+        egCore.env.aous['ui.circ.items_out.claimsreturned']) || 2;
+
+    var fetch_checked_in = true;
+    $scope.show_alt_circs = true;
+    if (display_lost & 4 && display_lo & 4 && display_cr & 4) {
+        // all special types are configured to be hidden once
+        // checked in, so there's no need to fetch checked-in circs.
+        fetch_checked_in = false;
+
+        if (display_lost & 1 && display_lo & 1 && display_cr & 1) {                 
+            // additionally, if all types are configured to display    
+            // in the main list while checked out, nothing will         
+            // ever appear in the alternate list, so we can hide          
+            // the alternate list from the UI.  
+            $scope.show_alt_circs = false;
+        }
+    }
+
+    $scope.items_out_display = 'main';
+    $scope.show_main_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.items_out_display = 'main';
+        patronSvc.items_out = [];
+        provider.refresh();
+    }
+
+    $scope.show_alt_list = function() {
+        // don't need a full reset_page() to swap tabs
+        $scope.items_out_display = 'alt';
+        patronSvc.items_out = [];
+        provider.refresh();
+    }
+
+    // Reload the user to pick up changes in items out, fines, etc.
+    // Reload circs since the contents of the main vs. alt list may
+    // have changed.
+    function reset_page() {
+        patronSvc.refreshPrimary();
+        patronSvc.items_out = []; 
+        $scope.main_list = [];
+        $scope.alt_list = [];
+        provider.refresh() 
+    }
+
+    var provider = egGridDataProvider.instance({});
+    $scope.gridDataProvider = provider;
+
+    function fetch_circs(id_list, offset, count) {
+        if (!id_list.length) return $q.when();
+
+        // fetch the lot of circs and stream the results back via notify
+        return egCore.pcrud.search('circ', {id : id_list},
+            {   flesh : 4,
+                flesh_fields : {
+                    circ : ['target_copy'],
+                    acp : ['call_number'],
+                    acn : ['record'],
+                    bre : ['simple_record']
+                },
+                // avoid fetching the MARC blob by specifying which 
+                // fields on the bre to select.  More may be needed.
+                // note that fleshed fields are explicitly selected.
+                select : { bre : ['id'] },
+                limit  : count,
+                offset : offset,
+                // we need an order-by to support paging
+                order_by : {circ : ['xact_start']} 
+
+        }).then(null, null, function(circ) {
+            circ.circ_lib(egCore.org.get(circ.circ_lib())); // local fleshing
+
+            if (circ.target_copy().call_number().id() == -1) {
+                // dummy-up a record for precat items
+                circ.target_copy().call_number().record().simple_record({
+                    title : function() {return circ.target_copy().dummy_title()},
+                    author : function() {return circ.target_copy().dummy_author()},
+                    isbn : function() {return circ.target_copy().dummy_isbn()}
+                })
+            }
+
+            patronSvc.items_out.push(circ); // toss it into the cache
+            return circ;
+        });
+    }
+
+    // decide which list each circ belongs to
+    function promote_circs(list, display_code, open) {
+        if (open) {                                                    
+            if (1 & display_code) { // bitflag 1 == top list                   
+                $scope.main_list = $scope.main_list.concat(list);
+            } else {                                                   
+                $scope.alt_list = $scope.alt_list.concat(list);
+            }                                                          
+        } else {                                                       
+            if (4 & display_code) return;  // bitflag 4 == hide on checkin     
+            $scope.alt_list = $scope.alt_list.concat(list);
+        } 
+    }
+
+    // fetch IDs for circs we care about
+    function get_circ_ids() {
+        $scope.main_list = [];
+        $scope.alt_list = [];
+
+        // we can fetch these in parallel
+        var promise1 = egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_out.authoritative',
+            egCore.auth.token(), $scope.patron_id
+        ).then(function(outs) {
+            $scope.main_list = outs.out.concat(outs.overdue);
+            promote_circs(outs.lost, display_lost, true);                            
+            promote_circs(outs.long_overdue, display_lo, true);             
+            promote_circs(outs.claims_returned, display_cr, true);
+        });
+
+        // only fetched checked-in-with-bills circs if configured to display
+        var promise2 = !fetch_checked_in ? $q.when() : egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.checked_in_with_fines.authoritative',
+            egCore.auth.token(), $scope.patron_id
+        ).then(function(outs) {
+            promote_circs(outs.lost, display_lost);
+            promote_circs(outs.long_overdue, display_lo);
+            promote_circs(outs.claims_returned, display_cr);
+        });
+
+        return $q.all([promise1, promise2]);
+    }
+
+    provider.get = function(offset, count) {
+
+        var id_list = $scope[$scope.items_out_display + '_list'];
+
+        // see if we have the requested range cached
+        if (patronSvc.items_out[offset]) {
+            return provider.arrayNotifier(
+                patronSvc.items_out, offset, count);
+        }
+
+        // See if we have the circ IDs for this range already loaded.
+        // this would happen navigating to a subsequent page.
+        if (id_list[offset]) {
+            return fetch_circs(id_list, offset, count);
+        }
+
+        // avoid returning the request directly to the caller so the
+        // notify()'s from egCore.net.request don't leak into the 
+        // final set of notifies (i.e. the real responses);
+
+        var deferred = $q.defer();
+        get_circ_ids().then(function() {
+
+            id_list = $scope[$scope.items_out_display + '_list'];
+
+            // relay the notified circs back to the grid through our promise
+            fetch_circs(id_list, offset, count).then(
+                deferred.resolve, null, deferred.notify);
+        });
+
+        return deferred.promise;
+    }
+
+
+    // true if circ is overdue, false otherwise
+    $scope.circIsOverdue = function(circ) {
+        // circ may not exist yet for rendered row
+        if (!circ) return false;
+
+        var date = new Date();
+        date.setTime(Date.parse(circ.due_date()));
+        return date < new Date();
+    }
+
+    $scope.edit_due_date = function(items) {
+        if (!items.length) return;
+
+        $modal.open({
+            templateUrl : './circ/patron/t_edit_due_date_dialog',
+            controller : [
+                        '$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                    // if there is only one circ, default to the due date
+                    // of that circ.  Otherwise, default to today.
+                    var due_date = items.length == 1 ? 
+                        Date.parse(items[0].due_date()) : new Date();
+
+                    $scope.args = {
+                        num_circs : items.length,
+                        due_date : due_date
+                    }
+
+                    // Fire off the due-date updater for each circ.
+                    // When all is done, close the dialog
+                    $scope.ok = function(args) {
+                        var due = args.due_date.toISOString().replace(/T.*/,'');
+                        console.debug("applying due date of " + due);
+
+                        var promises = [];
+                        angular.forEach(items, function(circ) {
+                            promises.push(
+                                egCore.net.request(
+                                    'open-ils.circ',
+                                    'open-ils.circ.circulation.due_date.update',
+                                    egCore.auth.token(), circ.id(), due
+
+                                ).then(function(new_circ) {
+                                    // update the grid circ with the canonical 
+                                    // date from the modified circulation.
+                                    circ.due_date(new_circ.due_date());
+                                })
+                            );
+                        });
+
+                        $q.all(promises).then(function() {
+                            $modalInstance.close();
+                            provider.refresh();
+                        });
+                    }
+                    $scope.cancel = function($event) {
+                        $modalInstance.dismiss();
+                        $event.preventDefault();
+                    }
+                }
+            ]
+        });
+    }
+
+    $scope.print_receipt = function(items) {
+        if (items.length == 0) return $q.when();
+        var print_data = {circulations : []}
+
+        angular.forEach(patronSvc.items_out, function(circ) {
+            print_data.circulations.push({
+                circ : egCore.idl.toHash(circ),
+                copy : egCore.idl.toHash(circ.target_copy()),
+                call_number : egCore.idl.toHash(circ.target_copy().call_number()),
+                title : circ.target_copy().call_number().record().simple_record().title(),
+                author : circ.target_copy().call_number().record().simple_record().author(),
+            })
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'items_out', 
+            scope : print_data,
+        });
+    }
+
+    function batch_action_with_barcodes(items, action) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+        action(barcodes).then(reset_page);
+    }
+    $scope.mark_lost = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_lost);
+    }
+    $scope.mark_claims_returned = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_claims_returned_dialog);
+    }
+    $scope.mark_claims_never_checked_out = function(items) {
+        batch_action_with_barcodes(items, egCirc.mark_claims_never_checked_out);
+    }
+
+    $scope.renew = function(items, msg) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        if (!msg) msg = egCore.strings.RENEW_ITEMS;
+
+        return egConfirmDialog.open(msg, barcodes.join(' '), {}).result
+        .then(function() {
+            function do_one() {
+                var bc = barcodes.pop();
+                if (!bc) { reset_page(); return }
+                // finally -> continue even when one fails
+                egCirc.renew({copy_barcode : bc}).finally(do_one);
+            }
+            do_one();
+        });
+    }
+
+    $scope.renew_all = function() {
+        var circs = patronSvc.items_out.filter(function(circ) {
+            return (
+                // all others will be rejected at the server
+                !circ.stop_fines() ||
+                circ.stop_fines() == 'MAXFINES'
+            );
+        });
+        $scope.renew(circs, egCore.strings.RENEW_ALL_ITEMS);
+    }
+
+    $scope.renew_with_date = function(items) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        return $modal.open({
+            templateUrl : './circ/patron/t_edit_due_date_dialog',
+            templateUrl : './circ/patron/t_renew_with_date_dialog',
+            controller : [
+                        '$scope','$modalInstance',
+                function($scope , $modalInstance) {
+                    $scope.args = {
+                        barcodes : barcodes,
+                        date : new Date()
+                    }
+                    $scope.cancel = function() {$modalInstance.dismiss()}
+
+                    // Fire off the due-date updater for each circ.
+                    // When all is done, close the dialog
+                    $scope.ok = function() {
+                        var due = $scope.args.date.toISOString().replace(/T.*/,'');
+                        console.debug("renewing with due date: " + due);
+
+                        function do_one() {
+                            if (bc = barcodes.pop()) {
+                                egCirc.renew({copy_barcode : bc, due_date : due})
+                                .finally(do_one);
+                            } else {
+                                $modalInstance.close(); 
+                                reset_page();
+                            }
+                        }
+                       do_one(); // kick it off
+                    }
+                }
+            ]
+        }).result;
+    }
+
+    $scope.checkin = function(items) {
+        if (!items.length) return;
+        var barcodes = items.map(function(circ) 
+            { return circ.target_copy().barcode() });
+
+        return egConfirmDialog.open(
+            egCore.strings.CHECK_IN_CONFIRM, barcodes.join(' '), {
+
+        }).result.then(function() {
+            function do_one() {
+                if (bc = barcodes.pop()) {
+                    egCirc.checkin({copy_barcode : bc})
+                    .finally(do_one);
+                } else {
+                    reset_page();
+                }
+            }
+            do_one(); // kick it off
+        });
+    }
+
+    $scope.add_billing = function(items) {
+        if (!items.length) return;
+        var circs = items.concat(); // don't pop from grid array
+        function do_one() {
+            var circ; // don't clobber window.circ!
+            if (circ = circs.pop()) {
+                egBilling.showBillDialog({
+                    // let the dialog fetch the transaction, since it's
+                    // not sufficiently fleshed here.
+                    xact_id : circ.id(),
+                    patron : patronSvc.current
+                }).finally(do_one);
+            } else {
+                reset_page();
+            }
+        }
+        do_one();
+    }
+
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/pending.js b/Open-ILS/web/js/ui/default/staff/circ/patron/pending.js
new file mode 100644 (file)
index 0000000..9c2bc65
--- /dev/null
@@ -0,0 +1,84 @@
+angular.module('egPendingPatronsApp', 
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/patron/pending/list', {
+        templateUrl: './circ/patron/t_pending_list',
+        controller: 'PendingPatronsCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/pending/list'});
+})
+
+.controller('PendingPatronsCtrl',
+       ['$scope','$q','$routeParams','$window','$location','egCore','egGridDataProvider',
+function($scope , $q , $routeParams , $window , $location , egCore , egGridDataProvider) {
+
+    console.log('HERE');
+
+    var pending_patrons = [];
+    var provider = egGridDataProvider.instance({});
+    $scope.grid_data_provider = provider;
+
+    function load_patron(item) {
+        if (angular.isArray(item)) item = item[0];
+        if (!item) return;
+        $window.open(
+            $location.path(
+                '/circ/patron/register/stage/' + item.user.usrname()).absUrl(),
+            '_blank'
+        ).focus();
+    }
+
+    $scope.load_patron = function(action, data, items) {
+        load_patron(items);
+    }
+
+    $scope.grid_controls = {
+        activateItem : load_patron
+    }
+
+    function refresh_page() {
+        pending_patrons = [];
+        provider.refresh();
+    }
+
+    provider.get = function(offset, count) {
+        var deferred = $q.defer();
+        var recv_index = 0;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.user.stage.retrieve.by_org',
+            egCore.auth.token(), $scope.context_org.id()
+
+        ).then(
+            deferred.resolve, null, 
+            function(user) {
+                user.id = user.user.row_id();
+                user.user.home_ou(egCore.org.get(user.user.home_ou()));
+
+                // only one (mailing) address is captured during patron
+                // self-registration
+                user.mailing_address = user.mailing_addresses[0];
+                pending_patrons[offset + recv_index++] = user;
+                deferred.notify(user);
+            }
+        );
+
+        return deferred.promise;
+    }
+
+    $scope.context_org = egCore.org.get(egCore.auth.user().ws_ou())
+    $scope.$watch('context_org', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) refresh_page();
+    });
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/patron/register.js b/Open-ILS/web/js/ui/default/staff/circ/patron/register.js
new file mode 100644 (file)
index 0000000..13b4a41
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * Patron App
+ *
+ * Search, checkout, items out, holds, bills, edit, etc.
+ */
+
+angular.module('egPatronRegApp', ['ui.bootstrap','ngRoute','egCoreMod'])
+
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : 
+        ['egStartup', function(egStartup) {return egStartup.go()}]}
+
+    $routeProvider.when('/circ/patron/register', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/stage/:stage_username', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/edit/:edit_id', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/patron/register/clone/:clone_id', {
+        template: '<eg-embed-frame url="reg_url"></eg-embed-frame>',
+        controller: 'PatronRegCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.otherwise({redirectTo : '/circ/patron/register'});
+})
+
+
+/**
+ * */
+.controller('PatronRegCtrl',
+       ['$scope','$routeParams','$location','egCore',
+function($scope , $routeParams , $location , egCore) {
+    
+
+    var url = $location.absUrl().replace(/\/staff.*/, '/actor/user/register');
+
+    // since we don't store auth cookies, pass the cookie via URL
+    url += '?ses=' + egCore.auth.token();
+
+    if ($routeParams.stage_username) {
+        url += '&stage=' + encodeURIComponent($routeParams.stage_username);
+    }
+
+    if ($routeParams.edit_id) {
+        url += '&usr=' + encodeURIComponent($routeParams.edit_id);
+    }
+
+    if ($routeParams.clone_id) {
+        url += '&clone=' + encodeURIComponent($routeParams.clone_id);
+    }
+
+    // pass the reg URL into the scope, thus into the 
+    $scope.reg_url = url;
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/circ/renew/app.js b/Open-ILS/web/js/ui/default/staff/circ/renew/app.js
new file mode 100644 (file)
index 0000000..c64c66c
--- /dev/null
@@ -0,0 +1,213 @@
+/**
+ * Renewal
+ */
+
+angular.module('egRenewApp', 
+    ['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('/circ/renew/renew', {
+        templateUrl: './circ/renew/t_renew',
+        controller: 'RenewCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/circ/renew/renew', {
+        templateUrl: './circ/renew/t_renew',
+        controller: 'RenewCtrl',
+        resolve : resolver
+    });
+    
+    $routeProvider.otherwise({redirectTo : '/circ/renew/renew'});
+})
+
+
+
+
+.controller('RenewCtrl',
+       ['$scope','$window','$location','egCore','egGridDataProvider','egCirc',
+function($scope , $window , $location , egCore , egGridDataProvider , egCirc) {
+
+    $scope.focusBarcode = true;
+    $scope.renewals = [];
+
+    var today = new Date();
+    $scope.renewalArgs = {due_date : today};
+
+    $scope.gridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier($scope.renewals, offset, count);
+        }
+    });
+
+    // avoid multiple, in-flight attempts on the same barcode
+    var pending_barcodes = {};
+
+    $scope.renew = function(args) {
+        var params = angular.copy(args);
+
+        if (args.sticky_date) {
+            params.due_date = args.due_date.toISOString();
+        } else {
+            delete params.due_date;
+        }
+        delete params.sticky_date;
+         if (!args.copy_barcode) return;
+
+        args.copy_barcode = ''; // reset UI input
+
+        if (pending_barcodes[params.copy_barcode]) {
+            console.log(
+                "Skipping renewals of redundant barcode " 
+                + params.copy_barcode
+            );
+            return;
+        }
+
+        pending_barcodes[params.copy_barcode] = true;
+        send_renewal(params);
+
+        $scope.focusBarcode = true; // return focus to barcode input
+    }
+
+    function send_renewal(params) {
+
+        params.noncat_type = params.noncat ? params.noncat_type : '';
+
+        // populate the grid row before we send the request so that the
+        // order of actions is maintained and so the user gets an 
+        // immediate reaction to their barcode input action.
+        var row_item = {
+            index : $scope.renewals.length,
+            copy_barcode : params.copy_barcode,
+            noncat_type : params.noncat_type
+        };
+
+        $scope.renewals.unshift(row_item);
+        $scope.gridDataProvider.refresh();
+
+        var options = {check_barcode : $scope.strict_barcode};
+
+        egCirc.renew(params, options).then(
+            function(final_resp) {
+
+                row_item.evt = final_resp.evt;
+                angular.forEach(final_resp.data, function(val, key) {
+                    row_item[key] = val;
+                });
+
+                if (row_item.mbts) {
+                    var amt = Number(row_item.mbts.balance_owed());
+                    if (amt != 0) {
+                        $scope.billable_barcode = row_item.copy_barcode;
+                        $scope.billable_amount = amt;
+                        $scope.fine_total = 
+                            ($scope.fine_total * 100 + amt * 100) / 100;
+                    }
+                }
+
+                if ($scope.trim_list && checkinSvc.checkins.length > 20)
+                    checkinSvc.checkins = checkinSvc.checkins.splice(0, 20);
+
+            },
+            function() {
+                // Circ was rejected somewhere along the way.
+                // Remove the copy from the grid since there was no action.
+                // note: since renewals are unshifted onto the array, the
+                // index value does not (generally) match the array position.
+                var pos = -1;
+                angular.forEach($scope.renewals, function(co, idx) {
+                    if (co.index == row_item.index) pos = idx;
+                });
+                $scope.renewals.splice(pos, 1);
+                $scope.gridDataProvider.refresh();
+            }
+
+        )['finally'](function() {
+
+            // regardless of the outcome of the circ, remove the 
+            // barcode from the pending list.
+            if (params.copy_barcode)
+                delete pending_barcodes[params.copy_barcode];
+        });
+    }
+
+    $scope.fetchLastCircPatron = function(items) {
+        var renewal = items[0];
+        if (!renewal || !renewal.acp) return;
+
+        egCirc.last_copy_circ(renewal.acp.id())
+        .then(function(circ) {
+
+            if (circ) {
+                // jump to the patron UI (separate app)
+                $window.location.href = $location
+                    .path('/circ/patron/' + circ.usr() + '/checkout')
+                    .absUrl();
+                return;
+            }
+
+            $scope.alert = {item_never_circed : renewal.acp.barcode()};
+        });
+    }
+
+    $scope.showMarkDamaged = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+
+        if (copy_ids.length) {
+            egCirc.mark_damaged(copy_ids).then(function() {
+                // update grid items?
+            });
+        }
+    }
+
+    $scope.showLastFewCircs = function(items) {
+        if (items.length && (copy = items[0].acp)) {
+            var url = $location.path(
+                '/cat/item/' + copy.id() + '/circ_list').absUrl();
+            $window.open(url, '_blank').focus();
+        }
+    }
+
+    $scope.abortTransit = function(items) {
+        var transit_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.transit) transit_ids.push(item.transit.id());
+        });
+
+        egCirc.abort_transits(transit_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.print_receipt = function() {
+        var print_data = {circulations : []}
+
+        if ($scope.renewals.length == 0) return $q.when();
+
+        angular.forEach($scope.renewals, function(renewal) {
+            if (renewal.circ) {
+                print_data.circulations.push({
+                    circ : egCore.idl.toHash(renewal.circ),
+                    copy : egCore.idl.toHash(renewal.acp),
+                    title : egCore.idl.toHash(renewal.title),
+                    author : egCore.idl.toHash(renewal.author)
+                });
+            }
+        });
+
+        return egCore.print.print({
+            context : 'default', 
+            template : 'renew', 
+            scope : print_data,
+        });
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/billing.js b/Open-ILS/web/js/ui/default/staff/circ/services/billing.js
new file mode 100644 (file)
index 0000000..9a2efb6
--- /dev/null
@@ -0,0 +1,175 @@
+/**
+ * Shared services for patron billing.
+ * 
+ */
+
+angular.module('egCoreMod')
+
+.factory('egBilling', 
+       ['$modal','$q','egCore',
+function($modal , $q , egCore) {
+
+    var service = {};
+
+    // fetch a fleshed money.billable_xact
+    service.fetchXact = function(xact_id) {
+        return egCore.pcrud.retrieve('mbt', xact_id, {
+            flesh : 5,
+            flesh_fields : {
+                mbt : ['summary','circulation','grocery','reservation'],
+                circ: ['target_copy'],
+                acp : ['call_number','location','status','age_protect'],
+                acn : ['record'],
+                bre : ['simple_record']
+            },
+            select : {bre : ['id']}}, // avoid MARC
+            {authoritative : true}
+        );
+    }
+
+    // apply a patron billing.  If no xact is provided, a grocery xact is
+    // created.
+    service.billPatron = function(args, xact) {
+        // apply a billing to an existing transaction
+        if (xact) return service.createBilling(xact.id, args);
+
+        // create a new grocery xact, then apply a billing
+        return service.createGroceryXact(args)
+        .then(function(xact_id) { 
+            return service.createBilling(xact_id, args);
+        });
+    }
+
+    // create a new grocery xact
+    service.createGroceryXact = function(args) {
+        var groc = new egCore.idl.mg();
+        groc.billing_location(egCore.auth.user().ws_ou());
+        groc.note(args.note);
+        groc.usr(args.patron_id);
+        
+        // create the xact
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.money.grocery.create',
+            egCore.auth.token(), groc
+
+        // create the billing on the new xact
+        ).then(function(xact_id) {
+            if (evt = egCore.evt.parse(xact_id)) 
+                return alert(evt);
+            return xact_id;
+        });
+    }
+
+    // fetch the org-focused billing types
+    // Cache on egEnv
+    service.fetchBillingTypes = function() {
+        if (egCore.env.cbt) 
+            return $q.when(egCore.env.cbt.list);
+
+        return egCore.pcrud.search('cbt', 
+            {   // first 100 are reserved for system-generated bills
+                id : {'>' : 100}, 
+                owner : egCore.org.ancestors(
+                    egCore.auth.user().ws_ou(), true)
+            }, 
+            {}, {atomic : true}
+        ).then(function(list) {
+            egCore.env.absorbList(list, 'cbt');
+            return list;
+        });
+    }
+
+    // create a patron billing
+    service.createBilling = function(xact_id, args) {
+        var bill = new egCore.idl.mb();
+        bill.xact(xact_id);
+        bill.amount(args.amount);
+        bill.btype(args.billingType);
+        bill.billing_type(egCore.env.cbt.map[args.billingType].name());
+        bill.note(args.note);
+
+        return egCore.net.request(
+            'open-ils.circ', 
+            'open-ils.circ.money.billing.create',
+            egCore.auth.token(), bill
+
+        // check the billing response
+        ).then(function(bill_id) {
+            if (evt = egCore.evt.parse(bill_id)) {
+                alert(evt);
+            } else {
+                return bill_id;
+            }
+        });
+    }
+
+
+    // Show the billing dialog.  
+    // Allows users to select amount, billing type, and note.
+    // args:
+    //   xact OR xact_id : if null, creates a grocery xact
+    //   patron OR patron_id
+    service.showBillDialog = function(args) {
+
+        return $modal.open({
+            templateUrl: './circ/share/t_bill_patron_dialog',
+            controller: 
+                   ['$scope','$modalInstance','$timeout','billingTypes','xact','patron',
+            function($scope , $modalInstance , $timeout , billingTypes , xact , patron) {
+                console.debug('billing patron ' + patron.id());
+                $scope.focus = true;
+                if (xact && xact._isfieldmapper)
+                    xact = egCore.idl.toHash(xact);
+                $scope.xact = xact;
+                $scope.patron = patron;
+                $scope.billingTypes = billingTypes;
+                $scope.location = egCore.org.get(egCore.auth.user().ws_ou()),
+                $scope.billArgs = {
+                    billingType : 101, // default to stock Misc. billing type
+                    xact : xact,
+                    patron_id : patron.id()
+                }
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+                $scope.updateDefaultPrice = function() {
+                    var type = billingTypes.filter(function(t) {
+                        return t.id() == $scope.billArgs.billingType })[0];
+                    if (type.default_price() && !$scope.billArgs.amount) 
+                        $scope.billArgs.amount = Number(type.default_price());
+                }
+            }],
+            resolve : {
+                // if we don't already have them, fetch the billing types
+                billingTypes : function() {
+                    return service.fetchBillingTypes();
+                }, 
+
+                xact : function() {
+                    if (args.xact) return $q.when(args.xact);
+                    if (args.xact_id) return service.fetchXact(args.xact_id);
+                    return $q.when();
+                },
+
+                patron : function() {
+                    if (args.patron) return $q.when(args.patron);
+                    return  egCore.pcrud.retrieve('au', args.patron_id,
+                        {flesh : 1, flesh_fields : {au : ['card']}});
+                }
+
+            }
+        }).result.then(
+            function(args) {
+                // send the billing to the server using the arguments
+                // provided in the billing dialog, then refresh
+                return service.billPatron(args, args.xact);
+            }
+        );
+    }
+
+    return service;
+}]);
+
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/circ.js b/Open-ILS/web/js/ui/default/staff/circ/services/circ.js
new file mode 100644 (file)
index 0000000..edf975a
--- /dev/null
@@ -0,0 +1,1485 @@
+/**
+ * Checkin, checkout, and renew
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCirc',
+
+       ['$modal','$q','egCore','egAlertDialog','egConfirmDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog) {
+
+    var service = {
+        // auto-override these events after the first override
+        auto_override_checkout_events : {},
+    };
+
+    service.reset = function() {
+        service.auto_override_checkout_events = {};
+    }
+
+    // these events can be overridden by staff during checkout
+    service.checkout_overridable_events = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES',
+        'PATRON_BARRED',
+        'CIRC_EXCEEDS_COPY_RANGE',
+        'ITEM_DEPOSIT_REQUIRED',
+        'ITEM_RENTAL_FEE_REQUIRED',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'COPY_CIRC_NOT_ALLOWED',
+        'COPY_NOT_AVAILABLE',
+        'COPY_IS_REFERENCE',
+        'COPY_ALERT_MESSAGE',
+        'ITEM_ON_HOLDS_SHELF'                 
+    ]
+
+    // after the first override of any of these events, 
+    // auto-override them in subsequent calls.
+    service.checkout_auto_override_after_first = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_BARRED',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES'
+    ]
+
+
+    // overridable during renewal
+    service.renew_overridable_events = [
+        'PATRON_EXCEEDS_OVERDUE_COUNT',
+        'PATRON_EXCEEDS_LOST_COUNT',
+        'PATRON_EXCEEDS_CHECKOUT_COUNT',
+        'PATRON_EXCEEDS_FINES',
+        'CIRC_EXCEEDS_COPY_RANGE',
+        'ITEM_DEPOSIT_REQUIRED',
+        'ITEM_RENTAL_FEE_REQUIRED',
+        'ITEM_DEPOSIT_PAID',
+        'COPY_CIRC_NOT_ALLOWED',
+        'COPY_IS_REFERENCE',
+        'COPY_ALERT_MESSAGE',
+        'COPY_NEEDED_FOR_HOLD',
+        'MAX_RENEWALS_REACHED',
+        'CIRC_CLAIMS_RETURNED'
+    ];
+
+    // these checkin events do not produce alerts when 
+    // options.suppress_alerts is in effect.
+    service.checkin_suppress_overrides = [
+        'COPY_BAD_STATUS',
+        'PATRON_BARRED',
+        'PATRON_INACTIVE',
+        'PATRON_ACCOUNT_EXPIRED',
+        'ITEM_DEPOSIT_PAID',
+        'CIRC_CLAIMS_RETURNED',
+        'COPY_ALERT_MESSAGE',
+        'COPY_STATUS_LOST',
+        'COPY_STATUS_LONG_OVERDUE',
+        'COPY_STATUS_MISSING',
+        'PATRON_EXCEEDS_FINES'
+    ]
+
+    // these events can be overridden by staff during checkin
+    service.checkin_overridable_events = 
+        service.checkin_suppress_overrides.concat([
+        'TRANSIT_CHECKIN_INTERVAL_BLOCK'
+    ])
+
+    // Performs a checkout.
+    // Returns a promise resolved with the original params and options
+    // and the final checkout event (e.g. in the case of override).
+    // Rejected if the checkout cannot be completed.
+    //
+    // params : passed directly as arguments to the server API 
+    // options : non-parameter controls.  e.g. "override", "check_barcode"
+    service.checkout = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.checkout() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.checkout.full';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+
+                return service.flesh_response_data('checkout', evt, params, options)
+                .then(function() {
+                    return service.handle_checkout_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // Performs a renewal.
+    // Returns a promise resolved with the original params and options
+    // and the final checkout event (e.g. in the case of override)
+    // Rejected if the renewal cannot be completed.
+    service.renew = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.renew() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.renew';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+
+                return service.flesh_response_data(
+                    'renew', evt, params, options)
+                .then(function() {
+                    return service.handle_renew_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // Performs a checkin
+    // Returns a promise resolved with the original params and options,
+    // plus the final checkin event (e.g. in the case of override).
+    // Rejected if the checkin cannot be completed.
+    service.checkin = function(params, options) {
+        if (!options) options = {};
+
+        console.debug('egCirc.checkin() : ' 
+            + js2JSON(params) + ' : ' + js2JSON(options));
+
+        var promise = options.check_barcode ? 
+            service.test_barcode(params.copy_barcode) : $q.when();
+
+        // avoid re-check on override, etc.
+        delete options.check_barcode;
+
+        return promise.then(function() {
+
+            var method = 'open-ils.circ.checkin';
+            if (options.override) method += '.override';
+
+            return egCore.net.request(
+                'open-ils.circ', method, egCore.auth.token(), params
+
+            ).then(function(evt) {
+
+                if (angular.isArray(evt)) evt = evt[0];
+                return service.flesh_response_data(
+                    'checkin', evt, params, options)
+                .then(function() {
+                    return service.handle_checkin_resp(evt, params, options);
+                })
+                .then(function(final_resp) {
+                    return service.munge_resp_data(final_resp)
+                })
+            });
+        });
+    }
+
+    // provide consistent formatting of the final response data
+    service.munge_resp_data = function(final_resp) {
+        var data = final_resp.data = {};
+
+        if (!final_resp.evt) return;
+
+        var payload = final_resp.evt.payload;
+        if (!payload) return;
+
+        data.circ = payload.circ;
+        data.parent_circ = payload.parent_circ;
+        data.hold = payload.hold;
+        data.record = payload.record;
+        data.acp = payload.copy;
+        data.acn = payload.volume ?  payload.volume : payload.copy.call_number();
+        data.au = payload.patron;
+        data.transit = payload.transit;
+        data.status = payload.status;
+        data.message = payload.message;
+        data.title = final_resp.evt.title;
+        data.author = final_resp.evt.author;
+        data.isbn = final_resp.evt.isbn;
+        data.route_to = final_resp.evt.route_to;
+
+        // for checkin, the mbts lives on the main circ
+        if (payload.circ && payload.circ.billable_transaction())
+            data.mbts = payload.circ.billable_transaction().summary();
+
+        // on renewals, the mbts lives on the parent circ
+        if (payload.parent_circ && payload.parent_circ.billable_transaction())
+            data.mbts = payload.parent_circ.billable_transaction().summary();
+
+        if (!data.route_to) {
+            if (data.transit) {
+                data.route_to = data.transit.dest().shortname();
+            } else if (data.acp) {
+                data.route_to = data.acp.location().name();
+            }
+        }
+
+        return final_resp;
+    }
+
+    service.handle_overridable_checkout_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        if (service.auto_override_checkout_events[evt.textcode]) {
+            // user has already opted to override this type
+            // of event.  Re-run the checkout w/ override.
+            options.override = true;
+            return service.checkout(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_NOT_AVAILABLE':
+                return service.copy_not_avail_dialog(evt, params, options);
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'checkout');
+            default: 
+                return service.override_dialog(evt, params, options, 'checkout');
+        }
+    }
+
+    service.handle_overridable_renew_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        // renewal auto-overrides are the same as checkout
+        if (service.auto_override_checkout_events[evt.textcode]) {
+            // user has already opted to override this type
+            // of event.  Re-run the renew w/ override.
+            options.override = true;
+            return service.renew(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'renew');
+            default: 
+                return service.override_dialog(evt, params, options, 'renew');
+        }
+    }
+
+
+    service.handle_overridable_checkin_event = function(evt, params, options) {
+
+        if (options.override) {
+            // override attempt already made and failed.
+            // NOTE: I don't think we'll ever get here, since the
+            // override attempt should produce a perm failure...
+            console.debug('override failed: ' + evt.textcode);
+            return $q.reject();
+
+        } 
+
+        if (options.suppress_checkin_popups
+            && service.checkin_suppress_overrides.indexOf(evt.textcode) > -1) {
+            // Event is suppressed.  Re-run the checkin w/ override.
+            options.override = true;
+            return service.checkin(params, options);
+        } 
+
+        // Ask the user if they would like to override this event.
+        // Some events offer a stock override dialog, while others
+        // require additional context.
+
+        switch(evt.textcode) {
+            case 'COPY_ALERT_MESSAGE':
+                return service.copy_alert_dialog(evt, params, options, 'checkin');
+            default: 
+                return service.override_dialog(evt, params, options, 'checkin');
+        }
+    }
+
+
+    service.handle_renew_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        // track the barcode regardless of whether it refers to a copy
+        evt.copy_barcode = params.copy_barcode;
+
+        // Overridable Events
+        if (service.renew_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_renew_event(evt, params, options);
+
+        // Other events
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                return $q.when(final_resp);
+
+            case 'COPY_IN_TRANSIT':
+            case 'PATRON_CARD_INACTIVE':
+            case 'PATRON_INACTIVE':
+            case 'PATRON_ACCOUNT_EXPIRED':
+            case 'CIRC_CLAIMS_RETURNED':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {barcode : params.copy_barcode}
+                );
+
+            case 'PERM_FAILURE':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {permission : evt.ilsperm}
+                );
+
+            default:
+                return service.exit_alert(
+                    egCore.strings.CHECKOUT_FAILED_GENERIC, {
+                        barcode : params.copy_barcode,
+                        textcode : evt.textcode,
+                        desc : evt.desc
+                    }
+                );
+        }
+    }
+
+
+    service.handle_checkout_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        // track the barcode regardless of whether it refers to a copy
+        evt.copy_barcode = params.copy_barcode;
+
+        // Overridable Events
+        if (service.checkout_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_checkout_event(evt, params, options);
+
+        // Other events
+        switch (evt.textcode) {
+            case 'SUCCESS':
+                return $q.when(final_resp);
+
+            case 'ITEM_NOT_CATALOGED':
+                return service.precat_dialog(params, options);
+
+            case 'OPEN_CIRCULATION_EXISTS':
+                return service.circ_exists_dialog(evt, params, options);
+
+            case 'COPY_IN_TRANSIT':
+                return service.copy_in_transit_dialog(evt, params, options);
+
+            case 'PATRON_CARD_INACTIVE':
+            case 'PATRON_INACTIVE':
+            case 'PATRON_ACCOUNT_EXPIRED':
+            case 'CIRC_CLAIMS_RETURNED':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {barcode : params.copy_barcode}
+                );
+
+            case 'PERM_FAILURE':
+                return service.exit_alert(
+                    egCore.strings[evt.textcode],
+                    {permission : evt.ilsperm}
+                );
+
+            default:
+                return service.exit_alert(
+                    egCore.strings.CHECKOUT_FAILED_GENERIC, {
+                        barcode : params.copy_barcode,
+                        textcode : evt.textcode,
+                        desc : evt.desc
+                    }
+                );
+        }
+    }
+
+    // returns a promise resolved with the list of circ mods
+    service.get_circ_mods = function() {
+        if (egCore.env.ccm) 
+            return $q.when(egCore.env.ccm.list);
+
+        return egCore.pcrud.retrieveAll('ccm', null, {atomic : true})
+        .then(function(list) { 
+            egCore.env.absorbList(list, 'ccm');
+            return list;
+        });
+    };
+
+    // returns a promise resolved with the list of noncat types
+    service.get_noncat_types = function() {
+        if (egCore.env.cnct) 
+            return $q.when(egCore.env.cnct.list);
+
+        return egCore.pcrud.search('cnct', 
+            {owning_lib : 
+                egCore.org.fullPath(egCore.auth.user().ws_ou(), true)}, 
+            null, {atomic : true}
+        ).then(function(list) { 
+            egCore.env.absorbList(list, 'cnct');
+            return list;
+        });
+    }
+
+    service.get_staff_penalty_types = function() {
+        if (egCore.env.csp) 
+            return $q.when(egCore.env.csp.list);
+        return egCore.pcrud.search(
+            // id <= 100 are reserved for system use
+            'csp', {id : {'>': 100}}, {}, {atomic : true})
+        .then(function(penalties) {
+            return egCore.env.absorbList(penalties, 'csp').list;
+        });
+    }
+
+    // ideally all of these data should be returned with the response,
+    // but until then, grab what we need.
+    service.flesh_response_data = function(action, evt, params, options) {
+        var promises = [];
+        var payload;
+        if (!evt || !(payload = evt.payload)) return $q.when();
+
+        promises.push(service.flesh_copy_location(payload.copy));
+        if (payload.copy) {
+            promises.push(
+                service.flesh_copy_status(payload.copy)
+
+                .then(function() {
+                    // copy is in transit, but no transit was delivered
+                    // in the payload.  Do this here instead of below to
+                    // ensure consistent copy status fleshiness
+                    if (!payload.transit && payload.copy.status().id() == 6) { // in-transit
+                        return service.find_copy_transit(evt, params, options)
+                        .then(function(trans) {
+                            if (trans) {
+                                trans.source(egCore.org.get(trans.source()));
+                                trans.dest(egCore.org.get(trans.dest()));
+                                payload.transit = trans;
+                            }
+                        })
+                    }
+                })
+            );
+        }
+
+        // local flesh transit
+        if (transit = payload.transit) {
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+        } 
+
+        // TODO: renewal responses should include the patron
+        if (!payload.patron && payload.circ) {
+            promises.push(
+                egCore.pcrud.retrieve('au', payload.circ.usr())
+                .then(function(user) {payload.patron = user})
+            );
+        }
+
+        // extract precat values
+        evt.title = payload.record ? payload.record.title() : 
+            (payload.copy ? payload.copy.dummy_title() : null);
+
+        evt.author = payload.record ? payload.record.author() : 
+            (payload.copy ? payload.copy.dummy_author() : null);
+
+        evt.isbn = payload.record ? payload.record.isbn() : 
+            (payload.copy ? payload.copy.dummy_isbn() : null);
+
+        return $q.all(promises);
+    }
+
+    // fetches the full list of copy statuses
+    service.flesh_copy_status = function(copy) {
+        if (!copy) return $q.when();
+        if (egCore.env.ccs) 
+            return $q.when(copy.status(egCore.env.ccs.map[copy.status()]));
+        return egCore.pcrud.retrieveAll('ccs', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'ccs');
+                copy.status(egCore.env.ccs.map[copy.status()]);
+            }
+        );
+    }
+
+    // there may be *many* copy locations and we may be handling items
+    // for other locations.  Fetch copy locations as-needed and cache.
+    service.flesh_copy_location = function(copy) {
+        if (!copy) return $q.when();
+        if (angular.isObject(copy.location())) return $q.when(copy);
+        if (egCore.env.acpl) {
+            if (egCore.env.acpl.map[copy.location()]) {
+                copy.location(egCore.env.acpl.map[copy.location()]);
+                return $q.when(copy);
+            }
+        } 
+        return egCore.pcrud.retrieve('acpl', copy.location())
+        .then(function(loc) {
+            egCore.env.absorbList([loc], 'acpl'); // append to cache
+            copy.location(loc);
+            return copy;
+        });
+    }
+
+
+    // fetch org unit addresses as needed.
+    service.get_org_addr = function(org_id, addr_type) {
+        var org = egCore.org.get(org_id);
+        var addr_id = org[addr_type]();
+
+        if (egCore.env.aoa && egCore.env.aoa.map[addr_id]) 
+            return $q.when(egCore.env.aoa.map[addr_id]); 
+
+        return egCore.pcrud.retrieve('aoa', addr_id).then(function(addr) {
+            egCore.env.absorbList([addr], 'aoa');
+            return egCore.env.aoa.map[addr_id]; 
+        });
+    }
+
+    service.exit_alert = function(msg, scope) {
+        return egAlertDialog.open(msg, scope).result.then(
+            function() {return $q.reject()});
+    }
+
+    // opens a dialog asking the user if they would like to override
+    // the returned event.
+    service.override_dialog = function(evt, params, options, action) {
+        return $modal.open({
+            templateUrl: './circ/share/t_event_override_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 
+                function($scope, $modalInstance) {
+                $scope.evt = evt;
+                $scope.auto_override = 
+                    service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1;
+                $scope.copy_barcode = params.copy_barcode; // may be null
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function ($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        }).result.then(
+            function() {
+                options.override = true;
+
+                if (action == 'checkin') {
+                    return service.checkin(params, options);
+                }
+
+                // checkout/renew support override-after-first
+                if (service.checkout_auto_override_after_first.indexOf(evt.textcode) > -1)
+                    service.auto_override_checkout_events[evt.textcode] = true;
+
+                return service[action](params, options);
+            }
+        );
+    }
+
+    service.copy_not_avail_dialog = function(evt, params, options) {
+        return $modal.open({
+            templateUrl: './circ/share/t_copy_not_avail_dialog',
+            controller: 
+                       ['$scope','$modalInstance','copyStatus',
+                function($scope , $modalInstance , copyStatus) {
+                $scope.copyStatus = copyStatus;
+                $scope.ok = function() {$modalInstance.close()}
+                $scope.cancel = function() {$modalInstance.dismiss()}
+            }],
+            resolve : {
+                copyStatus : function() {
+                    return egCore.pcrud.retrieve(
+                        'ccs', evt.payload.status());
+                }
+            }
+        }).result.then(
+            function() {
+                options.override = true;
+                return service.checkout(params, options);
+            }
+        );
+    }
+
+    // Opens a dialog allowing the user to fill in the desired non-cat count.
+    // Unlike other dialogs, which kickoff circ actions internally
+    // as a result of events, this dialog does not kick off any circ
+    // actions. It just collects the count and and resolves the promise.
+    //
+    // This assumes the caller has already handled the noncat-type
+    // selection and just needs to collect the count info.
+    service.noncat_dialog = function(params, options) {
+        var noncatMax = 99; // hard-coded max
+        
+        // the caller should presumably have fetched the noncat_types via
+        // our API already, but fetch them again (from cache) to be safe.
+        return service.get_noncat_types().then(function() {
+
+            params.noncat = true;
+            var type = egCore.env.cnct.map[params.noncat_type];
+
+            return $modal.open({
+                templateUrl: './circ/share/t_noncat_dialog',
+                controller: 
+                    ['$scope', '$modalInstance',
+                    function($scope, $modalInstance) {
+                    $scope.focusMe = true;
+                    $scope.type = type;
+                    $scope.count = 1;
+                    $scope.noncatMax = noncatMax;
+                    $scope.ok = function(count) { $modalInstance.close(count) }
+                    $scope.cancel = function ($event) { 
+                        $modalInstance.dismiss() 
+                        $event.preventDefault();
+                    }
+                }],
+            }).result.then(
+                function(count) {
+                    if (count && count > 0 && count <= noncatMax) { 
+                        // NOTE: in Chrome, form validation ensure a valid number
+                        params.noncat_count = count;
+                        return $q.when(params);
+                    } else {
+                        return $q.reject();
+                    }
+                }
+            );
+        });
+    }
+
+    // Opens a dialog allowing the user to fill in pre-cat copy info.
+    service.precat_dialog = function(params, options) {
+
+        return $modal.open({
+            templateUrl: './circ/share/t_precat_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 'circMods',
+                function($scope, $modalInstance, circMods) {
+                $scope.focusMe = true;
+                $scope.precatArgs = {
+                    copy_barcode : params.copy_barcode,
+                    circ_modifier : circMods.length ? circMods[0].code() : null
+                };
+                $scope.circModifiers = circMods;
+                $scope.ok = function(args) { $modalInstance.close(args) }
+                $scope.cancel = function () { $modalInstance.dismiss() }
+            }],
+            resolve : {
+                circMods : function() { 
+                    return service.get_circ_mods();
+                }
+            }
+        }).result.then(
+            function(args) {
+                if (!args || !args.dummy_title) return $q.reject();
+                angular.forEach(args, function(val, key) {params[key] = val});
+                params.precat = true;
+                return service.checkout(params, options);
+            }
+        );
+    }
+
+    // find the open transit for the given copy barcode; flesh the org
+    // units locally.
+    service.find_copy_transit = function(evt, params, options) {
+
+        if (evt && evt.payload && evt.payload.transit)
+            return $q.when(evt.payload.transit);
+
+         return egCore.pcrud.search('atc',
+            {   dest_recv_time : null},
+            {   flesh : 1, 
+                flesh_fields : {atc : ['target_copy']},
+                join : {
+                    acp : {
+                        filter : {
+                            barcode : params.copy_barcode,
+                            deleted : 'f'
+                        }
+                    }
+                },
+                limit : 1,
+                order_by : {atc : 'source_send_time desc'}, 
+            }
+        ).then(function(transit) {
+            transit.source(egCore.org.get(transit.source()));
+            transit.dest(egCore.org.get(transit.dest()));
+            return transit;
+        });
+    }
+
+    service.copy_in_transit_dialog = function(evt, params, options) {
+        return $modal.open({
+            templateUrl: './circ/share/t_copy_in_transit_dialog',
+            controller: 
+                       ['$scope','$modalInstance','transit',
+                function($scope , $modalInstance , transit) {
+                $scope.transit = transit;
+                $scope.ok = function() { $modalInstance.close(transit) }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }],
+            resolve : {
+                // fetch the conflicting open transit w/ fleshed copy
+                transit : function() {
+                    return service.find_copy_transit(evt, params, options);
+                }
+            }
+        }).result.then(
+            function(transit) {
+                // user chose to abort the transit then checkout
+                return service.abort_transit(transit.id())
+                .then(function() {
+                    return service.checkout(params, options);
+                });
+            }
+        );
+    }
+
+    service.abort_transit = function(transit_id) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.transit.abort',
+            egCore.auth.token(), {transitid : transit_id}
+        ).then(function(resp) {
+            if (evt = egCore.evt.parse(resp)) {
+                alert(evt);
+                return $q.reject();
+            }
+            return $q.when();
+        });
+    }
+
+    service.last_copy_circ = function(copy_id) {
+        return egCore.pcrud.search('circ', 
+            {target_copy : copy_id},
+            {order_by : {circ : 'xact_start desc' }, limit : 1}
+        );
+    }
+
+    service.circ_exists_dialog = function(evt, params, options) {
+
+        var openCirc = evt.payload.old_circ;
+        var sameUser = openCirc.usr() == params.patron_id;
+        
+        return $modal.open({
+            templateUrl: './circ/share/t_circ_exists_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+                $scope.circDate = openCirc.xact_start();
+                $scope.sameUser = sameUser;
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault(); // form, avoid calling ok();
+                }
+            }]
+        }).result.then(
+            function() {
+                
+                return service.checkin(
+                    {barcode : params.copy_barcode, noop : true}
+                ).then(function(checkin_resp) {
+                    if (checkin_resp.evt.textcode == 'SUCCESS') {
+                        return service.checkout(params, options);
+                    } else {
+                        alert(egCore.evt.parse(evt));
+                        return $q.reject();
+                    }
+                });
+            }
+        );
+    }
+
+    service.batch_backdate = function(circ_ids, backdate) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.post_checkin_backdate.batch',
+            egCore.auth.token(), circ_ids, backdate);
+    }
+
+    service.backdate_dialog = function(circ_ids) {
+        return $modal.open({
+            templateUrl: './circ/share/t_backdate_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                var today = new Date();
+                $scope.dialog = {
+                    num_circs : circ_ids.length,
+                    num_processed : 0,
+                    backdate : today
+                }
+
+                $scope.$watch('dialog.backdate', function(newval) {
+                    if (newval && newval > today) 
+                        $scope.dialog.backdate = today;
+                });
+
+
+                $scope.cancel = function() { 
+                    $modalInstance.dismiss();
+                }
+
+                $scope.ok = function() { 
+
+                    var bd = $scope.dialog.backdate.toISOString().replace(/T.*/,'');
+                    service.batch_backdate(circ_ids, bd)
+                    .then(
+                        function() { // on complete
+                            $modalInstance.close({backdate : bd});
+                        },
+                        null,
+                        function(resp) { // on response
+                            console.debug('backdate returned ' + resp);
+                            if (resp == '1') {
+                                $scope.num_processed++;
+                            } else {
+                                console.error(egCore.evt.parse(resp));
+                            }
+                        }
+                    );
+                }
+            }]
+        }).result;
+    }
+
+    service.mark_claims_returned = function(barcode, date, override) {
+
+        var method = 'open-ils.circ.circulation.set_claims_returned';
+        if (override) method += '.override';
+
+        console.debug('claims returned ' + method);
+
+        return egCore.net.request(
+            'open-ils.circ', method, egCore.auth.token(),
+            {barcode : barcode, backdate : date})
+
+        .then(function(resp) {
+
+            if (resp == 1) { // success
+                console.debug('claims returned succeeded for ' + barcode);
+                return barcode;
+
+            } else if (evt = egCore.evt.parse(resp)) {
+                console.debug('claims returned failed: ' + evt.toString());
+
+                if (evt.textcode == 'PATRON_EXCEEDS_CLAIMS_RETURN_COUNT') {
+                    // TODO check perms before offering override option?
+
+                    if (override) return;// just to be safe
+
+                    return egConfirmDialog.open(
+                        egCore.strings.TOO_MANY_CLAIMS_RETURNED, '', {}
+                    ).result.then(function() {
+                        return service.mark_claims_returned(barcode, date, true);
+                    });
+                }
+
+                if (evt.textcode == 'PERM_FAILURE') {
+                    console.error('claims returned permission denied')
+                    // TODO: auth override dialog?
+                }
+            }
+        });
+    }
+
+    service.mark_claims_returned_dialog = function(copy_barcodes) {
+        if (!copy_barcodes.length) return;
+
+        return $modal.open({
+            templateUrl: './circ/share/t_mark_claims_returned_dialog',
+            controller: 
+                       ['$scope','$modalInstance',
+                function($scope , $modalInstance) {
+
+                var today = new Date();
+                $scope.args = {
+                    barcodes : copy_barcodes,
+                    date : today
+                };
+
+                $scope.$watch('args.date', function(newval) {
+                    if (newval && newval > today) 
+                        $scope.args.backdate = today;
+                });
+
+                $scope.cancel = function() {$modalInstance.dismiss()}
+                $scope.ok = function() { 
+
+                    var date = $scope.args.date.toISOString().replace(/T.*/,'');
+
+                    var deferred = $q.defer();
+
+                    // serialize the action on each barcode so that the 
+                    // caller will never see multiple alerts at the same time.
+                    function mark_one() {
+                        var bc = copy_barcodes.pop();
+                        if (!bc) {
+                            deferred.resolve();
+                            $modalInstance.close();
+                            return;
+                        }
+
+                        // finally -> continue even when one fails
+                        service.mark_claims_returned(bc, date)
+                        .finally(function(barcode) {
+                            if (barcode) deferred.notify(barcode);
+                            mark_one();
+                        });
+                    }
+                    mark_one(); // kick it off
+                    return deferred.promise;
+                }
+            }]
+        }).result;
+    }
+
+    // serially checks in each barcode with claims_never_checked_out set
+    // returns promise, notified on each barcode, resolved after all
+    // checkins are complete.
+    service.mark_claims_never_checked_out = function(barcodes) {
+        if (!barcodes.length) return;
+
+        var deferred = $q.defer();
+        egConfirmDialog.open(
+            egCore.strings.MARK_NEVER_CHECKED_OUT, '', {barcodes : barcodes}
+
+        ).result.then(function() {
+            function mark_one() {
+                var bc = barcodes.pop();
+
+                if (!bc) { // all done
+                    deferred.resolve();
+                    return;
+                }
+
+                service.checkin(
+                    {claims_never_checked_out : true, copy_barcode : bc})
+                .finally(function() { 
+                    deferred.notify(bc);
+                    mark_one();
+                })
+            }
+            mark_one();
+        });
+
+        return deferred.promise;
+    }
+
+    service.mark_damaged = function(copy_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.MARK_DAMAGED_CONFIRM, '',
+            {   num_items : copy_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(copy_ids, function(copy_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.mark_item_damaged',
+                        egCore.auth.token(), copy_id
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('mark damaged failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+    service.mark_missing = function(copy_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.MARK_MISSING_CONFIRM, '',
+            {   num_items : copy_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(copy_ids, function(copy_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.mark_item_missing',
+                        egCore.auth.token(), copy_id
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('mark missing failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+
+
+    // Mark circulations as lost via copy barcode.  As each item is 
+    // processed, the returned promise is notified of the barcode.
+    // No confirmation dialog is presented.
+    service.mark_lost = function(copy_barcodes) {
+        var deferred = $q.defer();
+        var promises = [];
+
+        angular.forEach(copy_barcodes, function(barcode) {
+            promises.push(
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.circulation.set_lost',
+                    egCore.auth.token(), {barcode : barcode}
+                ).then(function(resp) {
+                    if (evt = egCore.evt.parse(resp)) {
+                        console.error("Mark lost failed: " + evt.toString());
+                        return;
+                    }
+                    // inform the caller as each item is processed
+                    deferred.notify(barcode);
+                })
+            );
+        });
+
+        $q.all(promises).then(function() {deferred.resolve()});
+        return deferred.promise;
+    }
+
+    service.abort_transits = function(transit_ids) {
+        return egConfirmDialog.open(
+            egCore.strings.ABORT_TRANSIT_CONFIRM, '',
+            {   num_transits : transit_ids.length,
+                ok : function() {},
+                cancel : function() {}
+            }
+
+        ).result.then(function() {
+            var promises = [];
+            angular.forEach(transit_ids, function(transit_id) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.transit.abort',
+                        egCore.auth.token(), {transitid : transit_id}
+                    ).then(function(resp) {
+                        if (evt = egCore.evt.parse(resp)) {
+                            console.error('abort transit failed: ' + evt);
+                        }
+                    })
+                );
+            });
+
+            return $q.all(promises);
+        });
+    }
+
+
+
+    // alert when copy location alert_message is set.
+    // This does not affect processing, it only produces a click-through
+    service.handle_checkin_loc_alert = function(evt, params, options) {
+
+        var copy = evt && evt.payload ? evt.payload.copy : null;
+
+        if (copy && !options.suppress_checkin_popups
+            && copy.location().checkin_alert() == 't') {
+
+            return egAlertDialog.open(
+                egCore.strings.LOCATION_ALERT_MSG, {copy : copy}).result;
+        }
+
+        return $q.when();
+    }
+
+    service.handle_checkin_resp = function(evt, params, options) {
+
+        var final_resp = {evt : evt, params : params, options : options};
+
+        var copy, hold, transit;
+        if (evt.payload) {
+            copy = evt.payload.copy;
+            hold = evt.payload.hold;
+            transit = evt.payload.transit;
+        }
+
+        // track the barcode regardless of whether it's valid
+        evt.copy_barcode = params.copy_barcode;
+
+        console.debug('checkin event ' + evt.textcode);
+
+        if (service.checkin_overridable_events.indexOf(evt.textcode) > -1) 
+            return service.handle_overridable_checkin_event(evt, params, options);
+
+        switch (evt.textcode) {
+
+            case 'SUCCESS':
+            case 'NO_CHANGE':
+
+                switch(Number(copy.status().id())) {
+
+                    case 0: /* AVAILABLE */                                        
+                    case 4: /* MISSING */                                          
+                    case 7: /* RESHELVING */ 
+
+                        // see if the copy location requires an alert
+                        return service.handle_checkin_loc_alert(evt, params, options)
+                        .then(function() {return final_resp});
+
+                    case 8: /* ON HOLDS SHELF */
+
+                        
+                        if (hold) {
+
+                            if (hold.pickup_lib() == egCore.auth.user().ws_ou()) {
+                                // inform user if the item is on the local holds shelf
+                            
+                                evt.route_to = egCore.strings.ROUTE_TO_HOLDS_SHELF;
+                                return service.route_dialog(
+                                    './circ/share/t_hold_shelf_dialog', 
+                                    evt, params, options
+                                ).then(function() { return final_resp });
+
+                            } else {
+                                // normally, if the hold was on the shelf at a 
+                                // different location, it would be put into 
+                                // transit, resulting in a ROUTE_ITEM event.
+                                return $q.when(final_resp);
+                            }
+                        } else {
+
+                            console.error('checkin: item on holds shelf, '
+                                + 'but hold info not returned from checkin');
+                            return $q.when(final_resp);
+                        }
+
+                    case 11: /* CATALOGING */
+                        evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+                        return $q.when(final_resp);
+
+                    case 15: /* ON_RESERVATION_SHELF */
+                        // TODO: show booking reservation dialog
+                        return $q.when(final_resp);
+
+                    default:
+                        console.error('Unhandled checkin copy status: ' 
+                            + copy.status().id() + ' : ' + copy.status().name());
+                        return $q.when(final_resp);
+                }
+                
+            case 'ROUTE_ITEM':
+                return service.route_dialog(
+                    './circ/share/t_transit_dialog', 
+                    evt, params, options
+                ).then(function() { return final_resp });
+
+            case 'ASSET_COPY_NOT_FOUND':
+                return egAlertDialog.open(
+                    egCore.strings.UNCAT_ALERT_DIALOG, params)
+                    .result.then(function() {return final_resp});
+
+            case 'ITEM_NOT_CATALOGED':
+                evt.route_to = egCore.strings.ROUTE_TO_CATALOGING;
+                if (options.no_precat_alert) 
+                    return $q.when(final_resp);
+                return egAlertDialog.open(
+                    egCore.strings.PRECAT_CHECKIN_MSG, params)
+                    .result.then(function() {return final_resp});
+
+            default:
+                console.warn('unhandled checkin response : ' + evt.textcode);
+                return $q.when(final_resp);
+        }
+    }
+
+    // collect transit, address, and hold info that's not already
+    // included in responses.
+    service.collect_route_data = function(tmpl, evt, params, options) {
+        var promises = [];
+        var data = {};
+
+        if (evt.org && !tmpl.match(/hold_shelf/)) {
+            promises.push(
+                service.get_org_addr(evt.org, 'holds_address')
+                .then(function(addr) { data.address = addr })
+            );
+        }
+
+        if (evt.payload.hold) {
+            promises.push(
+                egCore.pcrud.retrieve('au', 
+                    evt.payload.hold.usr(), {
+                        flesh : 1,
+                        flesh_fields : {'au' : ['card']}
+                    }
+                ).then(function(patron) {data.patron = patron})
+            );
+        }
+
+        if (!tmpl.match(/hold_shelf/)) {
+            promises.push(
+                service.find_copy_transit(evt, params, options)
+                .then(function(trans) {data.transit = trans})
+            );
+        }
+
+        return $q.all(promises).then(function() { return data });
+    }
+
+    service.route_dialog = function(tmpl, evt, params, options) {
+
+        return service.collect_route_data(tmpl, evt, params, options)
+        .then(function(data) {
+            
+            // All actions flow from the print data
+
+            var print_context = {
+                copy : egCore.idl.toHash(evt.payload.copy),
+                title : evt.title,
+                author : evt.author
+            }
+
+            if (data.transit) {
+                // route_dialog includes the "route to holds shelf" 
+                // dialog, which has no transit
+                print_context.transit = egCore.idl.toHash(data.transit);
+                print_context.dest_address = egCore.idl.toHash(data.address);
+                print_context.dest_location =
+                    egCore.idl.toHash(egCore.org.get(data.transit.dest()));
+            }
+
+            if (data.patron) {
+                print_context.hold = egCore.idl.toHash(evt.payload.hold);
+                print_context.patron = egCore.idl.toHash(data.patron);
+            }
+
+            function print_transit() {
+                var template = data.transit ? 
+                    (data.patron ? 'hold_transit_slip' : 'transit_slip') :
+                    'hold_shelf_slip';
+
+                return egCore.print.print({
+                    context : 'default', 
+                    template : template, 
+                    scope : print_context
+                });
+            }
+
+            // when auto-print is on, skip the dialog and go straight
+            // to printing.
+            if (options.auto_print_holds_transits) 
+                return print_transit();
+
+            return $modal.open({
+                templateUrl: tmpl,
+                controller: [
+                            '$scope','$modalInstance',
+                    function($scope , $modalInstance) {
+
+                    $scope.today = new Date();
+
+                    // copy the print scope into the dialog scope
+                    angular.forEach(print_context, function(val, key) {
+                        $scope[key] = val;
+                    });
+
+                    $scope.ok = function() {$modalInstance.close()}
+
+                    $scope.print = function() { 
+                        $modalInstance.close();
+                        print_transit();
+                    }
+                }]
+
+            }).result;
+        });
+    }
+
+    // action == what action to take if the user confirms the alert
+    service.copy_alert_dialog = function(evt, params, options, action) {
+        return egConfirmDialog.open(
+            egCore.strings.COPY_ALERT_MSG_DIALOG_TITLE, 
+            evt.payload,  // payload == alert message text
+            {   copy_barcode : params.copy_barcode,
+                ok : function() {},
+                cancel : function() {}
+            }
+        ).result.then(function() {
+            options.override = true;
+            return service[action](params, options);
+        });
+    }
+
+    // check the barcode.  If it's no good, show the warning dialog
+    // Resolves on success, rejected on error
+    service.test_barcode = function(bc) {
+
+        var ok = service.check_barcode(bc);
+        if (ok) return $q.when();
+
+        return $modal.open({
+            templateUrl: './circ/share/t_bad_barcode_dialog',
+            controller: 
+                ['$scope', '$modalInstance', 
+                function($scope, $modalInstance) {
+                $scope.barcode = bc;
+                $scope.ok = function() { $modalInstance.close() }
+                $scope.cancel = function() { $modalInstance.dismiss() }
+            }]
+        }).result;
+    }
+
+    // check() and checkdigit() copied directly 
+    // from chrome/content/util/barcode.js
+
+    service.check_barcode = function(bc) {
+        if (bc != Number(bc)) return false;
+        bc = bc.toString();
+        // "16.00" == Number("16.00"), but the . is bad.
+        // Throw out any barcode that isn't just digits
+        if (bc.search(/\D/) != -1) return false;
+        var last_digit = bc.substr(bc.length-1);
+        var stripped_barcode = bc.substr(0,bc.length-1);
+        return service.barcode_checkdigit(stripped_barcode).toString() == last_digit;
+    }
+
+    service.barcode_checkdigit = function(bc) {
+        var reverse_barcode = bc.toString().split('').reverse();
+        var check_sum = 0; var multiplier = 2;
+        for (var i = 0; i < reverse_barcode.length; i++) {
+            var digit = reverse_barcode[i];
+            var product = digit * multiplier; product = product.toString();
+            var temp_sum = 0;
+            for (var j = 0; j < product.length; j++) {
+                temp_sum += Number( product[j] );
+            }
+            check_sum += Number( temp_sum );
+            multiplier = ( multiplier == 2 ? 1 : 2 );
+        }
+        check_sum = check_sum.toString();
+        var next_multiple_of_10 = (check_sum.match(/(\d*)\d$/)[1] * 10) + 10;
+        var check_digit = next_multiple_of_10 - Number(check_sum);
+        if (check_digit == 10) check_digit = 0;
+        return check_digit;
+    }
+
+    service.create_penalty = function(user_id) {
+        return $modal.open({
+            templateUrl: './circ/share/t_new_message_dialog',
+            controller: 
+                   ['$scope','$modalInstance','staffPenalties',
+            function($scope , $modalInstance , staffPenalties) {
+                $scope.focusNote = true;
+                $scope.penalties = staffPenalties;
+                $scope.args = {penalty : 21}; // default to Note
+                $scope.setPenalty = function(id) {
+                    args.penalty = id;
+                }
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }],
+            resolve : { staffPenalties : service.get_staff_penalty_types }
+        }).result.then(
+            function(args) {
+                var pen = new egCore.idl.ausp();
+                pen.usr(user_id);
+                pen.org_unit(egCore.auth.user().ws_ou());
+                pen.note(args.note);
+                pen.standing_penalty(args.penalty);
+                pen.staff(egCore.auth.user().id());
+                pen.set_date('now');
+                return egCore.pcrud.create(pen);
+            }
+        );
+    }
+
+    // assumes, for now anyway,  penalty type is fleshed onto usr_penalty.
+    service.edit_penalty = function(usr_penalty) {
+        return $modal.open({
+            templateUrl: './circ/share/t_new_message_dialog',
+            controller: 
+                   ['$scope','$modalInstance','staffPenalties',
+            function($scope , $modalInstance , staffPenalties) {
+                $scope.focusNote = true;
+                $scope.penalties = staffPenalties;
+                $scope.args = {
+                    penalty : usr_penalty.standing_penalty().id(),
+                    note : usr_penalty.note()
+                }
+                $scope.setPenalty = function(id) { args.penalty = id; }
+                $scope.ok = function(count) { $modalInstance.close($scope.args) }
+                $scope.cancel = function($event) { 
+                    $modalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }],
+            resolve : { staffPenalties : service.get_staff_penalty_types }
+        }).result.then(
+            function(args) {
+                usr_penalty.note(args.note);
+                usr_penalty.standing_penalty(args.penalty);
+                return egCore.pcrud.update(usr_penalty);
+            }
+        );
+    }
+
+    return service;
+
+}]);
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/holds.js b/Open-ILS/web/js/ui/default/staff/circ/services/holds.js
new file mode 100644 (file)
index 0000000..161722c
--- /dev/null
@@ -0,0 +1,605 @@
+/**
+ * Holds, yo
+ */
+
+angular.module('egCoreMod')
+
+.factory('egHolds',
+
+       ['$modal','$q','egCore','egAlertDialog','egConfirmDialog','egAlertDialog',
+function($modal , $q , egCore , egAlertDialog , egConfirmDialog , egAlertDialog) {
+
+    var service = {};
+
+    service.fetch_holds = function(hold_ids) {
+        var deferred = $q.defer();
+
+        // FIXME: large batches using .authoritative result in many 
+        // stranded cstore backends on the server.  Needs investigation.
+        // For now, collect holds in a series of small batches.
+        // Fetch them serially both to avoid the above problem and
+        // to maintain order.
+        var batch_size = 5;
+        var index = 0;
+
+        function one_batch() {
+            var ids = hold_ids.slice(index, index + batch_size)
+                .filter(function(id) {return Boolean(id)}) // avoid nulls
+
+            console.debug('egHolds.fetch_holds => ' + ids);
+            index += batch_size;
+
+            if (!ids.length) {
+                deferred.resolve();
+                return;
+            }
+
+            egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.hold.details.batch.retrieve.authoritative',
+                egCore.auth.token(), ids
+
+            ).then(
+                one_batch,  // kick off the next batch
+                null, 
+                function(hold_data) {
+                    var hold = hold_data.hold;
+                    hold_data.id = hold.id();
+                    service.local_flesh(hold_data);
+                    deferred.notify(hold_data);
+                }
+            );
+        }
+
+        one_batch(); // kick it off
+        return deferred.promise;
+    }
+
+
+    service.cancel_holds = function(hold_ids) {
+       
+        return $modal.open({
+            templateUrl : './circ/share/t_cancel_hold_dialog',
+            controller : 
+                ['$scope', '$modalInstance', 'cancel_reasons',
+                function($scope, $modalInstance, cancel_reasons) {
+                    $scope.args = {
+                        cancel_reasons : cancel_reasons,
+                        num_holds : hold_ids.length
+                    };
+                    
+                    $scope.cancel = function($event) {
+                        $modalInstance.dismiss();
+                        $event.preventDefault();
+                    }
+
+                    $scope.ok = function() {
+
+                        function cancel_one() {
+                            var hold_id = hold_ids.pop();
+                            if (!hold_id) {
+                                $modalInstance.close();
+                                return;
+                            }
+                            egCore.net.request(
+                                'open-ils.circ', 'open-ils.circ.hold.cancel',
+                                egCore.auth.token(), hold_id,
+                                $scope.args.cancel_reason.id(), 
+                                $scope.args.note
+                            ).then(function(resp) {
+                                if (evt = egCore.evt.parse(resp)) {
+                                    console.error('unable to cancel hold: ' 
+                                        + evt.toString());
+                                }
+                                cancel_one();
+                            });
+                        }
+
+                        cancel_one();
+                    }
+                }
+            ],
+            resolve : {
+                cancel_reasons : function() {
+                    return service.get_cancel_reasons();
+                }
+            }
+        }).result;
+    }
+
+    service.get_cancel_reasons = function() {
+        if (egCore.env.ahrcc) return $q.when(egCore.env.ahrcc.list);
+        return egCore.pcrud.retrieveAll('ahrcc', {}, {atomic : true})
+        .then(function(list) { return egCore.env.absorbList(list, 'ahrcc').list });
+    }
+
+    // Updates a batch of holds, notifies on each response.
+    // new_values = array of hashes describing values to change,
+    // including the id of the hold to change.
+    // e.g. {id : 1, mint_condition : true}
+    service.update_holds = function(new_values) {
+        return egCore.net.request(
+            'open-ils.circ',
+            'open-ils.circ.hold.update.batch',
+            egCore.auth.token(), null, new_values);
+    }
+
+    service.set_copy_quality = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_copy_quality_dialog',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+
+                    function update(val) {
+                        var vals = hold_ids.map(function(hold_id) {
+                            return {id : hold_id, mint_condition : val}})
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.good = function() { update(true) }
+                    $scope.any = function() { update(false) }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ]
+        }).result;
+    }
+
+    service.edit_pickup_lib = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_edit_pickup_lib',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.args = {}
+                    $scope.ok = function() { 
+                        var vals = hold_ids.map(function(hold_id) {
+                            return {
+                                id : hold_id, 
+                                pickup_lib : $scope.args.org_unit.id()
+                            }
+                        });
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ]
+        }).result;
+    }
+
+    service.get_sms_carriers = function() {
+        if (egCore.env.csc) return $q.when(egCore.env.csc.list);
+        return egCore.pcrud.retrieveAll('csc', {}, {atomic : true})
+        .then(function(list) { return egCore.env.absorbList(list, 'csc').list });
+    }
+
+    service.edit_notify_prefs = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_notification_prefs',
+            controller : 
+                ['$scope', '$modalInstance', 'sms_carriers',
+                function($scope, $modalInstance, sms_carriers) {
+                    $scope.args = {}
+                    $scope.sms_carriers = sms_carriers;
+                    $scope.num_holds = hold_ids.length;
+                    $scope.ok = function() { 
+
+                        var vals = hold_ids.map(function(hold_id) {
+                            var val = {id : hold_id};
+                            angular.forEach(
+                                ['email', 'phone', 'sms'],
+                                function(type) {
+                                    var key = type + '_notify';
+                                    if ($scope.args['update_' + key]) 
+                                        val[key] = $scope.args[key];
+                                }
+                            );
+                            if ($scope.args.update_sms_carrier)
+                                val.sms_carrier = $scope.args.sms_carrier.id();
+                            return val;
+                        });
+
+                        service.update_holds(vals).finally(function() {
+                            $modalInstance.close();
+                        });
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ],
+            resolve : {
+                sms_carriers : service.get_sms_carriers
+            }
+        }).result;
+    }
+
+    service.edit_dates = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+
+        // collects the fields from the dialog the user wishes to modify
+        function relay_to_update(modal_scope) {
+            var vals = hold_ids.map(function(hold_id) {
+                var val = {id : hold_id};
+                angular.forEach(
+                    ['thaw_date', 'request_time', 'expire_time', 'shelf_expire_time'], 
+                    function(field) {
+                        if (modal_scope.args['modify_' + field]) 
+                            val[field] = modal_scope.args[field].toISOString();
+                    }
+                );
+
+                return val;
+            });
+
+            console.log(JSON.stringify(vals,null,2));
+            return service.update_holds(vals);
+        }
+
+        return $modal.open({
+            templateUrl : './circ/share/t_hold_dates',
+            controller : 
+                ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    var today = new Date();
+                    $scope.args = {
+                        thaw_date : today,
+                        request_time : today,
+                        expire_time : today,
+                        shelf_expire_time : today
+                    }
+                    $scope.num_holds = hold_ids.length;
+                    $scope.ok = function() { 
+                        relay_to_update($scope).then($modalInstance.close);
+                    }
+                    $scope.cancel = function() { $modalInstance.dismiss() }
+                }
+            ],
+        }).result;
+    }
+
+    service.update_field_with_confirm = function(hold_ids, msg_key, field, value) {
+        if (!hold_ids.length) return $q.when();
+
+        return egConfirmDialog.open(
+            egCore.strings[msg_key], '', {num_holds : hold_ids.length})
+        .result.then(function() {
+
+            var vals = hold_ids.map(function(hold_id) {
+                val = {id : hold_id};
+                val[field] = value;
+                return val;
+            });
+            return service.update_holds(vals);
+        });
+    }
+
+    service.suspend_holds = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'SUSPEND_HOLDS', 'frozen', true);
+    }
+
+    service.activate_holds = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'ACTIVATE_HOLDS', 'frozen', false);
+    }
+
+    service.set_top_of_queue = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'SET_TOP_OF_QUEUE', 'cut_in_line', true);
+    }
+
+    service.clear_top_of_queue = function(hold_ids) {
+        return service.update_field_with_confirm(
+            hold_ids, 'CLEAR_TOP_OF_QUEUE', 'cut_in_line', null);
+    }
+
+    service.transfer_to_marked_title = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+
+        var bib_id = egCore.hatch.getLocalItem(
+            'eg.circ.hold.title_transfer_target');
+
+        if (!bib_id) {
+            // no target marked
+            return egAlertDialog.open(
+                egCore.strings.NO_HOLD_TRANSFER_TITLE_MARKED).result;
+        }
+
+        return egConfirmDialog.open(
+            egCore.strings.TRANSFER_HOLD_TO_TITLE, '', {
+                num_holds : hold_ids.length,
+                bib_id : bib_id
+            }
+        ).result.then(function() {
+            return egCore.net.request(
+                'open-ils.circ',
+                'open-ils.circ.hold.change_title.specific_holds',
+                egCore.auth.token(), bib_id, hold_ids);
+        });
+    }
+
+    // serially retargets each hold
+    service.retarget = function(hold_ids) {
+        if (!hold_ids.length) return $q.when();
+        var deferred = $q.defer();
+
+        egConfirmDialog.open(
+            egCore.strings.RETARGET_HOLDS, '', 
+            {hold_ids : hold_ids.join(',')}
+
+        ).result.then(function() {
+
+            function do_one() {
+                var hold_id = hold_ids.pop();
+                if (!hold_id) {
+                    deferred.resolve();
+                    return;
+                }
+
+                egCore.net.request(
+                    'open-ils.circ',
+                    'open-ils.circ.hold.reset',
+                    egCore.auth.token(), hold_id).finally(do_one);
+            }
+
+            do_one(); // kick it off
+        });
+
+        return deferred.promise;
+    }
+
+    // fleshes orgs, etc. for hold data blobs retrieved from
+    // open-ils.circ.hold.details[.batch].retrieve
+    service.local_flesh = function(hold_data) {
+
+        hold_data.status_string = 
+            egCore.strings['HOLD_STATUS_' + hold_data.status] 
+            || hold_data.status;
+
+        var hold = hold_data.hold;
+        hold.pickup_lib(egCore.org.get(hold.pickup_lib()));
+        hold.current_shelf_lib(egCore.org.get(hold.current_shelf_lib()));
+        hold_data.id = hold.id();
+
+        // current_copy is not always fleshed in the API
+        if (hold.current_copy() && typeof hold.current_copy() != 'object')
+            hold.current_copy(hold_data.copy);
+    }
+
+    return service;
+}])
+
+/**  
+ * Action handlers for the common Hold grid UI.
+ * These generally scrub the data for valid input then pass the
+ * holds / copies / etc. off to the relevant action in egHolds or egCirc.
+ *
+ * Caller must apply a reset_page function, which is called after 
+ * most actionis are performed.
+ */
+.factory('egHoldGridActions', 
+       ['$window','$location','egCore','egHolds','egCirc',
+function($window , $location , egCore , egHolds , egCirc) {
+    
+    var service = {};
+
+    service.refresh = function() {
+        console.error('egHoldGridActions.refresh not defined!');
+    }
+
+    service.cancel_hold = function(items) {
+        var hold_ids = items.filter(function(item) {
+            return !item.hold.cancel_time();
+        }).map(function(item) {return item.hold.id()});
+
+        return egHolds.cancel_holds(hold_ids).then(service.refresh);
+    }
+
+    // jump to circ list for either 1) the targeted copy or
+    // 2) the hold target copy for copy-level holds
+    service.show_recent_circs = function(items) {
+        if (items.length && (copy = items[0].copy)) {
+            var url = $location.path(
+                '/cat/item/' + copy.id() + '/circ_list').absUrl();
+            $window.open(url, '_blank').focus();
+        }
+    }
+
+    function generic_update(items, action) {
+        if (!items.length) return $q.when();
+        var hold_ids = items.map(function(item) {return item.hold.id()});
+        return egHolds[action](hold_ids).then(service.refresh);
+    }
+
+    service.set_copy_quality = function(items) {
+        generic_update(items, 'set_copy_quality'); }
+    service.edit_pickup_lib = function(items) {
+        generic_update(items, 'edit_pickup_lib'); }
+    service.edit_notify_prefs = function(items) {
+        generic_update(items, 'edit_notify_prefs'); }
+    service.edit_dates = function(items) {
+        generic_update(items, 'edit_dates'); }
+    service.suspend = function(items) {
+        generic_update(items, 'suspend_holds'); }
+    service.activate = function(items) {
+        generic_update(items, 'activate_holds'); }
+    service.set_top_of_queue = function(items) {
+        generic_update(items, 'set_top_of_queue'); }
+    service.clear_top_of_queue = function(items) {
+        generic_update(items, 'clear_top_of_queue'); }
+    service.transfer_to_marked_title = function(items) {
+        generic_update(items, 'transfer_to_marked_title'); }
+
+    service.mark_damaged = function(items) {
+        var copy_ids = items
+            .filter(function(item) { return Boolean(item.copy) })
+            .map(function(item) { return item.copy.id() });
+        if (copy_ids.length) 
+            egCirc.mark_damaged(copy_ids).then(service.refresh);
+    }
+
+    service.mark_missing = function(items) {
+        var copy_ids = items
+            .filter(function(item) { return Boolean(item.copy) })
+            .map(function(item) { return item.copy.id() });
+        if (copy_ids.length) 
+            egCirc.mark_missing(copy_ids).then(service.refresh);
+    }
+
+    service.retarget = function(items) {
+        var hold_ids = items.map(function(item) { return item.hold.id() });
+        egHolds.retarget(hold_ids).then(service.refresh);
+    }
+
+    return service;
+}])
+
+/**
+ * Hold details interface 
+ */
+.directive('egHoldDetails', function() {
+    return {
+        restrict : 'AE',
+        templateUrl : './circ/share/t_hold_details',
+        scope : {
+            holdId : '=',
+            // if set, called whenever hold details are retrieved.  The
+            // argument is the hold blob returned from hold.details.retrieve
+            holdRetrieved : '=',
+            showPatron : '='
+        },
+        controller : [
+                    '$scope','$modal','egCore','egHolds','egCirc',
+            function($scope , $modal , egCore , egHolds , egCirc) {
+
+                function draw() {
+                    if (!$scope.holdId) return;
+
+                    egCore.net.request(
+                        'open-ils.circ',
+                        'open-ils.circ.hold.details.retrieve.authoritative',
+                        egCore.auth.token(), $scope.holdId
+
+                    ).then(function(hold_data) { 
+                        egHolds.local_flesh(hold_data);
+    
+                        angular.forEach(hold_data, 
+                            function(val, key) { $scope[key] = val });
+
+                        // fetch + flesh the cancel_cause if needed
+                        if ($scope.hold.cancel_time()) {
+                            egHolds.get_cancel_reasons().then(function() {
+                                // egHolds caches the causes in egEnv
+                                $scope.hold.cancel_cause(
+                                    egCore.env.ahrcc.map[$scope.hold.cancel_cause()]);
+                            })
+                        }
+
+                        if ($scope.hold.current_copy()) {
+                            egCirc.flesh_copy_location($scope.hold.current_copy());
+                        }
+
+                        if ($scope.holdRetrieved)
+                            $scope.holdRetrieved(hold_data);
+
+                    });
+                }
+
+                $scope.show_notify_tab = function() {
+                    $scope.detail_tab = 'notify';
+                    egCore.pcrud.search('ahn',
+                        {hold : $scope.hold.id()}, 
+                        {flesh : 1, flesh_fields : {ahn : ['notify_staff']}}, 
+                        {atomic : true}
+                    ).then(function(nots) {
+                        $scope.hold.notifications(nots);
+                    });
+                }
+
+                $scope.delete_note = function(note) {
+                    egCore.pcrud.remove(note).then(function() {
+                        // remove the deleted note from the locally fleshed notes
+                        $scope.hold.notes(
+                            $scope.hold.notes().filter(function(n) {
+                                return n.id() != note.id()
+                            })
+                        );
+                    });
+                }
+
+                $scope.new_note = function() {
+                    return $modal.open({
+                        templateUrl : './circ/share/t_hold_note_dialog',
+                        controller : 
+                            ['$scope', '$modalInstance',
+                            function($scope, $modalInstance) {
+                                $scope.args = {};
+                                $scope.ok = function() {
+                                    $modalInstance.close($scope.args)
+                                },
+                                $scope.cancel = function($event) {
+                                    $modalInstance.dismiss();
+                                    $event.preventDefault();
+                                }
+                            }
+                        ]
+                    }).result.then(function(args) {
+                        var note = new egCore.idl.ahrn();
+                        note.hold($scope.hold.id());
+                        note.staff(true);
+                        note.slip(args.slip);
+                        note.pub(args.pub); 
+                        note.title(args.title);
+                        note.body(args.body);
+                        return egCore.pcrud.create(note).then(function(n) {
+                            $scope.hold.notes().push(n);
+                        });
+                    });
+                }
+
+                $scope.new_notification = function() {
+                    return $modal.open({
+                        templateUrl : './circ/share/t_hold_notification_dialog',
+                        controller : 
+                            ['$scope', '$modalInstance',
+                            function($scope, $modalInstance) {
+                                $scope.args = {};
+                                $scope.ok = function() {
+                                    $modalInstance.close($scope.args)
+                                },
+                                $scope.cancel = function($event) {
+                                    $modalInstance.dismiss();
+                                    $event.preventDefault();
+                                }
+                            }
+                        ]
+                    }).result.then(function(args) {
+                        var note = new egCore.idl.ahn();
+                        note.hold($scope.hold.id());
+                        note.method(args.method);
+                        note.note(args.note);
+                        note.notify_staff(egCore.auth.user().id());
+                        note.notify_time('now');
+                        return egCore.pcrud.create(note).then(function(n) {
+                            n.notify_staff(egCore.auth.user());
+                            $scope.hold.notifications().push(n);
+                        });
+                    });
+                }
+
+                $scope.$watch('holdId', function(newVal, oldVal) {
+                    if (newVal != oldVal) draw();
+                });
+
+                draw();
+            }
+        ]
+    }
+})
+
diff --git a/Open-ILS/web/js/ui/default/staff/package.json b/Open-ILS/web/js/ui/default/staff/package.json
new file mode 100644 (file)
index 0000000..310ee6b
--- /dev/null
@@ -0,0 +1,28 @@
+{
+  "name": "evergreen-staff-client",
+  "description": "Evergreen ILS Browser Staff Client",
+  "version": "0.0.1",
+  "license": "GPL",
+  "homepage": "http://evergreen-ils.org/",
+  "devDependencies": {
+    "bower": "^1.3.3",
+    "grunt": "~0.4.4",
+    "grunt-cli": "^0.1.13",
+    "grunt-contrib-concat": "^0.4.0",
+    "grunt-contrib-copy": "^0.5.0",
+    "grunt-contrib-cssmin": "^0.9.0",
+    "grunt-contrib-jasmine": "^0.6.4",
+    "grunt-contrib-uglify": "^0.4.0",
+    "grunt-exec": "^0.4.5",
+    "grunt-karma": "^0.8.3",
+    "karma": "^0.12.14",
+    "karma-jasmine": "^0.1.5",
+    "karma-phantomjs-launcher": "^0.1.4",
+    "karma-script-launcher": "~0.1.0",
+    "karma-spec-reporter": "0.0.12",
+    "karma-story-reporter": "^0.2.2"
+  },
+  "scripts": {
+    "test": "grunt test"
+  }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/services/auth.js b/Open-ILS/web/js/ui/default/staff/services/auth.js
new file mode 100644 (file)
index 0000000..75ae9ba
--- /dev/null
@@ -0,0 +1,262 @@
+/* Core Sevice - egAuth
+ *
+ * Manages login and auth session retrieval.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egAuth', 
+       ['$q','$timeout','$rootScope','egNet','egHatch', 
+function($q , $timeout , $rootScope , egNet , egHatch) {
+
+    var service = {
+        // the currently active user (au) object
+        user : function() {
+            return this._user;
+        },
+
+        // the currently active auth token string
+        token : function() {
+            return egHatch.getLocalItem('eg.auth.token');
+        },
+
+        // authtime in seconds
+        authtime : function() {
+            return egHatch.getLocalItem('eg.auth.time');
+        },
+
+        // the currently active workstation name
+        // For ws_ou or wsid(), see egAuth.user().ws_ou(), etc.
+        workstation : function() {
+            return this.ws;
+        }
+    };
+
+    /* Returns a promise, which is resolved if valid
+     * authtoken is found, otherwise rejected */
+    service.testAuthToken = function() {
+        var deferred = $q.defer();
+        var token = service.token();
+
+        if (token) {
+
+            egNet.request(
+                'open-ils.auth',
+                'open-ils.auth.session.retrieve', token)
+
+            .then(function(user) {
+                if (user && user.classname) {
+                    // authtoken test succeeded
+                    service._user = user;
+                    service.poll();
+                   
+                    if (user.wsid()) {
+                        // user previously logged in with a workstation. 
+                        // Find the workstation name from the list 
+                        // of configured workstations
+                        egHatch.getItem('eg.workstation.all')
+                        .then(function(all) { 
+                            if (all) {
+                                var ws = all.filter(
+                                    function(w) {return w.id == user.wsid()})[0];
+                                if (ws) service.ws = ws.name;
+                            }
+                            deferred.resolve(); // found WS
+                        });
+                    } else {
+                        deferred.resolve(); // no WS
+                    }
+                } else {
+                    // authtoken test failed
+                    egHatch.removeLocalItem('eg.auth.token');
+                    deferred.reject(); 
+                }
+            });
+
+        } else {
+            // no authtoken to test
+            deferred.reject();
+        }
+
+        return deferred.promise;
+    };
+
+    /**
+     * Returns a promise, which is resolved on successful 
+     * login and rejected on failed login.
+     */
+    service.login = function(args) {
+        var deferred = $q.defer();
+        egNet.request(
+            'open-ils.auth',
+            'open-ils.auth.authenticate.init', args.username).then(
+            function(seed) {
+                args.password = hex_md5(seed + hex_md5(args.password))
+                egNet.request(
+                    'open-ils.auth',
+                    'open-ils.auth.authenticate.complete', args).then(
+                    function(evt) {
+                        if (evt.textcode == 'SUCCESS') {
+                            service.ws = args.workstation; 
+                            service.poll();
+                            egHatch.setLocalItem(
+                                'eg.auth.token', evt.payload.authtoken);
+                            egHatch.setLocalItem(
+                                'eg.auth.time', evt.payload.authtime);
+                            deferred.resolve();
+                        } else {
+                            // note: the likely outcome here is a NO_SESION
+                            // server event, which results in broadcasting an 
+                            // egInvalidAuth by egNet. 
+                            console.error('login failed ' + js2JSON(evt));
+                            deferred.reject();
+                        }
+                    }
+                )
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    /**
+     * Force-check the validity of the authtoken on occasion. 
+     * This allows us to redirect an idle staff client back to the login
+     * page after the session times out.  Otherwise, the UI would stay
+     * open with potentially sensitive data visible.
+     * TODO: What is the practical difference (for a browser) between 
+     * checking auth validity and the ui.general.idle_timeout setting?
+     * Does that setting serve a purpose in a browser environment?
+     */
+    service.poll = function() {
+        if (!service.authtime()) return;
+
+        $timeout(
+            function() {
+                if (!service.authtime()) return;
+                egNet.request(                                                     
+                    'open-ils.auth',                                               
+                    'open-ils.auth.session.retrieve', service.token())   
+                .then(function(user) {
+                    if (user && user.classname) { // all good
+                        service.poll();
+                    } else {
+                        $rootScope.$broadcast('egAuthExpired') 
+                    }
+                })
+            },
+            // add a 5 second delay to give the token plenty of time
+            // to expire on the server.
+            service.authtime() * 1000 + 5000
+        );
+    }
+
+    service.logout = function() {
+        if (service.token()) {
+            egNet.request(
+                'open-ils.auth', 
+                'open-ils.auth.session.delete', 
+                service.token()); // fire and forget
+            egHatch.removeLocalItem('eg.auth.token');
+            egHatch.removeLocalItem('eg.auth.time');
+        }
+        service._user = null;
+    };
+
+    return service;
+}])
+
+
+/**
+ * Service for testing user permissions.
+ * Note: this cannot live within egAuth, because it creates a circular
+ * dependency of egOrg -> egEnv -> egAuth -> egOrg
+ */
+.factory('egPerm', 
+       ['$q','egNet','egAuth','egOrg',
+function($q , egNet , egAuth , egOrg) {
+    var service = {};
+
+    /*
+     * Returns the full list of org unit objects at which the currently
+     * logged in user has the selected permissions.
+     * @permList - list or string.  If a list, the response object is a
+     * hash of perm => orgList maps.  If a string, the response is the
+     * org list for the requested perm.
+     */
+    service.hasPermAt = function(permList, asId) {
+        var deferred = $q.defer();
+        var isArray = true;
+        if (!angular.isArray(permList)) {
+            isArray = false;
+            permList = [permList];
+        }
+        // as called, this method will return the top-most org unit of the
+        // sub-tree at which this user has the selected permission.
+        // From there, flesh the descendant orgs locally.
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.has_work_perm_at.batch',
+            egAuth.token(), permList
+        ).then(function(resp) {
+            var answer = {};
+            angular.forEach(permList, function(perm) {
+                var all = [];
+                angular.forEach(resp[perm], function(oneOrg) {
+                    all = all.concat(egOrg.descendants(oneOrg, asId));
+                });
+                answer[perm] = all;
+            });
+            if (!isArray) answer = answer[permList[0]];
+            deferred.resolve(answer);
+        });
+       return deferred.promise;
+    };
+
+
+    /**
+     * Returns a hash of perm => hasPermBool for each requested permission.
+     * If the authenticated user has no workstation, no checks are made
+     * and all permissions return false.
+     */
+    service.hasPermHere = function(permList) {
+        var response = {};
+
+        var isArray = true;
+        if (!angular.isArray(permList)) {
+            isArray = false;
+            permList = [permList];
+        }
+
+        // no workstation, all are false
+        if (egAuth.user().wsid() === null) {
+            console.warn("egPerm.hasPermHere() called with no workstation");
+            if (isArray) {
+                response = permList.map(function(perm) {
+                    return response[perm] = false;
+                });
+            } else {
+                response = false;
+            }
+            return $q.when(response);
+        }
+
+        ws_ou = Number(egAuth.user().ws_ou()); // from string
+
+        return service.hasPermAt(permList, true)
+        .then(function(orgMap) {
+            angular.forEach(orgMap, function(orgIds, perm) {
+                // each permission is mapped to a flat list of org unit ids,
+                // including descendants.  See if our workstation org unit
+                // is in the list.
+                response[perm] = orgIds.indexOf(ws_ou) > -1;
+            });
+            if (!isArray) response = response[permList[0]];
+            return response;
+        });
+    }
+
+    return service;
+}])
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/core.js b/Open-ILS/web/js/ui/default/staff/services/core.js
new file mode 100644 (file)
index 0000000..e0ef021
--- /dev/null
@@ -0,0 +1,6 @@
+
+/**
+ * egCoreMod houses all of the services, etc. required by all pages
+ * for basic functionality.
+ */
+angular.module('egCoreMod', ['cfp.hotkeys']);
diff --git a/Open-ILS/web/js/ui/default/staff/services/coresvc.js b/Open-ILS/web/js/ui/default/staff/services/coresvc.js
new file mode 100644 (file)
index 0000000..6909978
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * egCore service
+ *
+ * Aggregates all core services into a container service.  This allows
+ * use of core services without having to inject each individually.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egCore', 
+       ['egIDL','egNet','egEnv','egOrg','egPCRUD','egEvent','egAuth',
+        'egPerm','egHatch','egPrint','egStartup','egStrings',
+function(egIDL , egNet , egEnv , egOrg , egPCRUD , egEvent , egAuth , 
+         egPerm , egHatch , egPrint , egStartup , egStrings) {
+
+    return {
+        idl     : egIDL,
+        net     : egNet,
+        env     : egEnv,
+        org     : egOrg,
+        pcrud   : egPCRUD,
+        evt     : egEvent,
+        auth    : egAuth,
+        perm    : egPerm,
+        hatch   : egHatch,
+        print   : egPrint,
+        startup : egStartup,
+        strings : egStrings
+    };
+
+}]);
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/eframe.js b/Open-ILS/web/js/ui/default/staff/services/eframe.js
new file mode 100644 (file)
index 0000000..b706a50
--- /dev/null
@@ -0,0 +1,210 @@
+angular.module('egCoreMod')
+
+/*
+ * Iframe container for (mostly legacy) embedded interfaces
+ */
+.directive('egEmbedFrame', function() {
+    return {
+        restrict : 'AE',
+        replace : true,
+        scope : {
+            // URL to load in the embed iframe
+            url : '=',
+
+            // optional hash of functions which augment or override 
+            // the stock xulG functions defined below.
+            handlers : '=',
+
+            // called after onload of each new iframe page
+            onchange : '=',
+        },
+
+        templateUrl : './share/t_eframe',
+
+        controller : 
+                   ['$scope','$window','$location','$q','$timeout','egCore',
+            function($scope , $window , $location , $q , $timeout , egCore) {
+
+            // Set the iframe height to just under the window height.
+            // leave room for the navbar, padding, margins, etc.
+            $scope.height = $window.outerHeight - 300;
+
+            // browser client doesn't use cookies, so we don't load the
+            // (at the time of writing, quite limited) angular.cookies
+            // module.  We could load something, but this seems to work
+            // well enough for setting the auth cookie (at least, until 
+            // it doesn't).
+            //
+            // note: document.cookie is smart enough to leave unreferenced
+            // cookies alone, so contrary to how this might look, it's not 
+            // deleting other cookies (anoncache, etc.)
+            
+            // delete any existing ses cookie
+            $window.document.cookie = "ses=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+            // push our authtoken in
+            $window.document.cookie = 'ses=' + egCore.auth.token() + '; path=/; secure'
+
+            // $location has functions for modifying paths and search,
+            // but they all assume you are staying within the angular
+            // app, which we are not.  Build the URLs by hand.
+            function open_tab(path) {
+                var url = 'https://' + $window.location.hostname + 
+                    egCore.env.basePath + path;
+                console.debug('egEmbedFrame opening tab ' + url);
+                $window.open(url, '_blank').focus();
+            }
+
+            // define our own xulG functions to be inserted into the
+            // iframe.  NOTE: window-level functions are bad.  Though
+            // there is probably a way, I was unable to correctly wire
+            // up the iframe onload handler within the controller or link
+            // funcs.  In any event, the code below is meant as a stop-gap
+            // for porting dojo, etc. apps to angular apps and should
+            // eventually go away.
+            // NOTE: catalog integration is not a stop-gap
+            $window.egEmbedFrameLoader = function(iframe) {
+
+                var page = iframe.contentWindow.location.href;
+                console.debug('egEmbedFrameLoader(): ' + page);
+
+                // reload ifram page w/o reloading the entire UI
+                $scope.reload = function() {
+                    iframe.contentWindow.location.replace(
+                        iframe.contentWindow.location);
+                }
+
+                // tell the iframe'd window its inside the staff client
+                iframe.contentWindow.IAMXUL = true;
+
+                // also tell it it's inside the browser client, which 
+                // may be needed in a few special cases.
+                iframe.contentWindow.IAMBROWSER /* hear me roar */ = true; 
+
+                // XUL has a dump() function which is occasinally called 
+                // from embedded browsers.
+                iframe.contentWindow.dump = function(msg) {
+                    console.debug('egEmbedFrame:dump(): ' + msg);
+                }
+
+                // define a few commonly used stock xulG handlers. 
+                
+                iframe.contentWindow.xulG = {
+                    // patron search
+                    spawn_search : function(search) {
+                        open_tab('/circ/patron/search?search=' 
+                            + encodeURIComponent(js2JSON(search)));
+                    },
+
+                    // edit an existing user
+                    spawn_editor : function(info) {
+                        if (info.usr) {
+                            open_tab('/circ/patron/register/edit/' + info.usr);
+                        
+                        } else if (info.clone) {
+                            // FIXME: The save-and-clone operation in the
+                            // patron editor results in this action.  
+                            // For some reason, this specific function results
+                            // in a new browser window opening instead of a 
+                            // browser tab.  Possibly this is caused by the 
+                            // fact that the action occurs as a result of a
+                            // button click instead of an href.  *shrug*.
+                            // It's obnoxious.
+                            open_tab('/circ/patron/register/clone/' + info.clone);
+                        } 
+                    },
+
+                    // open a user account
+                    new_patron_tab : function(tab_info, usr_info) {
+                        open_tab('/circ/patron/' + usr_info.id + '/checkout');
+                    },
+
+                    get_barcode_and_settings_async : function(barcode, only_settings) {
+                        if (!barcode) return $q.reject();
+                        var deferred = $q.defer();
+
+                        var barcode_promise = $q.when(barcode);
+                        if (!only_settings) {
+
+                            // first verify / locate the barcode
+                            barcode_promise = egCore.net.request(
+                                'open-ils.actor',
+                                'open-ils.actor.get_barcodes',
+                                egCore.auth.token(), 
+                                egCore.auth.user().ws_ou(), 'actor', barcode
+                            ).then(function(resp) {
+
+                                if (!resp || egCore.evt.parse(resp) || !resp.length) {
+                                    console.error('user not found: ' + barcode);
+                                    deferred.reject();
+                                    return null;
+                                } 
+
+                                resp = resp[0];
+                                return barcode = resp.barcode;
+                            });
+                        }
+
+                        barcode_promise.then(function(barcode) {
+                            if (!barcode) return;
+
+                            return egCore.net.request(
+                                'open-ils.actor',
+                                'open-ils.actor.user.fleshed.retrieve_by_barcode',
+                                egCore.auth.token(), barcode);
+
+                        }).then(function(user) {
+                            if (!user) return null;
+
+                            if (e = egCore.evt.parse(user)) {
+                                console.error('user fetch failed : ' + e.toString());
+                                deferred.reject();
+                                return null;
+                            }
+
+                            // copied more or less directly from XUL menu.js
+                            var settings = {};
+                            for(var i = 0; i < user.settings().length; i++) {
+                                settings[user.settings()[i].name()] = 
+                                    JSON2js(user.settings()[i].value());
+                            }
+
+                            if(!settings['opac.default_phone'] && user.day_phone()) 
+                                settings['opac.default_phone'] = user.day_phone();
+                            if(!settings['opac.hold_notify'] && settings['opac.hold_notify'] !== '') 
+                                settings['opac.hold_notify'] = 'email:phone';
+
+                            // Taken from patron/util.js format_name
+                            // FIXME: I18n
+                            var patron_name = 
+                                ( user.prefix() ? user.prefix() + ' ' : '') +
+                                user.family_name() + ', ' +
+                                user.first_given_name() + ' ' +
+                                ( user.second_given_name() ? user.second_given_name() + ' ' : '' ) +
+                                ( user.suffix() ? user.suffix() : '');
+
+                            deferred.resolve({
+                                "barcode": barcode, 
+                                "settings" : settings, 
+                                "user_email" : user.email(), 
+                                "patron_name" : patron_name
+                            });
+                        });
+
+                        return deferred.promise;
+                    }
+                }
+
+                if ($scope.handlers) {
+                    $scope.handlers.reload = $scope.reload;
+                    angular.forEach($scope.handlers, function(val, key) {
+                        iframe.contentWindow.xulG[key] = val;
+                    });
+                }
+
+                if ($scope.onchange) $scope.onchange(page);
+            }
+        }]
+    }
+})
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/env.js b/Open-ILS/web/js/ui/default/staff/services/env.js
new file mode 100644 (file)
index 0000000..25198fa
--- /dev/null
@@ -0,0 +1,165 @@
+/**
+ * Core Service - egEnv
+ *
+ * Manages startup data loading and data caching.  
+ * All registered loaders run * simultaneously.  When all promises 
+ * are resolved, the promise * returned by egEnv.load() is resolved.
+ *
+ * There are two main uses cases for egEnv:
+ *
+ * 1. When loading a variety of objects on page load, having them
+ * loaded with egEnv ensures that the load will happen in parallel
+ * and that it will complete before egStartup completes, which is 
+ * generally before page controllers run.
+ *
+ * 2. When loading generic IDL data across different services,
+ * having them all stash the data in egEnv means they each have
+ * an agreed-upon cache mechanism.
+ *
+ * It's also a good place to stash other environmental tidbits...
+ *
+ * Generic and class-based loaders are supported.  
+ *
+ * To load a registred class, push the class hint onto 
+ * egEnv.loadClasses.  
+ *
+ * // will cause all 'pgt' objects to be fetched
+ * egEnv.loadClasses.push('pgt');
+ *
+ * To register a new class loader,attach a loader function to 
+ * egEnv.classLoaders, keyed on the class hint, which returns a promise.
+ *
+ * egEnv.classLoaders.ccs = function() { 
+ *    // loads copy status objects, returns promise
+ * };
+ *
+ * Generic loaders go onto the egEnv.loaders array.  Each should
+ * return a promise.
+ *
+ * egEnv.loaders.push(function() {
+ *    return egNet.request(...)
+ *    .then(function(stuff) { console.log('stuff!') 
+ * });
+ */
+
+angular.module('egCoreMod')
+
+// env fetcher
+.factory('egEnv', 
+       ['$q','$window','egAuth','egPCRUD','egIDL',
+function($q,  $window , egAuth,  egPCRUD,  egIDL) { 
+
+    var service = {
+        // collection of custom loader functions
+        loaders : []
+    };
+
+
+    // <base href="<basePath>"/> from the current index page
+    // Currently defaults to /eg/staff for all pages.
+    // Use $location.path() to jump around within an app.
+    // Use egEnv.basePath to create URLs to new apps.
+    // NOTE: the dynamic version below derived from the DOM does not
+    // work w/ unit tests.  Use hard-coded value instead for now.
+    service.basePath = '/eg/staff/';
+        //$window.document.getElementsByTagName('base')[0].getAttribute('href');
+
+    /* returns a promise, loads all of the specified classes */
+    service.load = function() {
+        // always assume the user is logged in
+        if (!egAuth.user()) return $q.when();
+
+        var allPromises = [];
+        var classes = this.loadClasses;
+        console.debug('egEnv loading classes => ' + classes);
+
+        angular.forEach(classes, function(cls) {
+            allPromises.push(service.classLoaders[cls]());
+        });
+        angular.forEach(this.loaders, function(loader) {
+            allPromises.push(loader());
+        });
+
+        return $q.all(allPromises).then(
+            function() { console.debug('egEnv load complete') });
+    };
+
+    /** given a tree-shaped collection, captures the tree and
+     *  flattens the tree for absorption.
+     */
+    service.absorbTree = function(tree, class_) {
+        var list = [];
+        function squash(node) {
+            list.push(node);
+            angular.forEach(node.children(), squash);
+        }
+        squash(tree);
+        var blob = service.absorbList(list, class_);
+        blob.tree = tree;
+    };
+
+    /** caches the object list both as the list and an id => object map */
+    service.absorbList = function(list, class_) {
+        var blob;
+        var pkey = egIDL.classes[class_].pkey;
+
+        if (service[class_]) {
+            // appending data to an existing class.  Useful for receiving 
+            // class elements as-needed.  Avoid adding items which are 
+            // already tracked in the list.
+            blob = service[class_];
+            angular.forEach(list, function(item) {
+                if (!service[class_].map[item[pkey]()]) 
+                    blob.list.push(item);
+            });
+        } else {
+            blob = {list : list, map : {}};
+        }
+
+        angular.forEach(list, function(item) {blob.map[item[pkey]()] = item});
+        service[class_] = blob;
+        return blob;
+    };
+
+    /* 
+     * list of classes to load on every page, regardless of whether
+     * a page-specific list is provided.
+     */
+    service.loadClasses = ['aou'];
+
+    /*
+     * Default class loaders.  Only add classes directly to this file
+     * that are loaded practically always.  All other app-specific
+     * classes should be registerd from within the app.
+     */
+    service.classLoaders = {
+        aou : function() {
+
+            // EXPERIMENT: cache the org tree in session storage.
+            // This means that if the org tree changes, users will have to
+            // open the client in a new browser tab to clear the cached tree.
+            var treeJSON = $window.sessionStorage.getItem('eg.env.aou.tree');
+            if (treeJSON) {
+                console.debug('serving org tree from cache');
+                var tree = JSON2js(treeJSON);
+                service.absorbTree(tree, 'aou')
+                return $q.when(tree);
+            }
+
+            return egPCRUD.search('aou', {parent_ou : null}, 
+                {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}}
+            ).then(
+                function(tree) {
+                    $window.sessionStorage.setItem(
+                        'eg.env.aou.tree', js2JSON(tree));
+                    service.absorbTree(tree, 'aou')
+                }
+            );
+        },
+    };
+
+    return service;
+}]);
+
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/event.js b/Open-ILS/web/js/ui/default/staff/services/event.js
new file mode 100644 (file)
index 0000000..dbf0d82
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Core Service - egEvent
+ *
+ * Models / tests event objects returned by many server APIs. 
+ * E.g.
+ * {
+ *  "stacktrace":"..."
+ *  "ilsevent":"1575",
+ *  "pid":"28258",
+ *  "desc":"The requested container_biblio_record_entry_bucket was not found",
+ *  "payload":"2",
+ *  "textcode":"CONTAINER_BIBLIO_RECORD_ENTRY_BUCKET_NOT_FOUND",
+ *  "servertime":"Wed Nov 6 16:05:50 2013"
+ * }
+ *
+ * var evt = egEvent.parse(thing);
+ * if (evt) console.error(evt);
+ *
+ */
+
+angular.module('egCoreMod')
+
+.factory('egEvent', function() {
+
+    return {
+        parse : function(thing) {
+
+            function EGEvent(args) {
+                this.code = args.ilsevent;
+                this.textcode = args.textcode;
+                this.desc = args.desc;
+                this.payload = args.payload;
+                this.debug = args.stacktrace;
+                this.servertime = args.servertime;
+                this.ilsperm = args.ilsperm;
+                this.ilspermloc = args.ilspermloc;
+                this.note = args.note;
+                this.success = this.textcode == 'SUCCESS';
+                this.toString = function() {
+                    var s = 'Event: ' + (this.code || '') + ':' + 
+                        this.textcode + ' -> ' + new String(this.desc);
+                    if(this.ilsperm)
+                        s += ' ' + this.ilsperm + '@' + this.ilspermloc;
+                    if(this.note)
+                        s += '\n' + this.note;
+                    return s;
+                }
+            }
+            
+            if(thing && typeof thing == 'object' && 'textcode' in thing)
+                return new EGEvent(thing);
+            return null;
+        }
+    }
+});
diff --git a/Open-ILS/web/js/ui/default/staff/services/file.js b/Open-ILS/web/js/ui/default/staff/services/file.js
new file mode 100644 (file)
index 0000000..8dc0c2b
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * File upload reader.
+ * http://stackoverflow.com/questions/17063000/ng-model-for-input-type-file
+ *
+ * After reading, the contents will be available in the scope variable
+ * referred to by container="..."
+ */
+
+angular.module('egCoreMod')
+.directive("egFileReader", [function () {
+    return {
+        scope: {
+            container: "="
+        },
+        link: function (scope, element, attributes) {
+            // TODO: support DataURL, etc. via attrs
+            element.bind("change", function (changeEvent) {
+                var reader = new FileReader();
+                reader.onload = function (loadEvent) {
+                    scope.$apply(function () {
+                        scope.container = loadEvent.target.result;
+                    });
+                }
+                reader.readAsText(changeEvent.target.files[0]);
+            });
+        }
+    }
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/grid.js b/Open-ILS/web/js/ui/default/staff/services/grid.js
new file mode 100644 (file)
index 0000000..a554e93
--- /dev/null
@@ -0,0 +1,1522 @@
+angular.module('egGridMod', 
+    ['egCoreMod', 'egUiMod', 'ui.bootstrap'])
+
+.directive('egGrid', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+
+            // IDL class hint (e.g. "aou")
+            idlClass : '@',
+
+            // default page size
+            pageSize : '@',
+
+            // if true, grid columns are derived from all non-virtual
+            // fields on the base idlClass
+            autoFields : '@',
+
+            // grid preferences will be stored / retrieved with this key
+            persistKey : '@',
+
+            // field whose value is unique and may be used for item
+            // reference / lookup.  This will usually be someting like
+            // "id".  This is not needed when using autoFields, since we
+            // can determine the primary key directly from the IDL.
+            idField : '@',
+
+            // Reference to externally provided egGridDataProvider
+            itemsProvider : '=',
+
+            // comma-separated list of supported or disabled grid features
+            // supported features:
+            //  -display : columns are hidden by default
+            //  -sort    : columns are unsortable by default 
+            //  -multisort : sort priorities config disabled by default
+            features : '@',
+
+            // optional primary grid label
+            mainLabel : '@',
+
+            // if true, use the IDL class label as the mainLabel
+            autoLabel : '=', 
+
+            // optional context menu label
+            menuLabel : '@',
+
+            // Hash of control functions.
+            //
+            //  These functions are defined by the calling scope and 
+            //  invoked as-is by the grid w/ the specified parameters.
+            //
+            //  itemRetrieved     : function(item) {}
+            //  allItemsRetrieved : function() {}
+            //
+            //  ---------------
+            //  These functions are defined by the grid and thus
+            //  replace any values defined for these attributes from the
+            //  calling scope.
+            //
+            //  activateItem  : function(item) {}
+            //  allItems      : function(allItems) {}
+            //  selectedItems : function(selected) {}
+            //  selectItems   : function(ids) {}
+            //  setQuery      : function(queryStruct) {} // causes reload
+            //  setSort       : function(sortSturct) {} // causes reload
+            gridControls : '=',
+        },
+
+        // TODO: avoid hard-coded url
+        templateUrl : '/eg/staff/share/t_autogrid', 
+
+        link : function(scope, element, attrs) {     
+            // link() is called after page compilation, which means our
+            // eg-grid-field's have been parsed and loaded.  Now it's 
+            // safe to perform our initial page load.
+
+            // load auto fields after eg-grid-field's so they are not clobbered
+            scope.handleAutoFields();
+            scope.collect();
+        },
+
+        controller : [
+                    '$scope','$q','egCore','egGridFlatDataProvider','$location',
+                    'egGridColumnsProvider','$filter','$window','$sce','$timeout',
+            function($scope,  $q , egCore,  egGridFlatDataProvider , $location,
+                     egGridColumnsProvider , $filter , $window , $sce , $timeout) {
+
+            var grid = this;
+
+            grid.init = function() {
+                grid.offset = 0;
+                grid.limit = Number($scope.pageSize) || 25;
+                $scope.items = [];
+                $scope.showGridConf = false;
+                grid.totalCount = -1;
+                $scope.selected = {};
+                $scope.actions = []; // actions for selected items
+                $scope.menuItems = []; // global actions
+
+                // remove some unneeded values from the scope to reduce bloat
+
+                grid.idlClass = $scope.idlClass;
+                delete $scope.idlClass;
+
+                grid.persistKey = $scope.persistKey;
+                delete $scope.persistKey;
+
+                grid.indexField = $scope.idField;
+                delete $scope.idField;
+
+                grid.dataProvider = $scope.itemsProvider;
+
+                var features = ($scope.features) ? 
+                    $scope.features.split(',') : [];
+                delete $scope.features;
+
+                if (!grid.indexField && grid.idlClass)
+                    grid.indexField = egCore.idl.classes[grid.idlClass].pkey;
+
+                grid.columnsProvider = egGridColumnsProvider.instance({
+                    idlClass : grid.idlClass,
+                    defaultToHidden : (features.indexOf('-display') > -1),
+                    defaultToNoSort : (features.indexOf('-sort') > -1),
+                    defaultToNoMultiSort : (features.indexOf('-multisort') > -1)
+                });
+
+                $scope.handleAutoFields = function() {
+                    if ($scope.autoFields) {
+                        if (grid.autoLabel) {
+                            $scope.mainLabel = 
+                                egCore.idl.classes[grid.idlClass].label;
+                        }
+                        grid.columnsProvider.compileAutoColumns();
+                        delete $scope.autoFields;
+                    }
+                }
+   
+                if (!grid.dataProvider) {
+                    // no provider, um, provided.
+                    // Use a flat data provider
+
+                    grid.selfManagedData = true;
+                    grid.dataProvider = egGridFlatDataProvider.instance({
+                        indexField : grid.indexField,
+                        idlClass : grid.idlClass,
+                        columnsProvider : grid.columnsProvider,
+                        query : $scope.query
+                    });
+                }
+
+                $scope.itemFieldValue = grid.dataProvider.itemFieldValue;
+                $scope.indexValue = function(item) {
+                    return grid.indexValue(item)
+                };
+
+                grid.applyControlFunctions();
+
+                grid.loadConfig().then(function() { 
+                    // link columns to scope after loadConfig(), since it
+                    // replaces the columns array.
+                    $scope.columns = grid.columnsProvider.columns;
+                });
+
+                // NOTE: grid.collect() is first called from link(), not here.
+            }
+
+            // link our control functions into the gridControls 
+            // scope object so the caller can access them.
+            grid.applyControlFunctions = function() {
+
+                // we use some of these controls internally, so sett
+                // them up even if the caller doesn't request them.
+                var controls = $scope.gridControls || {};
+
+                // link in the control functions
+                controls.selectedItems = function() {
+                    return grid.getSelectedItems()
+                }
+
+                controls.allItems = function() {
+                    return $scope.items;
+                }
+
+                controls.selectItems = function(ids) {
+                    if (!ids) return;
+                    $scope.selected = {};
+                    angular.forEach(ids, function(i) {
+                        $scope.selected[''+i] = true;
+                    });
+                }
+
+                // if the caller provided a functional setQuery,
+                // extract the value before replacing it
+                if (controls.setQuery) {
+                    grid.dataProvider.query = 
+                        controls.setQuery();
+                }
+
+                controls.setQuery = function(query) {
+                    grid.dataProvider.query = query;
+                    controls.refresh();
+                }
+
+                // if the caller provided a functional setSort
+                // extract the value before replacing it
+                grid.dataProvider.sort = 
+                    controls.setSort ?  controls.setSort() : [];
+
+                controls.setSort = function(sort) {
+                    controls.refresh();
+                }
+
+                controls.refresh = function(noReset) {
+                    if (!noReset) grid.offset = 0;
+                    grid.collect();
+                }
+
+                controls.setLimit = function(limit) {
+                    grid.limit = limit;
+                }
+                controls.getLimit = function() {
+                    return grid.limit;
+                }
+                controls.setOffset = function(offset) {
+                    grid.offset = offset;
+                }
+                controls.getOffset = function() {
+                    return grid.offset;
+                }
+
+                grid.dataProvider.refresh = controls.refresh;
+                grid.controls = controls;
+            }
+
+            // add a new (global) grid menu item
+            grid.addMenuItem = function(item) {
+                $scope.menuItems.push(item);
+                var handler = item.handler;
+                item.handler = function() {
+                    $scope.gridMenuIsOpen = false; // close menu
+                    if (handler) {
+                        handler(item, 
+                            item.handlerData, grid.getSelectedItems());
+                    }
+                }
+            }
+
+            // add a selected-items action
+            grid.addAction = function(act) {
+                $scope.actions.push(act);
+            }
+
+            // remove the stored column configuration preferenc, then recover 
+            // the column visibility information from the initial page load.
+            $scope.resetColumns = function() {
+                $scope.gridColumnPickerIsOpen = false;
+                egCore.hatch.removeItem('eg.grid.' + grid.persistKey)
+                .then(function() {
+                    grid.columnsProvider.reset(); 
+                    if (grid.selfManagedData) grid.collect();
+                });
+            }
+
+            $scope.showAllColumns = function() {
+                grid.columnsProvider.showAllColumns();
+            }
+
+            $scope.hideAllColumns = function() {
+                grid.columnsProvider.hideAllColumns();
+            }
+
+            $scope.toggleColumnVisibility = function(col) {
+                $scope.gridColumnPickerIsOpen = false;
+                col.visible = !col.visible;
+
+                // egGridFlatDataProvider only retrieves data to be
+                // displayed.  When column visibility changes, it's
+                // necessary to fetch the newly visible column data.
+                if (grid.selfManagedData) grid.collect();
+            }
+
+            // save the columns configuration (position, sort, width) to
+            // eg.grid.<persist-key>
+            $scope.saveConfig = function() {
+                $scope.gridColumnPickerIsOpen = false;
+
+                if (!grid.persistKey) {
+                    console.warn(
+                        "Cannot save settings without a grid persist-key");
+                    return;
+                }
+
+                // only store information about visible columns.
+                var conf = grid.columnsProvider.columns.filter(
+                    function(col) {return Boolean(col.visible) });
+
+                // now scrunch the data down to just the needed info
+                conf = conf.map(function(col) {
+                    var c = {name : col.name}
+                    // Apart from the name, only store non-default values.
+                    // No need to store col.visible, since that's implicit
+                    if (col.flex != 2) c.flex = col.flex;
+                    if (Number(col.sort)) c.sort = Number(c.sort);
+                    return c;
+                });
+
+                egCore.hatch.setItem('eg.grid.' + grid.persistKey, conf)
+                .then(function() { 
+                    // Save operation performed from the grid configuration UI.
+                    // Hide the configuration UI and re-draw w/ sort applied
+                    if ($scope.showGridConf) 
+                        $scope.toggleConfDisplay();
+                });
+            }
+
+            // load the columns configuration (position, sort, width) from
+            // eg.grid.<persist-key> and apply the loaded settings to the
+            // columns on our columnsProvider
+            grid.loadConfig = function() {
+                if (!grid.persistKey) return $q.when();
+
+                return egCore.hatch.getItem('eg.grid.' + grid.persistKey)
+                .then(function(conf) {
+                    if (!conf) return;
+
+                    var columns = grid.columnsProvider.columns;
+                    var new_cols = [];
+
+                    angular.forEach(conf, function(col) {
+                        var grid_col = columns.filter(
+                            function(c) {return c.name == col.name})[0];
+
+                        if (!grid_col) {
+                            // saved column does not match a column in the 
+                            // current grid.  skip it.
+                            return;
+                        }
+
+                        grid_col.flex = col.flex || 2;
+                        grid_col.sort = col.sort || 0;
+                        // all saved columns are assumed to be true
+                        grid_col.visible = true;
+                        new_cols.push(grid_col);
+                    });
+
+                    // columns which are not expressed within the saved 
+                    // configuration are marked as non-visible and 
+                    // appended to the end of the new list of columns.
+                    angular.forEach(columns, function(col) {
+                        var found = conf.filter(
+                            function(c) {return (c.name == col.name)})[0];
+                        if (!found) {
+                            col.visible = false;
+                            new_cols.push(col);
+                        }
+                    });
+
+                    grid.columnsProvider.columns = new_cols;
+                    grid.compileSort();
+                });
+            }
+
+            $scope.onContextMenu = function($event) {
+                var col = angular.element($event.target).attr('column');
+                console.log('selected column ' + col);
+            }
+
+            $scope.page = function() {
+                return (grid.offset / grid.limit) + 1;
+            }
+
+            $scope.goToPage = function(page) {
+                page = Number(page);
+                if (angular.isNumber(page) && page > 0) {
+                    grid.offset = (page - 1) * grid.limit;
+                    grid.collect();
+                }
+            }
+
+            $scope.offset = function(o) {
+                if (angular.isNumber(o))
+                    grid.offset = o;
+                return grid.offset 
+            }
+
+            $scope.limit = function(l) { 
+                if (angular.isNumber(l))
+                    grid.limit = l;
+                return grid.limit 
+            }
+
+            $scope.onFirstPage = function() {
+                return grid.offset == 0;
+            }
+
+            $scope.hasNextPage = function() {
+                // we have less data than requested, there must
+                // not be any more pages
+                if (grid.count() < grid.limit) return false;
+
+                // if the total count is not known, assume that a full
+                // page of data implies more pages are available.
+                if (grid.totalCount == -1) return true;
+
+                // we have a full page of data, but is there more?
+                return grid.totalCount > (grid.offset + grid.count());
+            }
+
+            $scope.incrementPage = function() {
+                grid.offset += grid.limit;
+                grid.collect();
+            }
+
+            $scope.decrementPage = function() {
+                if (grid.offset < grid.limit) {
+                    grid.offset = 0;
+                } else {
+                    grid.offset -= grid.limit;
+                }
+                grid.collect();
+            }
+
+            // number of items loaded for the current page of results
+            grid.count = function() {
+                return $scope.items.length;
+            }
+
+            // returns the unique identifier value for the provided item
+            // for internal consistency, indexValue is always coerced 
+            // into a string.
+            grid.indexValue = function(item) {
+                if (angular.isObject(item)) {
+                    if (item !== null) {
+                        if (angular.isFunction(item[grid.indexField]))
+                            return ''+item[grid.indexField]();
+                        return ''+item[grid.indexField]; // flat data
+                    }
+                }
+                // passed a non-object; assume it's an index
+                return ''+item; 
+            }
+
+            // fires the action handler function for a context action
+            $scope.actionLauncher = function(action) {
+                if (!action.handler) {
+                    console.error(
+                        'No handler specified for "' + action.label + '"');
+                    return;
+                }
+
+                try {
+                    action.handler(grid.getSelectedItems());
+                } catch(E) {
+                    console.error('Error executing handler for "' 
+                        + action.label + '" => ' + E + "\n" + E.stack);
+                }
+            }
+
+            // returns the list of selected item objects
+            grid.getSelectedItems = function() {
+                return $scope.items.filter(
+                    function(item) {
+                        return Boolean($scope.selected[grid.indexValue(item)]);
+                    }
+                );
+            }
+
+            grid.getItemByIndex = function(index) {
+                for (var i = 0; i < $scope.items.length; i++) {
+                    var item = $scope.items[i];
+                    if (grid.indexValue(item) == index) 
+                        return item;
+                }
+            }
+
+            // selects one row after deselecting all of the others
+            grid.selectOneItem = function(index) {
+                $scope.selected = {};
+                $scope.selected[index] = true;
+            }
+
+            // selects or deselects an item, without affecting the others.
+            // returns true if the item is selected; false if de-selected.
+            grid.toggleSelectOneItem = function(index) {
+                if ($scope.selected[index]) {
+                    delete $scope.selected[index];
+                    return false;
+                } else {
+                    return $scope.selected[index] = true;
+                }
+            }
+
+            grid.selectAllItems = function() {
+                angular.forEach($scope.items, function(item) {
+                    $scope.selected[grid.indexValue(item)] = true
+                });
+            }
+
+            $scope.$watch('selectAll', function(newVal) {
+                if (newVal) {
+                    grid.selectAllItems();
+                } else {
+                    $scope.selected = {};
+                }
+            });
+
+            // returns true if item1 appears in the list before item2;
+            // false otherwise.  this is slightly more efficient that
+            // finding the position of each then comparing them.
+            // item1 / item2 may be an item or an item index
+            grid.itemComesBefore = function(itemOrIndex1, itemOrIndex2) {
+                var idx1 = grid.indexValue(itemOrIndex1);
+                var idx2 = grid.indexValue(itemOrIndex2);
+
+                // use for() for early exit
+                for (var i = 0; i < $scope.items.length; i++) {
+                    var idx = grid.indexValue($scope.items[i]);
+                    if (idx == idx1) return true;
+                    if (idx == idx2) return false;
+                }
+                return false;
+            }
+
+            // 0-based position of item in the current data set
+            grid.indexOf = function(item) {
+                var idx = grid.indexValue(item);
+                for (var i = 0; i < $scope.items.length; i++) {
+                    if (grid.indexValue($scope.items[i]) == idx)
+                        return i;
+                }
+                return -1;
+            }
+
+            grid.modifyColumnFlex = function(column, val) {
+                column.flex += val;
+                // prevent flex:0;  use hiding instead
+                if (column.flex < 1)
+                    column.flex = 1;
+            }
+            $scope.modifyColumnFlex = function(col, val) {
+                grid.modifyColumnFlex(col, val);
+            }
+
+            // handles click, control-click, and shift-click
+            $scope.handleRowClick = function($event, item) {
+                var index = grid.indexValue(item);
+
+                var origSelected = Object.keys($scope.selected);
+
+                if ($event.ctrlKey || $event.metaKey /* mac command */) {
+                    // control-click
+                    if (grid.toggleSelectOneItem(index)) 
+                        grid.lastSelectedItemIndex = index;
+
+                } else if ($event.shiftKey) { 
+                    // shift-click
+
+                    if (!grid.lastSelectedItemIndex || 
+                            index == grid.lastSelectedItemIndex) {
+                        grid.selectOneItem(index);
+                        grid.lastSelectedItemIndex = index;
+
+                    } else {
+
+                        var selecting = false;
+                        var ascending = grid.itemComesBefore(
+                            grid.lastSelectedItemIndex, item);
+                        var startPos = 
+                            grid.indexOf(grid.lastSelectedItemIndex);
+
+                        // update to new last-selected
+                        grid.lastSelectedItemIndex = index;
+
+                        // select each row between the last selected and 
+                        // currently selected items
+                        while (true) {
+                            startPos += ascending ? 1 : -1;
+                            var curItem = $scope.items[startPos];
+                            if (!curItem) break;
+                            var curIdx = grid.indexValue(curItem);
+                            $scope.selected[curIdx] = true;
+                            if (curIdx == index) break; // all done
+                        }
+                    }
+                        
+                } else {
+                    grid.selectOneItem(index);
+                    grid.lastSelectedItemIndex = index;
+                }
+            }
+
+            // Builds a sort expression from column sort priorities.
+            // called on page load and any time the priorities are modified.
+            grid.compileSort = function() {
+                var sortList = grid.columnsProvider.columns.filter(
+                    function(col) { return Number(col.sort) != 0 }
+                ).sort( 
+                    function(a, b) { 
+                        if (Math.abs(a.sort) < Math.abs(b.sort))
+                            return -1;
+                        return 1;
+                    }
+                );
+
+                if (sortList.length) {
+                    grid.dataProvider.sort = sortList.map(function(col) {
+                        var blob = {};
+                        blob[col.name] = col.sort < 0 ? 'desc' : 'asc';
+                        return blob;
+                    });
+                }
+            }
+
+            // builds a sort expression using a single column, 
+            // toggling between ascending and descending sort.
+            $scope.quickSort = function(col_name) {
+                var sort = grid.dataProvider.sort;
+                if (sort && sort.length &&
+                    sort[0] == col_name) {
+                    var blob = {};
+                    blob[col_name] = 'desc';
+                    grid.dataProvider.sort = [blob];
+                } else {
+                    grid.dataProvider.sort = [col_name];
+                }
+
+                grid.offset = 0;
+                grid.collect();
+            }
+
+            // show / hide the grid configuration row
+            $scope.toggleConfDisplay = function() {
+                if ($scope.showGridConf) {
+                    $scope.showGridConf = false;
+                    if (grid.columnsProvider.hasSortableColumn()) {
+                        // only refresh the grid if the user has the
+                        // ability to modify the sort priorities.
+                        grid.compileSort();
+                        grid.offset = 0;
+                        grid.collect();
+                    }
+                } else {
+                    $scope.showGridConf = true;
+                }
+
+                $scope.gridColumnPickerIsOpen = false;
+            }
+
+            // called when a dragged column is dropped onto itself
+            // or any other column
+            grid.onColumnDrop = function(target) {
+                if (angular.isUndefined(target)) return;
+                if (target == grid.dragColumn) return;
+                var srcIdx, targetIdx, srcCol;
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col, idx) {
+                        if (col.name == grid.dragColumn) {
+                            srcIdx = idx;
+                            srcCol = col;
+                        } else if (col.name == target) {
+                            targetIdx = idx;
+                        }
+                    }
+                );
+
+                if (srcIdx < targetIdx) targetIdx--;
+
+                // move src column from old location to new location in 
+                // the columns array, then force a page refresh
+                grid.columnsProvider.columns.splice(srcIdx, 1);
+                grid.columnsProvider.columns.splice(targetIdx, 0, srcCol);
+                $scope.$apply(); 
+            }
+
+            // prepares a string for inclusion within a CSV document
+            // by escaping commas and quotes and removing newlines.
+            grid.csvDatum = function(str) {
+                str = ''+str;
+                if (!str) return '';
+                str = str.replace(/\n/g, '');
+                if (str.match(/\,/) || str.match(/"/)) {                                     
+                    str = str.replace(/"/g, '""');
+                    str = '"' + str + '"';                                           
+                } 
+                return str;
+            }
+
+            // sets the download file name and inserts the current CSV
+            // into a Blob URL for browser download.
+            $scope.generateCSVExportURL = function() {
+                $scope.gridColumnPickerIsOpen = false;
+
+                // let the file name describe the grid
+                $scope.csvExportFileName = 
+                    ($scope.mainLabel || grid.persistKey || 'eg_grid_data')
+                    .replace(/\s+/g, '_') + '_' + $scope.page();
+
+                // toss the CSV into a Blob and update the export URL
+                var csv = grid.generateCSV();
+                var blob = new Blob([csv], {type : 'text/plain'});
+                $scope.csvExportURL = 
+                    ($window.URL || $window.webkitURL).createObjectURL(blob);
+            }
+
+            $scope.printCSV = function() {
+                $scope.gridColumnPickerIsOpen = false;
+                egCore.hatch.print('default', 'text/plain', grid.generateCSV())
+                .then(function() { console.debug('print complete') });
+            }
+
+            // generates CSV for the currently visible grid contents
+            grid.generateCSV = function() {
+                var csvStr = '';
+                var colCount = grid.columnsProvider.columns.length;
+
+                // columns
+                angular.forEach(grid.columnsProvider.columns,
+                    function(col) {
+                        if (!col.visible) return;
+                        csvStr += grid.csvDatum(col.label);
+                        csvStr += ',';
+                    }
+                );
+
+                csvStr = csvStr.replace(/,$/,'\n');
+
+                // items
+                angular.forEach($scope.items, function(item) {
+                    angular.forEach(grid.columnsProvider.columns, 
+                        function(col) {
+                            if (!col.visible) return;
+                            // bare value
+                            var val = grid.dataProvider.itemFieldValue(item, col);
+                            // filtered value (dates, etc.)
+                            val = $filter('egGridValueFilter')(val, col);
+                            csvStr += grid.csvDatum(val);
+                            csvStr += ',';
+                        }
+                    );
+                    csvStr = csvStr.replace(/,$/,'\n');
+                });
+
+                return csvStr;
+            }
+
+            // Interpolate the value for column.linkpath within the context
+            // of the row item to generate the final link URL.
+            $scope.generateLinkPath = function(col, item) {
+                return egCore.strings.$replace(col.linkpath, {item : item});
+            }
+
+            // If a column provides its own HTML template, translate it,
+            // using the current item for the template scope.
+            // note: $sce is required to avoid security restrictions and
+            // is OK here, since the template comes directly from a
+            // local HTML template (not user input).
+            $scope.translateCellTemplate = function(col, item) {
+                var html = egCore.strings.$replace(col.template, {item : item});
+                return $sce.trustAsHtml(html);
+            }
+
+            $scope.collect = function() { grid.collect() }
+
+            // asks the dataProvider for a page of data
+            grid.collect = function() {
+
+                // avoid firing the collect if there is nothing to collect.
+                if (grid.selfManagedData && !grid.dataProvider.query) return;
+
+                if (grid.collecting) return; // avoid parallel collect()
+                grid.collecting = true;
+
+                console.debug('egGrid.collect() offset=' 
+                    + grid.offset + '; limit=' + grid.limit);
+
+                // ensure all of our dropdowns are closed
+                // TODO: git rid of these and just use dropdown-toggle, 
+                // which is more reliable.
+                $scope.gridColumnPickerIsOpen = false;
+                $scope.gridRowCountIsOpen = false;
+                $scope.gridPageSelectIsOpen = false;
+
+                $scope.items = [];
+                $scope.selected = {};
+                grid.dataProvider.get(grid.offset, grid.limit).then(
+                function() {
+                    if (grid.controls.allItemsRetrieved)
+                        grid.controls.allItemsRetrieved();
+                },
+                null, 
+                function(item) {
+                    if (item) {
+                        $scope.items.push(item)
+                        if (grid.controls.itemRetrieved)
+                            grid.controls.itemRetrieved(item);
+                    }
+                }).finally(function() { 
+                    console.debug('egGrid.collect() complete');
+                    grid.collecting = false 
+                });
+            }
+
+            grid.init();
+        }]
+    };
+})
+
+/**
+ * eg-grid-field : used for collecting custom field data from the templates.
+ * This directive does not direct display, it just passes data up to the 
+ * parent grid.
+ */
+.directive('egGridField', function() {
+    return {
+        require : '^egGrid',
+        restrict : 'AE',
+        scope : {
+            name  : '@', // required; unique name
+            path  : '@', // optional; flesh path
+            label : '@', // optional; display label
+            flex  : '@',  // optional; default flex width
+            dateformat : '@', // optional: passed down to egGridValueFilter
+
+            // if a field is part of an IDL object, but we are unable to
+            // determine the class, because it's nested within a hash
+            // (i.e. we can't navigate directly to the object via the IDL),
+            // idlClass lets us specify the class.  This is particularly
+            // useful for nested wildcard fields.
+            parentIdlClass : '@', 
+
+            // optional: for non-IDL columns, specifying a datatype
+            // lets the caller control which display filter is used.
+            // datatype should match the standard IDL datatypes.
+            datatype : '@'
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+
+            // boolean fields are presented as value-less attributes
+            angular.forEach(
+                [
+                    'visible', 
+                    'hidden', 
+                    'sortable', 
+                    'nonsortable',
+                    'multisortable',
+                    'nonmultisortable',
+                    'required' // if set, always fetch data for this column
+                ],
+                function(field) {
+                    if (angular.isDefined(attrs[field]))
+                        scope[field] = true;
+                }
+            );
+
+            // any HTML content within the field is its custom template
+            var tmpl = element.html();
+            if (tmpl && !tmpl.match(/^\s*$/))
+                scope.template = tmpl
+
+            egGridCtrl.columnsProvider.add(scope);
+            scope.$destroy();
+        }
+    };
+})
+
+/**
+ * eg-grid-action : used for specifying actions which may be applied
+ * to items within the grid.
+ */
+.directive('egGridAction', function() {
+    return {
+        require : '^egGrid',
+        restrict : 'AE',
+        transclude : true,
+        scope : {
+            label   : '@', // Action label
+            handler : '=',  // Action function handler
+            divider : '='
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+            egGridCtrl.addAction({
+                label : scope.label,
+                divider : scope.divider,
+                handler : scope.handler
+            });
+            scope.$destroy();
+        }
+    };
+})
+
+.factory('egGridColumnsProvider', ['egCore', function(egCore) {
+
+    function ColumnsProvider(args) {
+        var cols = this;
+        cols.columns = [];
+        cols.stockVisible = [];
+        cols.idlClass = args.idlClass;
+        cols.defaultToHidden = args.defaultToHidden;
+        cols.defaultToNoSort = args.defaultToNoSort;
+        cols.defaultToNoMultiSort = args.defaultToNoMultiSort;
+
+        // resets column width, visibility, and sort behavior
+        // Visibility resets to the visibility settings defined in the 
+        // template (i.e. the original egGridField values).
+        cols.reset = function() {
+            angular.forEach(cols.columns, function(col) {
+                col.flex = 2;
+                col.sort = 0;
+                if (cols.stockVisible.indexOf(col.name) > -1) {
+                    col.visible = true;
+                } else {
+                    col.visible = false;
+                }
+            });
+        }
+
+        // returns true if any columns are sortable
+        cols.hasSortableColumn = function() {
+            return cols.columns.filter(
+                function(col) {
+                    return col.sortable || col.multisortable;
+                }
+            ).length > 0;
+        }
+
+        cols.showAllColumns = function() {
+            $scope.gridColumnPickerIsOpen = false;
+            angular.forEach(cols.columns, function(column) {
+                column.visible = true;
+            });
+            if (grid.selfManagedData) grid.collect();
+        }
+
+        cols.hideAllColumns = function() {
+            $scope.gridColumnPickerIsOpen = false;
+            angular.forEach(cols.columns, function(col) {
+                delete col.visible;
+            });
+            // note: no need to fetch new data if no columns are visible
+        }
+
+        cols.indexOf = function(name) {
+            for (var i = 0; i < cols.columns.length; i++) {
+                if (cols.columns[i].name == name) 
+                    return i;
+            }
+            return -1;
+        }
+
+        cols.findColumn = function(name) {
+            return cols.columns[cols.indexOf(name)];
+        }
+
+        cols.compileAutoColumns = function() {
+            var idl_class = egCore.idl.classes[cols.idlClass];
+
+            angular.forEach(
+                idl_class.fields.sort(
+                    function(a, b) { return a.name < b.name ? -1 : 1 }),
+                function(field) {
+                    if (field.virtual) return;
+                    if (field.datatype == 'link' || field.datatype == 'org_unit') {
+                        // if the field is a link and the linked class has a
+                        // "selector" field specified, use the selector field
+                        // as the display field for the columns.
+                        // flattener will take care of the fleshing.
+                        if (field['class']) {
+                            var selector_field = egCore.idl.classes[field['class']].fields
+                                .filter(function(f) { return Boolean(f.selector) })[0];
+                            if (selector_field) {
+                                field.path = field.name + '.' + selector_field.selector;
+                            }
+                        }
+                    }
+                    cols.add(field, true);
+                }
+            );
+        }
+
+        // if a column definition has a path with a wildcard, create
+        // columns for all non-virtual fields at the specified 
+        // position in the path.
+        cols.expandPath = function(colSpec) {
+
+            var dotpath = colSpec.path.replace(/\.?\*$/,'');
+            var class_obj;
+
+            if (colSpec.parentIdlClass) {
+                class_obj = egCore.idl.classes[colSpec.parentIdlClass];
+
+            } else {
+
+                class_obj = egCore.idl.classes[cols.idlClass];
+                if (!class_obj) return;
+
+                var path_parts = dotpath.split(/\./);
+
+                // find the IDL class definition for the last element in the
+                // path before the .*
+                // an empty path_parts means expand the root class
+                if (path_parts) {
+                    for (var path_idx in path_parts) {
+                        var part = path_parts[path_idx];
+                        var idl_field = class_obj.field_map[part];
+
+                        // unless we're at the end of the list, this field should
+                        // link to another class.
+                        if (idl_field && idl_field['class'] && (
+                            idl_field.datatype == 'link' || 
+                            idl_field.datatype == 'org_unit')) {
+                            class_obj = egCore.idl.classes[idl_field['class']];
+                        } else {
+                            if (path_idx < (path_parts.length - 1)) {
+                                // we ran out of classes to hop through before
+                                // we ran out of path components
+                                console.error("egGrid: invalid IDL path: " + dotpath);
+                            }
+                        }
+                    }
+                }
+            }
+
+            if (class_obj) {
+                angular.forEach(class_obj.fields, function(field) {
+
+                    // Only show wildcard fields where we have data to show
+                    // Virtual and un-fleshed links will not have any data.
+                    if (field.virtual || (
+                        field.datatype == 'link' || field.datatype == 'org_unit'))
+                        return;
+
+                    var col = cols.cloneFromScope(colSpec);
+                    col.path = dotpath + '.' + field.name;
+                    cols.add(col, false, true, 
+                        {idl_field : field, idl_class : class_obj});
+                });
+
+            } else {
+                console.error(
+                    "egGrid: wildcard path does not resolve to an object: "
+                    + dotpath);
+            }
+        }
+
+        // angular.clone(scopeObject) is not permittable.  Manually copy
+        // the fields over that we need (so the scope object can go away).
+        cols.cloneFromScope = function(colSpec) {
+            return {
+                name  : colSpec.name,
+                label : colSpec.label,
+                path  : colSpec.path,
+                flex  : Number(colSpec.flex) || 2,
+                sort  : Number(colSpec.sort) || 0,
+                required : colSpec.required,
+                linkpath : colSpec.linkpath,
+                template : colSpec.template,
+                visible  : colSpec.visible,
+                hidden   : colSpec.hidden,
+                datatype : colSpec.datatype,
+                sortable : colSpec.sortable,
+                nonsortable      : colSpec.nonsortable,
+                multisortable    : colSpec.multisortable,
+                nonmultisortable : colSpec.nonmultisortable,
+                dateformat       : colSpec.dateformat,
+                parentIdlClass   : colSpec.parentIdlClass
+            };
+        }
+
+
+        // Add a column to the columns collection.
+        // Columns may come from a slim eg-columns-field or 
+        // directly from the IDL.
+        cols.add = function(colSpec, fromIDL, fromExpand, idl_info) {
+
+            // First added column with the specified path takes precedence.
+            // This allows for specific definitions followed by wildcard
+            // definitions.  If a match is found, back out.
+            if (cols.columns.filter(function(c) {
+                return (c.path == colSpec.path) })[0]) {
+                //console.debug('skipping column ' + colSpec.path);
+                return;
+            }
+
+            var column = fromExpand ? colSpec : cols.cloneFromScope(colSpec);
+
+            if (column.path && column.path.match(/\*$/)) 
+                return cols.expandPath(colSpec);
+
+            if (!column.name) column.name = column.path;
+            if (!column.path) column.path = column.name;
+
+            if (column.visible || (!cols.defaultToHidden && !column.hidden))
+                column.visible = true;
+
+            if (column.sortable || (!cols.defaultToNoSort && !column.nonsortable))
+                column.sortable = true;
+
+            if (column.multisortable || 
+                (!cols.defaultToNoMultiSort && !column.nonmultisortable))
+                column.multisortable = true;
+
+            cols.columns.push(column);
+
+            // Track which columns are visible by default in case we
+            // need to reset column visibility
+            if (column.visible) 
+                cols.stockVisible.push(column.name);
+
+            if (fromIDL) return; // directly from egIDL.  nothing left to do.
+
+            // lookup the matching IDL field
+            if (!idl_info && cols.idlClass) 
+                idl_info = cols.idlFieldFromPath(column.path);
+
+            if (!idl_info) {
+                // column is not represented within the IDL
+                column.adhoc = true; 
+                return; 
+            }
+
+            column.datatype = idl_info.idl_field.datatype;
+            
+            if (!column.label) {
+                column.label = idl_info.idl_field.label || column.name;
+                /*
+                // append class label to column label to better differentiate
+                // columns in the selector.
+                // Disabled for now, since it results in columns w/ really
+                // long names, making the grid unappealing when any of
+                // these colmns are selected.
+                // TODO: consider nesting the colum picker by class?
+                if (fromExpand) {
+                    var label = 
+                        idl_info.idl_class.label || idl_info.idl_class.name;
+                    column.label = label + '::' + column.label;
+                }
+                */
+            }
+        },
+
+        // finds the IDL field from the dotpath, using the columns
+        // idlClass as the base.
+        cols.idlFieldFromPath = function(dotpath) {
+            var class_obj = egCore.idl.classes[cols.idlClass];
+            var path_parts = dotpath.split(/\./);
+
+            var idl_field;
+            for (var path_idx in path_parts) {
+                var part = path_parts[path_idx];
+                idl_field = class_obj.field_map[part];
+
+                if (idl_field && idl_field['class'] && (
+                    idl_field.datatype == 'link' || 
+                    idl_field.datatype == 'org_unit')) {
+                    class_obj = egCore.idl.classes[idl_field['class']];
+                }
+                // else, path is not in the IDL, which is fine
+            }
+
+            if (!idl_field) return null;
+
+            return {
+                idl_field :idl_field,
+                idl_class : class_obj
+            };
+        }
+    }
+
+    return {
+        instance : function(args) { return new ColumnsProvider(args) }
+    }
+}])
+
+
+/*
+ * Generic data provider template class.  This is basically an abstract
+ * class factory service whose instances can be locally modified to 
+ * meet the needs of each individual grid.
+ */
+.factory('egGridDataProvider', 
+           ['$q','$timeout','$filter','egCore',
+    function($q , $timeout , $filter , egCore) {
+
+        function GridDataProvider(args) {
+            var gridData = this;
+            if (!args) args = {};
+
+            gridData.sort = [];
+            gridData.get = args.get;
+            gridData.query = args.query;
+            gridData.idlClass = args.idlClass;
+            gridData.columnsProvider = args.columnsProvider;
+
+            // Delivers a stream of array data via promise.notify()
+            // Useful for passing an array of data to egGrid.get()
+            // If a count is provided, the array will be trimmed to
+            // the range defined by count and offset
+            gridData.arrayNotifier = function(arr, offset, count) {
+                if (!arr || arr.length == 0) return $q.when();
+                if (count) arr = arr.slice(offset, offset + count);
+                var def = $q.defer();
+                // promise notifications are only witnessed when delivered
+                // after the caller has his hands on the promise object
+                $timeout(function() {
+                    angular.forEach(arr, def.notify);
+                    def.resolve();
+                });
+                return def.promise;
+            }
+
+            // Calls the grid refresh function.  Once instantiated, the
+            // grid will replace this function with it's own refresh()
+            gridData.refresh = function(noReset) { }
+
+            if (!gridData.get) {
+                // returns a promise whose notify() delivers items
+                gridData.get = function(index, count) {
+                    console.error("egGridDataProvider.get() not implemented");
+                }
+            }
+
+            // attempts a flat field lookup first.  If the column is not
+            // found on the top-level object, attempts a nested lookup
+            // TODO: consider a caching layer to speed up template 
+            // rendering, particularly for nested objects?
+            gridData.itemFieldValue = function(item, column) {
+                if (column.name in item) {
+                    if (typeof item[column.name] == 'function') {
+                        return item[column.name]();
+                    } else {
+                        return item[column.name];
+                    }
+                } else {
+                    return gridData.nestedItemFieldValue(item, column);
+                }
+            }
+
+            // TODO: deprecate me
+            gridData.flatItemFieldValue = function(item, column) {
+                console.warn('gridData.flatItemFieldValue deprecated; '
+                    + 'leave provider.itemFieldValue unset');
+                return item[column.name];
+            }
+
+            // given an object and a dot-separated path to a field,
+            // extract the value of the field.  The path can refer
+            // to function names or object attributes.  If the final
+            // value is an IDL field, run the value through its
+            // corresponding output filter.
+            gridData.nestedItemFieldValue = function(obj, column) {
+                if (obj === null || obj === undefined || obj === '') return '';
+                if (!column.path) return obj;
+
+                var idl_field;
+                var parts = column.path.split('.');
+
+                angular.forEach(parts, function(step, idx) {
+                    // object is not fleshed to the expected extent
+                    if (!obj || typeof obj != 'object') {
+                        obj = '';
+                        return;
+                    }
+
+                    var cls = obj.classname;
+                    if (cls && (class_obj = egCore.idl.classes[cls])) {
+                        idl_field = class_obj.field_map[step];
+                        obj = obj[step] ? obj[step]() : '';
+                    } else {
+                        if (angular.isFunction(obj[step])) {
+                            obj = obj[step]();
+                        } else {
+                            obj = obj[step];
+                        }
+                    }
+                });
+
+                // We found a nested IDL object which may or may not have 
+                // been configured as a top-level column.  Grab the datatype.
+                if (idl_field && !column.datatype) 
+                    column.datatype = idl_field.datatype;
+
+                if (obj === null || obj === undefined || obj === '') return '';
+                return obj;
+            }
+        }
+
+        return {
+            instance : function(args) {
+                return new GridDataProvider(args);
+            }
+        };
+    }
+])
+
+
+// Factory service for egGridDataManager instances, which are
+// responsible for collecting flattened grid data.
+.factory('egGridFlatDataProvider', 
+           ['$q','egCore','egGridDataProvider',
+    function($q , egCore , egGridDataProvider) {
+
+        return {
+            instance : function(args) {
+                var provider = egGridDataProvider.instance(args);
+
+                provider.get = function(offset, count) {
+
+                    // no query means no call
+                    if (!provider.query || 
+                            angular.equals(provider.query, {})) 
+                        return $q.when();
+
+                    // find all of the currently visible columns
+                    var queryFields = {}
+                    angular.forEach(provider.columnsProvider.columns, 
+                        function(col) {
+                            // only query IDL-tracked columns
+                            if (!col.adhoc && (col.required || col.visible))
+                                queryFields[col.name] = col.path;
+                        }
+                    );
+
+                    return egCore.net.request(
+                        'open-ils.fielder',
+                        'open-ils.fielder.flattened_search',
+                        egCore.auth.token(), provider.idlClass, 
+                        queryFields, provider.query,
+                        {   sort : provider.sort,
+                            limit : count,
+                            offset : offset
+                        }
+                    );
+                }
+                //provider.itemFieldValue = provider.flatItemFieldValue;
+                return provider;
+            }
+        };
+    }
+])
+
+.directive('egGridColumnDragSource', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+            angular.element(element).attr('draggable', 'true');
+
+            element.bind('dragstart', function(e) {
+                egGridCtrl.dragColumn = attrs.column;
+                egGridCtrl.dragType = attrs.dragType || 'move'; // or resize
+                egGridCtrl.colResizeDir = 0;
+
+                if (egGridCtrl.dragType == 'move') {
+                    // style the column getting moved
+                    angular.element(e.target).addClass(
+                        'eg-grid-column-move-handle-active');
+                }
+            });
+
+            element.bind('dragend', function(e) {
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass(
+                        'eg-grid-column-move-handle-active');
+                }
+            });
+        }
+    };
+})
+
+.directive('egGridColumnDragDest', function() {
+    return {
+        restrict : 'A',
+        require : '^egGrid',
+        link : function(scope, element, attrs, egGridCtrl) {
+
+            element.bind('dragover', function(e) { // required for drop
+                e.stopPropagation();
+                e.preventDefault();
+                e.dataTransfer.dropEffect = 'move';
+
+                if (egGridCtrl.colResizeDir == 0) return; // move
+
+                var cols = egGridCtrl.columnsProvider;
+                var srcCol = egGridCtrl.dragColumn;
+                var srcColIdx = cols.indexOf(srcCol);
+
+                if (egGridCtrl.colResizeDir == -1) {
+                    if (cols.indexOf(attrs.column) <= srcColIdx) {
+                        egGridCtrl.modifyColumnFlex(
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), -1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column shrinks by one, column to the
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], 1);
+                        }
+                        scope.$apply();
+                    }
+                } else {
+                    if (cols.indexOf(attrs.column) > srcColIdx) {
+                        egGridCtrl.modifyColumnFlex( 
+                            egGridCtrl.columnsProvider.findColumn(
+                                egGridCtrl.dragColumn), 1);
+                        if (cols.columns[srcColIdx+1]) {
+                            // source column grows by one, column to the 
+                            // right grows by one.
+                            egGridCtrl.modifyColumnFlex(
+                                cols.columns[srcColIdx+1], -1);
+                        }
+
+                        scope.$apply();
+                    }
+                }
+            });
+
+            element.bind('dragenter', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).addClass('eg-grid-col-hover');
+                } else {
+                    // resize grips are on the right side of each column.
+                    // dragenter will either occur on the source column 
+                    // (dragging left) or the column to the right.
+                    if (egGridCtrl.colResizeDir == 0) {
+                        if (egGridCtrl.dragColumn == attrs.column) {
+                            egGridCtrl.colResizeDir = -1; // west
+                        } else {
+                            egGridCtrl.colResizeDir = 1; // east
+                        }
+                    }
+                }
+            });
+
+            element.bind('dragleave', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                }
+            });
+
+            element.bind('drop', function(e) {
+                e.stopPropagation();
+                e.preventDefault();
+                egGridCtrl.colResizeDir = 0;
+                if (egGridCtrl.dragType == 'move') {
+                    angular.element(e.target).removeClass('eg-grid-col-hover');
+                    egGridCtrl.onColumnDrop(attrs.column); // move the column
+                }
+            });
+        }
+    };
+})
+.directive('egGridMenuItem', function() {
+    return {
+        restrict : 'AE',
+        require : '^egGrid',
+        scope : {
+            label : '@',  
+            handler : '=', // onclick handler function
+            divider : '=', // if true, show a divider only
+            handlerData : '=', // if set, passed as second argument to handler
+            disabled : '=', // function
+            hidden : '=' // function
+        },
+        link : function(scope, element, attrs, egGridCtrl) {
+            egGridCtrl.addMenuItem({
+                label : scope.label,
+                handler : scope.handler,
+                divider : scope.divider,
+                disabled : scope.disabled,
+                hidden : scope.hidden,
+                handlerData : scope.handlerData
+            });
+            scope.$destroy();
+        }
+    };
+})
+
+
+
+/**
+ * Translates bare IDL object values into display values.
+ * 1. Passes dates through the angular date filter
+ * 2. Translates bools to Booleans so the browser can display translated 
+ *    value.  (Though we could manually translate instead..)
+ * Others likely to follow...
+ */
+.filter('egGridValueFilter', ['$filter', function($filter) {                         
+    return function(value, column) {                                             
+        switch(column.datatype) {                                                
+            case 'bool':                                                       
+                switch(value) {
+                    // Browser will translate true/false for us                    
+                    case 't' : 
+                    case '1' :  // legacy
+                    case true:
+                        return ''+true;
+                    case 'f' : 
+                    case '0' :  // legacy
+                    case false:
+                        return ''+false;
+                    // value may be null,  '', etc.
+                    default : return '';
+                }
+            case 'timestamp':                                                  
+                // canned angular date filter FTW                              
+                if (!column.dateformat) 
+                    column.dateformat = 'shortDate';
+                return $filter('date')(value, column.dateformat);
+            case 'money':                                                  
+                return $filter('currency')(value);
+            default:                                                           
+                return value;                                                  
+        }                                                                      
+    }                                                                          
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/hatch.js b/Open-ILS/web/js/ui/default/staff/services/hatch.js
new file mode 100644 (file)
index 0000000..9e41d91
--- /dev/null
@@ -0,0 +1,428 @@
+/**
+ * Core Service - egHatch
+ *
+ * Dispatches print and data storage requests to the appropriate handler.
+ *
+ * With each top-level request, if a connection to Hatch is established,
+ * the request is relayed.  If a connection has not been attempted, an
+ * attempt is made then the request is handled.  If Hatch is known to be
+ * inaccessible, requests are routed to local handlers.
+ *
+ * Most handlers also provide direct remote and local variants to the
+ * application can decide to which to use as needed.
+ *
+ * Local storage requests are handled by $window.localStorage.
+ *
+ * Note that all top-level and remote requests return promises.  All
+ * local requests return immediate values, since local requests are
+ * never asynchronous.
+ *
+ * BEWARE: never store "fieldmapper" objects, since their structure
+ * may change over time as the IDL changes.  Always flatten objects
+ * into key/value pairs before calling set*Item()
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egHatch',
+           ['$q','$window','$timeout','$interpolate','$http',
+    function($q , $window , $timeout , $interpolate , $http) {
+
+    var service = {};
+    service.msgId = 0;
+    service.messages = {};
+    service.pending = [];
+    service.socket = null;
+    service.hatchAvailable = null;
+    service.defaultHatchURL = 'wss://localhost:8443/hatch'; 
+
+    // write a message to the Hatch websocket
+    service.sendToHatch = function(msg) {
+        var msg2 = {};
+
+        // shallow copy and scrub msg before sending
+        angular.forEach(msg, function(val, key) {
+            if (key.match(/deferred/)) return;
+            msg2[key] = val;
+        });
+
+        console.debug("sending to Hatch: " + JSON.stringify(msg2,null,2));
+        service.socket.send(JSON.stringify(msg2));
+    }
+
+    // Send the request to Hatch if it's available.  
+    // Otherwise handle the request locally.
+    service.attemptHatchDelivery = function(msg) {
+
+        msg.msgid = service.msgId++;
+        msg.deferred = $q.defer();
+
+        if (service.hatchAvailable === false) { // Hatch is closed
+            msg.deferred.reject(msg);
+
+        } else if (service.hatchAvailable === true) { // Hatch is open
+            // Hatch is known to be open
+            service.messages[msg.msgid] = msg;
+            service.sendToHatch(msg);
+
+        } else {  // Hatch status unknown; attempt to connect
+            service.messages[msg.msgid] = msg;
+            service.pending.push(msg);
+            service.hatchConnect();
+        }
+
+        return msg.deferred.promise;
+    }
+
+
+    // resolve the promise on the given request and remove
+    // it from our tracked requests.
+    service.resolveRequest = function(msg) {
+
+        if (!service.messages[msg.msgid]) {
+            console.warn('no cached message for ' 
+                + msg.msgid + ' : ' + JSON.stringify(msg, null, 2));
+            return;
+        }
+
+        // for requests sent through Hatch, only the cached 
+        // request will have the original promise attached
+        msg.deferred = service.messages[msg.msgid].deferred;
+        delete service.messages[msg.msgid]; // un-cache
+
+        // resolve / reject
+        if (msg.error) {
+            throw new Error(
+            "egHatch command failed : " 
+                + JSON.stringify(msg.error, null, 2));
+        } else {
+            msg.deferred.resolve(msg.content);
+        } 
+    }
+
+    service.hatchClosed = function() {
+        service.socket = null;
+        service.printers = [];
+        service.printConfig = {};
+        while ( (msg = service.pending.shift()) ) {
+            msg.deferred.reject(msg);
+            delete service.messages[msg.msgid];
+        }
+        if (service.onHatchClose)
+            service.onHatchClose();
+    }
+
+    service.hatchURL = function() {
+        return service.getLocalItem('eg.hatch.url') 
+            || service.defaultHatchURL;
+    }
+
+    // Returns true if Hatch is required or if we are currently
+    // communicating with the Hatch service. 
+    service.usingHatch = function() {
+        return service.hatchAvailable || service.hatchRequired();
+    }
+
+    // Returns true if this browser (via localStorage) is 
+    // configured to require Hatch.
+    service.hatchRequired = function() {
+        return service.getLocalItem('eg.hatch.required');
+    }
+
+    service.hatchConnect = function() {
+
+        if (service.socket && 
+            service.socket.readyState == service.socket.CONNECTING) {
+            // connection in progress.  Nothing to do.  Our queued
+            // message will be delivered when onopen() fires
+            return;
+        }
+
+        try {
+            service.socket = new WebSocket(service.hatchURL());
+        } catch(e) {
+            service.hatchAvailable = false;
+            service.hatchClosed();
+            return;
+        }
+
+        service.socket.onopen = function() {
+            console.debug('connected to Hatch');
+            service.hatchAvailable = true;
+            if (service.onHatchOpen) 
+                service.onHatchOpen();
+            while ( (msg = service.pending.shift()) ) {
+                service.sendToHatch(msg);
+            };
+        }
+
+        service.socket.onclose = function() {
+            if (service.hatchAvailable === false) return; // already registered
+
+            // onclose() will be called regularly as we disconnect from
+            // Hatch via timeouts.  Return hatchAvailable to its unknow state
+            service.hatchAvailable = null;
+            service.hatchClosed();
+        }
+
+        service.socket.onerror = function() {
+            if (service.hatchAvailable === false) return; // already registered
+            service.hatchAvailable = false;
+            console.debug(
+                "unable to connect to Hatch server at " + service.hatchURL());
+            service.hatchClosed();
+        }
+
+        service.socket.onmessage = function(evt) {
+            var msgStr = evt.data;
+            if (!msgStr) throw new Error("Hatch returned empty message");
+
+            var msgObj = JSON.parse(msgStr);
+            console.debug('Hatch says ' + JSON.stringify(msgObj, null, 2));
+            service.resolveRequest(msgObj); 
+        }
+    }
+
+    service.getPrintConfig = function() {
+        if (service.printConfig) 
+            return $q.when(service.printConfig);
+
+        return service.getRemoteItem('eg.print.config')
+        .then(function(conf) { 
+            return (service.printConfig = conf || {}) 
+        });
+    }
+
+    service.setPrintConfig = function(conf) {
+        service.printConfig = conf;
+        return service.setRemoteItem('eg.print.config', conf);
+    }
+
+
+    service.remotePrint = function(
+        context, contentType, content, withDialog) {
+
+        return service.getPrintConfig().then(
+            function(conf) {
+                // print configuration retrieved; print
+                return service.attemptHatchDelivery({
+                    action : 'print',
+                    config : conf[context],
+                    content : content, 
+                    contentType : contentType,
+                    showDialog : withDialog,
+                });
+            }
+        );
+    }
+
+    // launch the print dialog then attach the resulting configuration
+    // to the requested context, then store the final values.
+    service.configurePrinter = function(context, printer) {
+
+        // load current settings
+        return service.getPrintConfig()
+
+        // dispatch the print configuration request
+        .then(function(config) {
+
+            // loaded remote config
+            if (!config[context]) config[context] = {};
+            config[context].printer = printer;
+            return service.attemptHatchDelivery({
+                key : 'no-op', 
+                action : 'print-config',
+                config : config[context]
+            })
+        })
+
+        // set the returned settings to the requested context
+        .then(function(newconf) {
+            if (angular.isObject(newconf)) {
+                newconf.printer = printer;
+                return service.printConfig[context] = newconf;
+            } else {
+                console.warn("configurePrinter() returned " + newconf);
+            }
+        })
+
+        // store the newly linked settings
+        .then(function() {
+            service.setItem('eg.print.config', service.printConfig);
+        })
+
+        // return the final settings to the caller
+        .then(function() {return service.printConfig});
+    }
+
+    service.getPrinters = function() {
+        if (service.printers) // cached printers
+            return $q.when(service.printers);
+
+        return service.attemptHatchDelivery({action : 'printers'}).then(
+
+            // we have remote printers; sort by name and return
+            function(printers) {
+                service.printers = printers.sort(
+                    function(a,b) {return a.name < b.name ? -1 : 1});
+                return service.printers;
+            },
+
+            // remote call failed and there is no such thing as local
+            // printers; return empty set.
+            function() { return [] } 
+        );
+    }
+
+    // get the value for a stored item
+    service.getItem = function(key) {
+        return service.getRemoteItem(key)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to getItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                return service.getLocalItem(msg.key);
+            }
+        );
+    }
+
+    service.getRemoteItem = function(key) {
+        return service.attemptHatchDelivery({
+            key : key,
+            action : 'get', 
+        });
+    }
+
+    service.getLocalItem = function(key) {
+        var val = $window.localStorage.getItem(key);
+        if (val == null) return;
+        return JSON.parse(val);
+    }
+
+    service.setItem = function(key, value) {
+        var str = JSON.stringify(value);
+        return service.setRemoteItem(key, str)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to setItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                return service.setLocalItem(msg.key, null, str);
+            }
+        );
+    }
+
+    // set the value for a stored or new item
+    service.setRemoteItem = function(key, value) {
+        return service.attemptHatchDelivery({
+            key : key, 
+            value : value, 
+            action : 'set',
+        });
+    }
+
+    // Set the value for the given key
+    // If the value is raw, pass it as 'value'.  If it was
+    // externally JSONified, pass it via jsonified.
+    service.setLocalItem = function(key, value, jsonified) {
+        if (jsonified === undefined ) 
+            jsonified = JSON.stringify(value);
+        $window.localStorage.setItem(key, jsonified);
+    }
+
+    // appends the value to the existing item stored at key.
+    // If not item is found at key, this behaves just like setItem()
+    service.appendItem = function(key, value) {
+        return service.appendRemoteItem(key, value)['catch'](
+            function(msg) {
+                if (service.hatchRequired()) {
+                    console.error("Unable to appendItem: " + key
+                     + "; hatchRequired=true, but hatch is not connected");
+                     return null;
+                }
+                service.appendLocalItem(msg.key, msg.value);
+            }
+        );
+    }
+
+    service.appendRemoteItem = function(key, value) {
+        return service.attemptHatchDelivery({
+            key : key, 
+            value : value, 
+            action : 'append',
+        });
+    }
+
+    // assumes the appender and appendee are both strings
+    // TODO: support arrays as well
+    service.appendLocalItem = function(key, value) {
+        var item = service.getLocalItem(key);
+        if (item) {
+            if (typeof item != 'string') {
+                logger.warn("egHatch.appendLocalItem => "
+                    + "cannot append to a non-string item: " + key);
+                return;
+            }
+            value = item + value; // concatenate our value
+        }
+        service.setLocalitem(key, value);
+    }
+
+    // remove a stored item
+    service.removeItem = function(key) {
+        return service.removeRemoteItem(key)['catch'](
+            function(msg) { 
+                return service.removeLocalItem(msg.key) 
+            }
+        );
+    }
+
+    service.removeRemoteItem = function(key) {
+        return service.attemptHatchDelivery({
+            key : key,
+            action : 'remove'
+        });
+    }
+
+    service.removeLocalItem = function(key) {
+        $window.localStorage.removeItem(key);
+    }
+
+    // if set, prefix limits the return set to keys starting with 'prefix'
+    service.getKeys = function(prefix) {
+        return service.getRemoteKeys(prefix)['catch'](
+            function() { 
+                if (service.hatchRequired()) {
+                    console.error("Unable to get pref keys; "
+                     + "hatchRequired=true, but hatch is not connected");
+                     return [];
+                }
+                return service.getLocalKeys(prefix) 
+            }
+        );
+    }
+
+    service.getRemoteKeys = function(prefix) {
+        return service.attemptHatchDelivery({
+            key : prefix,
+            action : 'keys'
+        });
+    }
+
+    service.getLocalKeys = function(prefix) {
+        var keys = [];
+        var idx = 0;
+        while ( (k = $window.localStorage.key(idx++)) !== null) {
+            // key prefix match test
+            if (prefix && k.substr(0, prefix.length) != prefix) continue; 
+            keys.push(k);
+        }
+        return keys;
+    }
+
+    return service;
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/idl.js b/Open-ILS/web/js/ui/default/staff/services/idl.js
new file mode 100644 (file)
index 0000000..5934063
--- /dev/null
@@ -0,0 +1,141 @@
+/**
+ * Core Service - egIDL
+ *
+ * IDL parser
+ * usage:
+ *  var aou = new egIDL.aou();
+ *  var fullIDL = egIDL.classes;
+ *
+ *  IDL TODO:
+ *
+ * 1. selector field only appears once per class.  We could save
+ *    a lot of IDL (network) space storing it only once at the 
+ *    class level.
+ * 2. we don't need to store array_position in /IDL2js since it
+ *    can be derived at parse time.  Ditto saving space.
+ */
+angular.module('egCoreMod')
+
+.factory('egIDL', ['$window', function($window) {
+
+    var service = {};
+
+    service.parseIDL = function() {
+        //console.debug('egIDL.parseIDL()');
+
+        // retain a copy of the full IDL within the service
+        service.classes = $window._preload_fieldmapper_IDL;
+
+        // keep this reference around (note: not a clone, just a ref)
+        // so that unit tests, which repeatedly instantiate the
+        // service will work.
+        //$window._preload_fieldmapper_IDL = null;
+
+        /**
+         * Creates the class constructor and getter/setter
+         * methods for each IDL class.
+         */
+        function mkclass(cls, fields) {
+
+            service[cls] = function(seed) {
+                this.a = seed || [];
+                this.classname = cls;
+                this._isfieldmapper = true;
+            }
+
+            /** creates the getter/setter methods for each field */
+            angular.forEach(fields, function(field, idx) {
+                service[cls].prototype[fields[idx].name] = function(n) {
+                    if (arguments.length==1) this.a[idx] = n;
+                    return this.a[idx];
+                }
+            });
+
+            // global class constructors required for JSON_v1.js
+            $window[cls] = service[cls]; 
+        }
+
+        for (var cls in service.classes) 
+            mkclass(cls, service.classes[cls].fields);
+    };
+
+    /**
+     * Generate a hash version of an IDL object.
+     *
+     * Flatten determines if nested objects should be squashed into
+     * the top-level hash.
+     *
+     * If 'flatten' is false, e.g.:
+     *
+     * {"call_number" : {"label" :  "foo"}}
+     *
+     * If 'flatten' is true, e.g.:
+     *
+     * e.g.  {"call_number.label" : "foo"}
+     */
+    service.toHash = function(obj, flatten) {
+        if (!angular.isObject(obj)) return obj; // arrays are objects
+
+        if (angular.isArray(obj)) { // NOTE: flatten arrays not supported
+            return obj.map(function(item) {return service.toHash(item)});
+        }
+
+        var field_names = obj.classname ? 
+            Object.keys(service.classes[obj.classname].field_map) :
+            Object.keys(obj);
+
+        var hash = {};
+        angular.forEach(
+            field_names,
+            function(field) { 
+
+                var val = service.toHash(
+                    angular.isFunction(obj[field]) ? 
+                        obj[field]() : obj[field], 
+                    flatten
+                );
+
+                if (flatten && angular.isObject(val)) {
+                    angular.forEach(val, function(sub_val, key) {
+                        var fname = field + '.' + key;
+                        hash[fname] = sub_val;
+                    });
+
+                } else if (val !== undefined) {
+                    hash[field] = val;
+                }
+            }
+        );
+
+        return hash;
+    }
+
+    // Transforms a flattened hash (see toHash() or egGridFlatDataProvider)
+    // to a nested hash.
+    //
+    // e.g. {"call_number.label" : "foo"} => {"call_number":{"label":"foo"}}
+    service.flatToNestedHash = function(obj) {
+        var hash = {};
+        angular.forEach(obj, function(val, key) {
+            var parts = key.split('.');
+            var sub_hash = hash;
+            var last_key;
+            for (var i = 0; i < parts.length; i++) {
+                var part = parts[i];
+                if (i == parts.length - 1) {
+                    sub_hash[part] = val;
+                    break;
+                } else {
+                    if (!sub_hash[part])
+                        sub_hash[part] = {};
+                    sub_hash = sub_hash[part];
+                }
+            }
+        });
+
+        return hash;
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/navbar.js b/Open-ILS/web/js/ui/default/staff/services/navbar.js
new file mode 100644 (file)
index 0000000..8806d65
--- /dev/null
@@ -0,0 +1,79 @@
+angular.module('egCoreMod')
+
+.directive('egNavbar', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        templateUrl : 'eg-navbar-template',
+        link : function(scope, element, attrs) {
+
+            // Find all eg-accesskey entries within the menu and attach
+            // hotkey handlers for each.  
+            // jqlite doesn't support selectors, so we have to 
+            // manually navigate to the elements we're interested in.
+            function inspect(elm) {
+                elm = angular.element(elm);
+                if (elm.attr('eg-accesskey')) {
+                    scope.addHotkey(
+                        elm.attr('eg-accesskey'),
+                        elm.attr('href'),
+                        elm.attr('eg-accesskey-desc')
+                    );
+                }
+                angular.forEach(elm.children(), inspect);
+            }
+            inspect(element);
+        },
+
+        controller:['$scope','$window','$location','hotkeys','egCore',
+            function($scope , $window , $location , hotkeys , egCore) {
+
+                function navTo(path) {                                           
+                    // $location.path() does not want a leading ".",
+                    // which <a>'s will have.  
+                    // Note: avoid using $location.path() to derive the new
+                    // URL, since it creates an intermediate path change.
+                    path = path.replace(/^\./,'');
+                    var reg = new RegExp($location.path());
+                    $window.location.href = 
+                        $window.location.href.replace(reg, path);
+                }       
+
+                // adds a keyboard shortcut
+                // http://chieffancypants.github.io/angular-hotkeys/
+                $scope.addHotkey = function(key, path, desc) {                 
+                    hotkeys.add(key, desc, function() { navTo(path) });
+                };
+
+                $scope.applyLocale = function(locale) {
+                    // EGWeb.pm can change the locale for us w/ the right param
+                    // Note: avoid using $location.search() to derive a new
+                    // URL, since it creates an intermediate path change.
+                    // Instead, use the ham-fisted approach of killing any
+                    // search args and applying the args we want.
+                    $window.location.href = 
+                        $window.location.href.replace(
+                            /(\?|\&).*/,
+                            '?set_eg_locale=' + encodeURIComponent(locale)
+                        );
+                }
+
+                // tied to logout link
+                $scope.logout = function() {
+                    egCore.auth.logout();
+                    return true;
+                };
+
+                egCore.startup.go().then(
+                    function() {
+                        if (egCore.auth.user()) {
+                            $scope.username = egCore.auth.user().usrname();
+                            $scope.workstation = egCore.auth.workstation();
+                        }
+                    }
+                );
+            }
+        ]
+    }
+});
diff --git a/Open-ILS/web/js/ui/default/staff/services/net.js b/Open-ILS/web/js/ui/default/staff/services/net.js
new file mode 100644 (file)
index 0000000..156d554
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * Core Service - egNet
+ *
+ * Promise wrapper for OpenSRF network calls.
+ * http://docs.angularjs.org/api/ng.$q
+ *
+ * promise.notify() is called with each streamed response.
+ *
+ * promise.resolve() is called when the request is complete 
+ * and passes as its value the response received from the 
+ * last call to onresponse().  If no calls to onresponse()
+ * were made (i.e. no responses delivered) no value will
+ * be passed to resolve(), hence any value seen by the client
+ * will be 'undefined'.
+ *
+ * Example: Call with one response and no error checking:
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *    function(data) { 
+ *      // data == undefined if no responses were received
+ *      // data == null if last response was a null value
+ *      console.log(data) 
+ *    });
+ *
+ * Example: capture streaming responses, error checking
+ *
+ * egNet.request(service, method, param1, param2).then(
+ *      function(data) { console.log('all done') },
+ *      function(err)  { console.log('error: ' + err) },
+ *      functoin(data) { console.log('received stream response ' + data) }
+ *  );
+ */
+
+angular.module('egCoreMod')
+
+.factory('egNet', 
+       ['$q','$rootScope','egEvent', 
+function($q,  $rootScope,  egEvent) {
+
+    var net = {};
+
+    // raises the egAuthExpired event on NO_SESSION
+    net.checkResponse = function(resp) {
+        var content = resp.content();
+        if (!content) return null;
+        var evt = egEvent.parse(content);
+        if (evt && evt.textcode == 'NO_SESSION') {
+            $rootScope.$broadcast('egAuthExpired') 
+        } else {
+            return content;
+        }
+    };
+
+    net.request = function(service, method) {
+        var last;
+        var deferred = $q.defer();
+        var params = Array.prototype.slice.call(arguments, 2);
+        console.debug('egNet ' + method);
+        new OpenSRF.ClientSession(service).request({
+            async  : true,
+            method : method,
+            params : params,
+            oncomplete : function() {
+                deferred.resolve(last);
+            },
+            onresponse : function(r) {
+                last = net.checkResponse(r.recv());
+                deferred.notify(last);
+            },
+            onerror : function(msg) {
+                // 'msg' currently tells us very little, so don't 
+                // bother JSON-ifying it, since there is the off
+                // chance that JSON-ification could fail, e.g if 
+                // the object has circular refs.
+                console.error(method + 
+                    ' (' + params + ')  failed.  See server logs.');
+                deferred.reject(msg);
+            },
+            onmethoderror : function(req, statCode, statMsg) { 
+                console.error('error calling method ' + 
+                method + ' : ' + statCode + ' : ' + statMsg);
+            }
+
+        }).send();
+
+        return deferred.promise;
+    }
+
+    // In addition to the service and method names, accepts a single array
+    // as the collection of API call parameters.  This array will get 
+    // expanded to individual arguments in the final server call.
+    // This is useful when the server call expects each param to be
+    // a top-level value, but the set of params is dynamic.
+    net.requestWithParamList = function(service, method, params) {
+        var args = [service, method].concat(params);
+        return net.request.apply(net, args);
+    }
+
+    return net;
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/org.js b/Open-ILS/web/js/ui/default/staff/services/org.js
new file mode 100644 (file)
index 0000000..8ff7137
--- /dev/null
@@ -0,0 +1,115 @@
+/**
+ * Core Service - egOrg
+ *
+ * TODO: more docs
+ */
+angular.module('egCoreMod')
+
+.factory('egOrg', 
+       ['$q','egEnv','egAuth','egNet',
+function($q,  egEnv,  egAuth,  egNet) { 
+
+    var service = {};
+
+    // org unit settings cache.
+    // This allows the caller to avoid local caches
+    service.cachedSettings = {};
+
+    service.get = function(node_or_id) {
+        if (typeof node_or_id == 'object')
+            return node_or_id;
+        return egEnv.aou.map[node_or_id];
+    };
+
+    service.list = function() {
+        return egEnv.aou.list;
+    };
+
+    service.tree = function() {
+        return egEnv.aou.tree;
+    }
+
+    // list of org_unit objects or IDs for ancestors + me
+    service.ancestors = function(node_or_id, as_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [node];
+        while( (node = service.get(node.parent_ou())))
+            nodes.push(node);
+        if (as_id) 
+            return nodes.map(function(n){return n.id()});
+        return nodes;
+    };
+
+    // list of org_unit objects  or IDs for me + descendants
+    service.descendants = function(node_or_id, as_id) {
+        var node = service.get(node_or_id);
+        if (!node) return [];
+        var nodes = [];
+        function descend(n) {
+            nodes.push(n);
+            angular.forEach(n.children(), descend);
+        }
+        descend(node);
+        if (as_id) 
+            return nodes.map(function(n){return n.id()});
+        return nodes;
+    }
+
+    // list of org_unit objects or IDs for ancestors + me + descendants
+    service.fullPath = function(node_or_id, as_id) {
+        var list = service.ancestors(node_or_id).concat(
+          service.descendants(node_or_id).slice(1));
+        if (as_id) 
+            return list.map(function(n){return n.id()});
+        return list;
+    }
+
+    // returns a promise, resolved with a hash of setting name =>
+    // setting value for the selected org unit.  Org unit defaults to 
+    // auth workstation org unit.
+    service.settings = function(names, ou_id) {
+        var deferred = $q.defer();
+        ou_id = ou_id || egAuth.user().ws_ou();
+        var here = (ou_id == egAuth.user().ws_ou());
+
+        // allow non-array
+        if (!angular.isArray(names)) names = [names];
+        
+        if (here) { 
+            // only cache org settings retrieved for the current 
+            // workstation org unit.
+            var newNames = [];
+            angular.forEach(names, function(name) {
+                if (!angular.isDefined(service.cachedSettings[name]))
+                    newNames.push(name)
+            });
+
+            // only retrieve uncached values
+            names = newNames;
+            if (names.length == 0)
+                return $q.when(service.cachedSettings);
+        }
+
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.ou_setting.ancestor_default.batch',
+            ou_id, names, egAuth.token()
+        ).then(function(blob) {
+            var settings = {};
+            angular.forEach(blob, function(val, key) {
+                // val is either null or a structure containing the value
+                settings[key] = val ? val.value : null;
+                if (here) service.cachedSettings[key] = settings[key];
+            });
+
+            // resolve with cached settings if 'here', since 'settings'
+            // will only contain settings we had to retrieve
+            deferred.resolve(here ? service.cachedSettings : settings);
+        });
+        return deferred.promise;
+    }
+
+    return service;
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/pcrud.js b/Open-ILS/web/js/ui/default/staff/services/pcrud.js
new file mode 100644 (file)
index 0000000..1f78b17
--- /dev/null
@@ -0,0 +1,298 @@
+/**
+ * Core Service - egPCRUD
+ *
+ * PCRUD client.
+ *
+ * Factory for PCRUDContext objects with pass-through service-level API.
+ *
+ * For most types of communication, where the client expects to make a
+ * single request which egPCRUD manages internally, use the service-
+ * level API.
+ *
+ * All service-level APIs (except connect()) return a promise, whose
+ * notfiy() channels individual responses (think: onresponse) and 
+ * whose resolve() channels the last received response (think: 
+ * oncomplete), consistent with egNet.request().  If only one response
+ * is expected (e.g. retrieve(), or .atomic searches), notify() 
+ * handlers are not required.
+ *
+ * egPCRUD.retrieve('aou', 1)
+ * .then(function(org) { console.log(org.shortname()) });
+ *
+ * egPCRUD.search('aou', {id : [1,2,3]})
+ * .then(function(orgs) { console.log(orgs.length) } );
+ *
+ * egPCRUD.search('aou', {id : {'!=' : null}}, {limit : 10})
+ * .then(...);
+ *
+ * For requests where the caller needs to manually connect and make
+ * individual API calls, the service.connect() call will create and
+ * pass a PCRUDContext object as the argument to the connect promise 
+ * resolver.  The PCRUDContext object can be used to make subsequent 
+ * pcrud calls directly.
+ *
+ * egPCRUD.connnect()
+ * .then(function(ctx) { return ctx.retrieve('aou', 1) })
+ * .then(function(org) { console.log(org.id()); ctx.disconnect() })
+ *
+ */
+angular.module('egCoreMod')
+
+.factory('egPCRUD', ['$q','$rootScope','egAuth','egIDL', 
+             function($q , $rootScope , egAuth , egIDL) { 
+    
+    var service = {};
+
+    // create service-level pass through functions 
+    // for one-off PCRUDContext actions.
+    angular.forEach(['connect', 'retrieve', 'retrieveAll', 
+        'search', 'create', 'update', 'remove', 'apply'],
+        function(action) {
+            service[action] = function() {
+                var ctx = new PCRUDContext();
+                return ctx[action].apply(ctx, arguments);
+            }
+        }
+    );
+
+    /*
+     * Since services are singleton objectss, we need an internal 
+     * class to manage individual PCRUD conversations.  
+     */
+    var PCRUDContextIdent = 0; // useful for debug logging
+    function PCRUDContext() {
+        var self = this;
+        this.xact_close_mode = 'rollback';
+        this.ident = PCRUDContextIdent++;
+        this.session = new OpenSRF.ClientSession('open-ils.pcrud');
+
+        this.toString = function() {
+            return '[PCRUDContext ' + this.ident + ']';
+        };
+
+        this.log = function(msg) {
+            console.debug(this + ': ' + msg);
+        };
+
+        this.err = function(msg) {
+            console.error(this + ': ' + msg);
+        };
+
+        this.connect = function() {
+            this.log('connect');
+            var deferred = $q.defer();
+            this.session.connect({onconnect : 
+                function() {deferred.resolve(self)}});
+            return deferred.promise;
+        };
+
+        this.disconnect = function() {
+            this.log('disconnect');
+            this.session.disconnect();
+        };
+
+        this.retrieve = function(fm_class, pkey, pcrud_ops) {
+            return this._dispatch(
+                'open-ils.pcrud.retrieve.' + fm_class,
+                [egAuth.token(), pkey, pcrud_ops]
+            );
+        };
+
+        this.retrieveAll = function(fm_class, pcrud_ops, req_ops) {
+            var search = {};
+            search[egIDL.classes[fm_class].pkey] = {'!=' : null};
+            return this.search(fm_class, search, pcrud_ops, req_ops);
+        };
+
+        this.search = function (fm_class, search, pcrud_ops, req_ops) {
+            req_ops = req_ops || {};
+
+            var return_type = req_ops.idlist ? 'id_list' : 'search';
+            var method = 'open-ils.pcrud.' + return_type + '.' + fm_class;
+
+            if (req_ops.atomic) method += '.atomic';
+
+            return this._dispatch(method, 
+                [egAuth.token(), search, pcrud_ops]);
+        };
+
+        this.create = function(list) {return this.CUD('create', list)};
+        this.update = function(list) {return this.CUD('update', list)};
+        this.remove = function(list) {return this.CUD('delete', list)};
+        this.apply  = function(list) {return this.CUD('apply',  list)};
+
+        this.xactClose = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.' + this.xact_close_mode,
+                [egAuth.token()]
+            );
+        };
+
+        this.xactBegin = function() {
+            return this._send_request(
+                'open-ils.pcrud.transaction.begin',
+                [egAuth.token()]
+            );
+        };
+
+        this._dispatch = function(method, params) {
+            if (this.authoritative) {
+                return this._wrap_xact(
+                    function() {
+                        return self._send_request(method, params);
+                    }
+                );
+            } else {
+                return this._send_request(method, params)
+            }
+        };
+
+
+        // => connect
+        // => xact_begin 
+        // => action
+        // => xact_close(commit/rollback) 
+        // => disconnect
+        // Returns a promise
+        // main_func should return a promise
+        this._wrap_xact = function(main_func) {
+            var deferred = $q.defer();
+
+            // 1. connect
+            this.connect().then(function() {
+
+            // 2. start the transaction
+            self.xactBegin().then(function() {
+
+            // 3. execute the main body 
+            main_func().then(
+                // main body complete
+                function(lastResp) {  
+
+                    // 4. close the transaction
+                    self.xactClose().then(function() {
+                        // 5. disconnect
+                        self.disconnect();
+                        // 6. all done
+                        deferred.resolve(lastResp);
+                    });
+                },
+
+                // main body error handler
+                function() {}, 
+
+                // main body notify() handler
+                function(data) {deferred.notify(data)}
+            );
+
+            })}); // close 'em all up.
+
+            return deferred.promise;
+        };
+
+        this._send_request = function(method, params) {
+            this.log('_send_request(' + method + ')');
+            var deferred = $q.defer();
+            var lastResp;
+            this.session.request({
+                method : method,
+                params : params,
+                onresponse : function(r) {
+                    var resp = r.recv();
+                    if (resp && (lastResp = resp.content())) {
+                        deferred.notify(lastResp);
+                    } else {
+                        // pcrud requests should always return something
+                        self.err(method + " returned no response");
+                    }
+                },
+                oncomplete : function() {
+                    deferred.resolve(lastResp);
+                },
+
+                onmethoderror : function(req, stat, stat_text) {
+                    self.err(method + " failed. \ncode => " 
+                        + stat + "\nstatus => " + stat_text 
+                        + "\nparams => " + js2JSON(params));
+
+                    if (stat == 401) {
+                        // 401 is the PCRUD equivalent of a NO_SESSION event
+                        $rootScope.$broadcast('egAuthExpired');
+                    }
+
+                    deferred.reject(req);
+                }
+                // Note: no onerror handler for websockets connections,
+                // because errors exist and are reported as top-level
+                // conditions, not request-specific conditions.
+                // Practically every error we care about (minus loss of 
+                // connection) will be reported as a method error.
+            }).send();
+
+            return deferred.promise;
+        };
+
+        this.CUD = function (action, list) {
+            this.log('CUD(): ' + action);
+
+            this.cud_idx = 0;
+            this.cud_action = action;
+            this.xact_close_mode = 'commit';
+            this.cud_list = list;
+            this.cud_deferred = $q.defer();
+
+            if (!angular.isArray(list) || list.classname)
+                this.cud_list = [list];
+
+            return this._wrap_xact(
+                function() {
+                    self._CUD_next_request();
+                    return self.cud_deferred.promise;
+                }
+            );
+        }
+
+        /**
+         * Loops through the list of objects to update and sends
+         * them one at a time to the server for processing.  Once
+         * all are done, the cud_deferred promise is resolved.
+         */
+        this._CUD_next_request = function() {
+
+            if (this.cud_idx >= this.cud_list.length) {
+                this.cud_deferred.resolve(this.cud_last);
+                return;
+            }
+
+            var action = this.cud_action;
+            var fm_obj = this.cud_list[this.cud_idx++];
+
+            if (action == 'auto') {
+                if (fm_obj.ischanged()) action = 'update';
+                if (fm_obj.isnew())     action = 'create';
+                if (fm_obj.isdeleted()) action = 'delete';
+
+                if (action == 'auto') {
+                    // object does not need updating; move along
+                    this._CUD_next_request();
+                }
+            }
+
+            this._send_request(
+                'open-ils.pcrud.' + action + '.' + fm_obj.classname,
+                [egAuth.token(), fm_obj]).then(
+                function(data) {
+                    // update actions return one response.
+                    // no notify() handler needed.
+                    self.cud_last = data;
+                    self.cud_deferred.notify(data);
+                    self._CUD_next_request();
+                }
+            );
+           
+        };
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/print.js b/Open-ILS/web/js/ui/default/staff/services/print.js
new file mode 100644 (file)
index 0000000..bcb8aa2
--- /dev/null
@@ -0,0 +1,199 @@
+/**
+ * egPrint : manage print templates, process templates, print content
+ *
+ * TODO: create configurable links between print template and context.
+ */
+angular.module('egCoreMod')
+
+.factory('egPrint',
+       ['$q','$window','$timeout','$http','egHatch','egAuth','egIDL','egOrg',
+function($q , $window , $timeout , $http , egHatch , egAuth , egIDL , egOrg) {
+
+    var service = {};
+
+    service.template_base_path = 'share/print_templates/t_';
+
+    /*
+     * context  : 'default', 'receipt','label', etc. 
+     * scope    : data loaded into the template environment
+     * template : template name (e.g. 'checkout', 'transit_slip'
+     * content  : content to print.  If 'template' is set, content is
+     *            derived from the template.
+     * content_type : 'text/html', 'text/plain', 'text/csv'
+     * show_dialog  : boolean, if true, print dialog is shown.  This setting
+     *                only affects remote printers, since browser printers
+     *                do not allow such control
+     */
+    service.print = function(args) {
+        if (!args) return $q.when();
+
+        if (args.template) {
+            // fetch the template, then proceed to printing
+
+            return service.getPrintTemplate(args.template)
+            .then(function(content) {
+                args.content = content;
+                if (!args.content_type) args.content_type = 'html';
+                return service.print_content(args);
+            });
+
+        } 
+
+        return service.print_content(args);
+    }
+
+    // add commonly used attributes to the print scope
+    service.fleshPrintScope = function(scope) {
+        if (!scope) scope = {};
+        scope.today = new Date().toISOString();
+        scope.staff = egIDL.toHash(egAuth.user());
+        scope.current_location = 
+            egIDL.toHash(egOrg.get(egAuth.user().ws_ou()));
+    }
+
+    // Template has been fetched (or no template needed) 
+    // Process the template and send the result off to the printer.
+    service.print_content = function(args) {
+        service.fleshPrintScope(args.scope);
+
+        var promise;
+        if (args.content_type == 'text/html') {
+
+            // all HTML content is assumed to require compilation, 
+            // regardless of the print destination
+            promise = service.ingest_print_content(
+                args.content_type, args.content, args.scope);
+
+        } else {
+            // text content does not require compilation for remote printing
+            promise = $q.when();
+        }
+
+        // TODO: link print context to template type
+        var context = args.context || 'default';
+
+        return promise.then(function(html) {
+
+            return egHatch.remotePrint(context,
+                args.content_type, html, args.show_dialog)['catch'](
+
+                function(msg) {
+                    // remote print not available; 
+
+                    if (egHatch.hatchRequired()) {
+                        console.error("Unable to print data; "
+                         + "hatchRequired=true, but hatch is not connected");
+                         return $q.reject();
+                    }
+
+                    if (args.content_type != 'text/html') {
+                        // text content does require compilation 
+                        // (absorption) for browser printing
+                        return service.ingest_print_content(
+                            args.content_type, args.content, args.scope
+                        ).then(function() { $window.print() });
+                    } else {
+                        // HTML content is already ingested and accessible
+                        // within the page to the printer.  
+                        $window.print();
+                    }
+                }
+            );
+        });
+    }
+
+    // loads an HTML print template by name from the server
+    // If no template is available in local/hatch storage, 
+    // fetch the template as an HTML file from the server.
+    service.getPrintTemplate = function(name) {
+        var deferred = $q.defer();
+
+        egHatch.getItem('eg.print.template.' + name)
+        .then(function(html) {
+
+            if (html) {
+                // we have a locally stored template
+                deferred.resolve(html);
+                return;
+            }
+
+            var path = service.template_base_path + name;
+            console.debug('fetching template ' + path);
+
+            $http.get(path)
+            .success(function(data) { deferred.resolve(data) })
+            .error(function() {
+                console.error('unable to locate print template: ' + name);
+                deferred.reject();
+            });
+        });
+
+        return deferred.promise;
+    }
+
+    service.storePrintTemplate = function(name, html) {
+        return egHatch.setItem('eg.print.template.' + name, html);
+    }
+
+    return service;
+}])
+
+
+/**
+ * Container for inserting print data into the browser page.
+ * On insert, $window.print() is called to print the data.
+ * The div housing eg-print-container must apply the correct
+ * print media CSS to ensure this content (and not the rest
+ * of the page) is printed.
+ */
+
+// FIXME: only apply print CSS when print commands are issued via the 
+// print container, otherwise using the browser's native print page 
+// option will always result in empty pages.  Move the print CSS
+// out of the standalone CSS file and put it into a template file
+// for this directive.
+.directive('egPrintContainer', ['$compile', function($compile) {
+    return {
+        restrict : 'AE',
+        scope : {}, // isolate our scope
+        link : function(scope, element, attrs) {
+            scope.elm = element;
+        },
+        controller : 
+                   ['$scope','$q','$window','$timeout','egHatch','egPrint',
+            function($scope , $q , $window , $timeout , egHatch , egPrint) {
+
+                egPrint.ingest_print_content = function(type, content, printScope) {
+
+                    if (type == 'text/csv' || type == 'text/plain') {
+                        // preserve newlines, spaces, etc.
+                        content = '<pre>' + content + '</pre>';
+                    }
+
+                    $scope.elm.html(content);
+
+                    var sub_scope = $scope.$new(true);
+                    angular.forEach(printScope, function(val, key) {
+                        sub_scope[key] = val;
+                    })
+
+                    var resp = $compile($scope.elm.contents())(sub_scope);
+
+                    var deferred = $q.defer();
+                    $timeout(function(){
+                        // give the $digest a chance to complete then
+                        // resolve with the compiled HTML from our
+                        // print container
+
+                        deferred.resolve(
+                            resp.contents()[0].parentNode.innerHTML
+                        );
+                    });
+
+                    return deferred.promise;
+                }
+            }
+        ]
+    }
+}])
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js
new file mode 100644 (file)
index 0000000..670248c
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Core Service - egStartup
+ *
+ * Coordinates all startup routines and consolidates them into
+ * a single startup promise.  Startup can be launched from multiple
+ * controllers, etc., but only one startup routine will be run.
+ *
+ * If no valid authtoken is found, startup will exit early and 
+ * change the page href to the login page.  Otherwise, the global
+ * promise returned by startup.go() will be resolved after all
+ * async data is arrived.
+ */
+
+angular.module('egCoreMod')
+
+.factory('egStartup', 
+       ['$q','$rootScope','$location','$window','egIDL','egAuth','egEnv',
+function($q,  $rootScope,  $location,  $window,  egIDL,  egAuth,  egEnv) {
+
+    var service = { promise : null }
+
+    // returns true if we are staying on the current page
+    // false if we are redirecting to login
+    service.expiredAuthHandler = function() {
+        console.debug('egStartup.expiredAuthHandler()');
+        egAuth.logout(); // clean up
+
+        // no need to redirect if we're on the /login page
+        if ($location.path() == '/login') return true;
+
+        // change locations to the login page, using the current page
+        // as the 'route_to' destination on /login
+        $window.location.href = $location
+            .path('/login')
+            .search({route_to : 
+                $window.location.pathname + $window.location.search})
+            .absUrl();
+
+        return false;
+    }
+
+    // if during startup or any time in the future we encounter an expired
+    // authtoken, call our epired token handler
+    // we handle this here instead egAuth, since it affects the flow
+    // of the startup routines when no valid token exists during startup.
+    $rootScope.$on('egAuthExpired', function() {service.expiredAuthHandler()});
+
+    service.go = function () {
+        if (service.promise) {
+            // startup already started, return our existing promise
+            return service.promise;
+        } 
+
+        // create a new promise and fire off startup
+        var deferred = $q.defer();
+        service.promise = deferred.promise;
+
+        // IDL parsing is sync.  No promises required
+        egIDL.parseIDL();
+        egAuth.testAuthToken().then(
+
+            // testAuthToken resolved
+            function() { 
+                egEnv.load().then(
+                    function() { deferred.resolve() }, 
+                    function() { 
+                        deferred.reject('egEnv did not resolve')
+                    }
+                );
+            },
+
+            // testAuthToken rejected
+            function() { 
+                console.log('egAuth found no valid authtoken');
+                if (service.expiredAuthHandler()) deferred.resolve();
+            }
+        );
+
+        return service.promise;
+    }
+    
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/statusbar.js b/Open-ILS/web/js/ui/default/staff/services/statusbar.js
new file mode 100644 (file)
index 0000000..29968f9
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * egStatusBar
+ *
+ * Displays key information and messages to the user.
+ *
+ * Currently displays network connection status, egHatch connection
+ * status, and messages delivered via 
+ * $scope.$emit('egStatusBarMessage', msg)
+ */
+
+angular.module('egCoreMod')
+
+.directive('egStatusBar', function() {
+    return {
+        restrict : 'AE',
+        replace : true,
+        templateUrl : 'eg-status-bar-template',
+        controller : [
+                    '$scope','$rootScope','egHatch',
+            function($scope , $rootScope , egHatch) {
+            $scope.messages = []; // keep a log of recent messages
+
+            $scope.netConnected = function() {
+                // TODO: should should be abstracted through egNet
+                return OpenSRF.websocketConnected();
+            }
+
+            // update the UI whenever we lose connection
+            OpenSRF.onWebSocketClosed = function() {
+                $scope.$apply();
+            }
+
+            $scope.hatchConnected = function() {
+                return egHatch.hatchAvailable;
+            }
+
+            // update the UI whenever we lose connection
+            egHatch.onHatchClose = function() {
+                $scope.$apply();
+            }
+
+            // update the UI whenever we lose connection
+            egHatch.onHatchOpen = function() {
+                $scope.$apply();
+            }
+
+            $scope.hatchConnect = function() {
+                egHatch.hatchConnect();
+            }
+
+            $rootScope.$on('egStatusBarMessage', function(evt, args) {
+                $scope.messages.unshift(args.message);
+
+                // ensure the list does not exceed 10 messages
+                // TODO: configurable?
+                $scope.messages.splice(10, 1); 
+            });
+        }]
+    }
+});
diff --git a/Open-ILS/web/js/ui/default/staff/services/strings.js b/Open-ILS/web/js/ui/default/staff/services/strings.js
new file mode 100644 (file)
index 0000000..d9a801b
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * egStrings : service for tracking page-specific string translations.
+ *
+ * Convience functions embedded herein are prefixed with "$" to avoid
+ * collisions with string keys, which are linked directly to the 
+ * service.
+ *
+ * egStrings.A_STRING = 'hello, world {{foo}';
+ *
+ * egStrings.$replace(egStrings.A_STRING, {foo : 'bar'})
+ *
+ */
+
+angular.module('egCoreMod').factory('egStrings', 
+['$interpolate', function($interpolate) { 
+    return {
+        '$replace' : function(str, args) {
+            if (!str) return '';
+            return $interpolate(str)(args);
+        }
+    } 
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
new file mode 100644 (file)
index 0000000..c960ffb
--- /dev/null
@@ -0,0 +1,297 @@
+/**
+  * UI tools and directives.
+  */
+angular.module('egUiMod', ['egCoreMod', 'ui.bootstrap'])
+
+
+/**
+ * <input focus-me="iAmOpen"/>
+ * $scope.iAmOpen = true;
+ */
+.directive('focusMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.focusMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].focus()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+/**
+ * <input blur-me="pleaseBlurMe"/>
+ * $scope.pleaseBlurMe = true
+ * Useful for de-focusing when no other obvious focus target exists
+ */
+.directive('blurMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.blurMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].blur()});
+            });
+            element.bind('focus', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+
+// <input select-me="iWantToBeSelected"/>
+// $scope.iWantToBeSelected = true;
+.directive('selectMe', 
+       ['$timeout','$parse', 
+function($timeout , $parse) {
+    return {
+        link: function(scope, element, attrs) {
+            var model = $parse(attrs.selectMe);
+            scope.$watch(model, function(value) {
+                if(value === true) 
+                    $timeout(function() {element[0].select()});
+            });
+            element.bind('blur', function() {
+                scope.$apply(model.assign(scope, false));
+            })
+        }
+    };
+}])
+
+
+// 'reverse' filter 
+// <div ng-repeat="item in items | reverse">{{item.name}}</div>
+// http://stackoverflow.com/questions/15266671/angular-ng-repeat-in-reverse
+// TODO: perhaps this should live elsewhere
+.filter('reverse', function() {
+    return function(items) {
+        return items.slice().reverse();
+    };
+})
+
+
+/**
+ * egAlertDialog.open({message : 'hello {{name}}'}).result.then(
+ *     function() { console.log('alert closed') });
+ */
+.factory('egAlertDialog', 
+
+        ['$modal','$interpolate',
+function($modal , $interpolate) {
+    var service = {};
+
+    service.open = function(message, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_alert_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.ok = function() {
+                        if (msg_scope && msg_scope.ok) msg_scope.ok();
+                        $modalInstance.close()
+                    }
+                }
+            ]
+        });
+    }
+
+    return service;
+}])
+
+/**
+ * egConfirmDialog.open("some message goes {{here}}", {
+ *  here : 'foo', ok : function() {}, cancel : function() {}});
+ */
+.factory('egConfirmDialog', 
+    
+       ['$modal','$interpolate',
+function($modal, $interpolate) {
+    var service = {};
+
+    service.open = function(title, message, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_confirm_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.title = $interpolate(title)(msg_scope);
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.ok = function() {
+                        if (msg_scope.ok) msg_scope.ok();
+                        $modalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (msg_scope.cancel) msg_scope.cancel();
+                        $modalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+/**
+ * egPromptDialog.open(
+ *    "prompt message goes {{here}}", 
+ *    promptValue,  // optional
+ *    {
+ *      here : 'foo',  
+ *      ok : function(value) {console.log(value)}, 
+ *      cancel : function() {console.log('prompt denied')}
+ *    }
+ *  );
+ */
+.factory('egPromptDialog', 
+    
+       ['$modal','$interpolate',
+function($modal, $interpolate) {
+    var service = {};
+
+    service.open = function(message, promptValue, msg_scope) {
+        return $modal.open({
+            templateUrl: './share/t_prompt_dialog',
+            controller: ['$scope', '$modalInstance',
+                function($scope, $modalInstance) {
+                    $scope.message = $interpolate(message)(msg_scope);
+                    $scope.args = {value : promptValue || ''};
+                    $scope.focus = true;
+                    $scope.ok = function() {
+                        if (msg_scope.ok) msg_scope.ok($scope.args.value);
+                        $modalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (msg_scope.cancel) msg_scope.cancel();
+                        $modalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+
+/**
+ * Nested org unit selector modeled as a Bootstrap dropdown button.
+ */
+.directive('egOrgSelector', function() {
+    return {
+        restrict : 'AE',
+        transclude : true,
+        replace : true, // makes styling easier
+        scope : {
+            selected : '=', // defaults to workstation or root org
+            
+            // Each org unit is passed into this function and, for
+            // any org units where the response value is true, the
+            // org unit will not be added to the selector.
+            hiddenTest : '=',
+
+            // Caller can either $watch(selected, ..) or register an
+            // onchange handler.
+            onchange : '=',
+
+            // optional primary drop-down button label
+            label : '@'
+        },
+
+        // any reason to move this into a TT2 template?
+        template : 
+            '<div class="btn-group eg-org-selector" dropdown>'
+            + '<button type="button" class="btn btn-default dropdown-toggle">'
+             + '<span style="padding-right: 5px;">{{getSelectedName()}}</span>'
+             + '<span class="caret"></span>'
+           + '</button>'
+           + '<ul class="dropdown-menu">'
+             + '<li ng-repeat="org in orgList" ng-hide="hiddenTest(org.id)">'
+               + '<a href dropdown-toggle ng-click="orgChanged(org)"'
+                 + 'style="padding-left: {{org.depth * 10 + 5}}px">'
+                 + '{{org.shortname}}'
+               + '</a>'
+             + '</li>'
+           + '</ul>'
+          + '</div>',
+
+        controller : ['$scope','$timeout','egOrg','egAuth',
+              function($scope , $timeout , egOrg , egAuth) {
+
+            // avoid linking the full fleshed tree to the scope by 
+            // tossing in a flattened list.
+            $scope.orgList = egOrg.list().map(function(org) {
+                return {
+                    id : org.id(),
+                    shortname : org.shortname(), 
+                    depth : org.ou_type().depth()
+                }
+            });
+
+            $scope.getSelectedName = function() {
+                if ($scope.selected)
+                    return $scope.selected.shortname();
+                return $scope.label;
+            }
+
+            $scope.orgChanged = function(org) {
+                $scope.selected = egOrg.get(org.id);
+                if ($scope.onchange) $scope.onchange($scope.selected);
+            }
+
+            if (!$scope.selected)
+                $scope.selected = egOrg.get(egAuth.user().ws_ou());
+        }]
+    }
+})
+
+
+/*
+http://stackoverflow.com/questions/18061757/angular-js-and-html5-date-input-value-how-to-get-firefox-to-show-a-readable-d
+
+This directive allows us to use html5 input type="date" (for Chrome) and 
+gracefully fall back to a regular ISO text input for Firefox.
+It also allows us to abstract away some browser finickiness.
+*/
+.directive(
+    'egDateInput',
+    function(dateFilter) {
+        return {
+            require: 'ngModel',
+            template: '<input type="date"></input>',
+            replace: true,
+            link: function(scope, elm, attrs, ngModelCtrl) {
+
+                // since this is a date-only selector, set the time
+                // portion to 00:00:00, which should better match the
+                // user's expectations.  Note this allows us to retain
+                // the timezone.
+                function strip_time(date) {
+                    if (!date) date = new Date();
+                    date.setHours(0);
+                    date.setMinutes(0);
+                    date.setSeconds(0);
+                    date.setMilliseconds(0);
+                    return date;
+                }
+
+                ngModelCtrl.$formatters.unshift(function (modelValue) {
+                    // apply strip_time here in case the user never 
+                    // modifies the date value.
+                    return dateFilter(strip_time(modelValue), 'yyyy-MM-dd');
+                });
+                
+                ngModelCtrl.$parsers.unshift(function(viewValue) {
+                    return strip_time(new Date(viewValue));
+                });
+            },
+        };
+})
diff --git a/Open-ILS/web/js/ui/default/staff/services/user.js b/Open-ILS/web/js/ui/default/staff/services/user.js
new file mode 100644 (file)
index 0000000..0ed5cac
--- /dev/null
@@ -0,0 +1,56 @@
+/** 
+ * Service for fetching fleshed user objects.
+ */
+
+angular.module('egUserMod', ['egCoreMod'])
+
+.factory('egUser', 
+       ['$q','$timeout','egNet','egAuth','egOrg',
+function($q,  $timeout,  egNet,  egAuth,  egOrg) {
+
+    var service = {
+        defaultFleshFields : [
+            'card',                                                                
+            'standing_penalties',                                                  
+            'addresses',                                                           
+            'billing_address',                                                     
+            'mailing_address',                                                     
+            'stat_cat_entries',                                                    
+            'usr_activity' 
+        ]
+    };
+
+    service.get = function(userId, args) {
+        var deferred = $q.defer();
+
+        var fields = service.defaultFleshFields;
+        if (args) {
+            if (args.useFields) { 
+                // overridde flesh fields
+                fields = args.useFields; 
+            }
+            if (args.addFields) {
+                // append flesh fields
+                fields = fields.concat(args.addFields);
+            }
+        }
+            
+        egNet.request(
+            'open-ils.actor',
+            'open-ils.actor.user.fleshed.retrieve',
+            egAuth.token(), userId, fields).then(
+            function(user) {
+                if (user && user.classname == 'au') {
+                    deferred.resolve(user);
+                } else {
+                    deferred.reject(user);
+                }
+            }
+        );
+
+        return deferred.promise;
+    };
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js b/Open-ILS/web/js/ui/default/staff/test/data/eg_mock.js
new file mode 100644 (file)
index 0000000..956d46c
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * Mock data required by multiple unit tests.
+ */
+
+window._eg_mock_data = {
+
+    // builds a mock org unit tree fleshed with ou_types and
+    // absorbs the tree into egEnv
+    orgTree : function(egIDL, egEnv) {
+        var type1 = new egIDL.aout();
+        type1.id(1);
+        type1.depth(0);
+
+        var type2 = new egIDL.aout();
+        type2.id(2);
+        type2.depth(1);
+        type2.parent(1);
+
+        var type3 = new egIDL.aout();
+        type3.id(3);
+        type3.depth(2);
+        type3.parent(2);
+
+        var org1 = new egIDL.aou(); 
+        org1.id(1);
+        org1.ou_type(type1);
+
+        var org2 = new egIDL.aou(); 
+        org2.id(2); 
+        org2.parent_ou(1);
+        org2.ou_type(type2);
+
+        var org3 = new egIDL.aou(); 
+        org3.id(3); 
+        org3.parent_ou(1);
+        org3.ou_type(type2);
+
+        var org4 = new egIDL.aou(); 
+        org4.id(4); 
+        org4.parent_ou(2);
+        org4.ou_type(type3);
+
+        org1.children([org2, org3]);
+        org2.children([org4]);
+        org3.children([]);
+        org4.children([]);
+
+        egEnv.absorbTree(org1, 'aou');
+    }
+}
diff --git a/Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl b/Open-ILS/web/js/ui/default/staff/test/data/idl2js.pl
new file mode 100644 (file)
index 0000000..fbf998f
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/perl
+use strict; use warnings;
+use XML::LibXML;
+use XML::LibXSLT;
+my $out_file = 'IDL2js.js';
+my $idl_file = '../../../../../../../examples/fm_IDL.xml';
+my $xsl_file = '/openils/var/xsl/fm_IDL2js.xsl'; # FIXME: hard-coded path
+
+my $xslt = XML::LibXSLT->new();
+my $style_doc = XML::LibXML->load_xml(location => $xsl_file, no_cdata=>1);
+my $stylesheet = $xslt->parse_stylesheet($style_doc);
+my $idl_doc = XML::LibXML->load_xml(location => $idl_file);
+my $results = $stylesheet->transform($idl_doc);
+my $output = $stylesheet->output_as_bytes($results);
+
+open(IDL, ">$out_file") or die "Cannot open IDL2js file $out_file : $!\n";
+
+print IDL $output;
+
+close(IDL);
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
new file mode 100644 (file)
index 0000000..a27ca66
--- /dev/null
@@ -0,0 +1,88 @@
+module.exports = function(config){
+    config.set({
+    basePath : '../',
+
+    // config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+    files : [
+      'build/js/angular.min.js',
+      'build/js/angular-route.min.js',
+      'bower_components/angular-mocks/angular-mocks.js', // testing only
+      'build/js/ui-bootstrap.min.js',
+      'build/js/hotkeys.min.js',
+      /* OpenSRF must be installed first */
+      '/openils/lib/javascript/md5.js',
+      '/openils/lib/javascript/JSON_v1.js',
+      '/openils/lib/javascript/opensrf.js',
+      '/openils/lib/javascript/opensrf_ws.js',
+
+      // mock data for testing only
+      'test/data/IDL2js.js',
+      'test/data/eg_mock.js',
+
+      // service/*.js have to be loaded in order
+      'services/core.js',
+      'services/idl.js',
+      'services/strings.js',
+      'services/event.js',
+      'services/net.js',
+      'services/auth.js',
+      'services/pcrud.js',
+      'services/env.js',
+      'services/org.js',
+      'services/hatch.js',
+      'services/print.js',
+      'services/coresvc.js',
+      'services/user.js',
+      'services/startup.js',
+      'services/ui.js',
+      'services/statusbar.js',
+      'services/grid.js',
+      'services/navbar.js',
+      // load app scripts
+      'app.js',
+      'circ/**/*.js',
+      'cat/**/*.js',
+      'admin/**/*.js',
+      'test/unit/egIDL.js', // order matters for some of these
+      'test/unit/egOrg.js', 
+      'test/unit/**/*.js'
+    ],
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage'
+    reporters: ['spec'],  // detailed report
+    //reporters: ['progress'], // summary report
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch : false,
+
+    frameworks: ['jasmine'],
+
+    browsers: ['PhantomJS'],
+
+    // web server port
+    port: 9876,
+
+    /*
+    coverageReporter: {
+      type : 'html',
+      dir : 'coverage/',
+    },
+
+    preprocessors: {
+      '../src/*.js': ['coverage']
+    },
+    */
+
+    // If browser does not capture in given timeout [ms], kill it
+    captureTimeout: 60000,
+
+    // Continuous Integration mode
+    // if true, it capture browsers, run tests and exit
+    singleRun: true
+})}
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egCore.js b/Open-ILS/web/js/ui/default/staff/test/unit/egCore.js
new file mode 100644 (file)
index 0000000..d19a371
--- /dev/null
@@ -0,0 +1,18 @@
+'use strict';
+
+describe('egCore', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should wrap services', inject(function(egCore, egIDL) {
+        expect(egCore.idl).toBe(egIDL);
+    }));
+
+    it('should wrap services', inject(function(egCore, egIDL) {
+        expect(egCore.auth).not.toBe(egIDL);
+    }));
+
+    it('should not wrap non-services', inject(function(egCore) {
+        expect(egCore.junk).not.toBeDefined();
+    }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js b/Open-ILS/web/js/ui/default/staff/test/unit/egEvent.js
new file mode 100644 (file)
index 0000000..7b17653
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+
+describe('egEvent', function(){
+    beforeEach(module('egCoreMod'));
+
+    var evt = {                                                                           
+        ilsevent: "12345",                                                         
+        pid: "12345",                                                             
+        desc: "Test Event Description",
+        payload: {test : 'xyz'},                                                             
+        textcode: "TEST_EVENT",
+        servertime: "Wed Nov 6 16:05:50 2013"                                     
+    };
+
+    it('should parse an event object', inject(function(egEvent) {
+        expect(egEvent.parse(evt)).not.toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse({})).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse({abc : '123'})).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse([])).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse('STRING')).toBe(null);
+    }));
+
+    it('should not parse a non-event', inject(function(egEvent) {
+        expect(egEvent.parse(true)).toBe(null);
+    }));
+
+    it('should stringify an event', inject(function(egEvent) {
+        expect(egEvent.parse(evt).toString()).toBe(
+            'Event: 12345:TEST_EVENT -> Test Event Description')
+    }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js b/Open-ILS/web/js/ui/default/staff/test/unit/egHomeApp.js
new file mode 100644 (file)
index 0000000..eac4669
--- /dev/null
@@ -0,0 +1,21 @@
+'use strict';
+
+describe('egHomeControllers', function(){
+  beforeEach(module('egHome'));
+
+  /* ---- LoginCtrl ---------------------------------- */
+
+  var loginCtrl, loginScope;
+  beforeEach(inject(function ($rootScope, $controller, $location) {
+      // pass the workstation name via (mock) URL param
+      $location.search({ws : 'TestWorkstation'});
+
+      loginScope = $rootScope.$new();
+      loginCtrl = $controller('LoginCtrl', {$scope: loginScope});
+  }));
+
+  it('should focus the login controller', inject(function() {
+    expect(loginScope.focusMe).toBe(true);
+  }));
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js b/Open-ILS/web/js/ui/default/staff/test/unit/egIDL.js
new file mode 100644 (file)
index 0000000..bbf4e35
--- /dev/null
@@ -0,0 +1,25 @@
+'use strict';
+
+describe('egIDL', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should parse the IDL', inject(function(egIDL) {
+        egIDL.parseIDL();
+        expect(egIDL.classes.aou.fields.length).toBeGreaterThan(0);
+    }));
+
+    it('should create an aou object', inject(function(egIDL) {
+        egIDL.parseIDL();
+        var org = new egIDL.aou();
+        expect(typeof org.id).toBe('function');
+    }));
+
+    it('should create an aou object with accessor/mutators', inject(function(egIDL) {
+        egIDL.parseIDL();
+        var org = new egIDL.aou();
+        org.name('AN ORG');
+        expect(org.name()).toBe('AN ORG');
+    }));
+});
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js b/Open-ILS/web/js/ui/default/staff/test/unit/egOrg.js
new file mode 100644 (file)
index 0000000..f919d66
--- /dev/null
@@ -0,0 +1,37 @@
+'use strict';
+
+describe('egOrg', function(){
+    beforeEach(module('egCoreMod'));
+
+    function mkTree(egIDL, egEnv) { // FIXME: external sample data
+        egIDL.parseIDL();
+        window._eg_mock_data.orgTree(egIDL, egEnv);
+    }
+
+    it('should provide get by ID', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.get(egEnv.aou.tree.id())).toBe(egEnv.aou.tree);
+    }));
+
+    it('should provide get by node', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.get(egEnv.aou.tree).id()).toBe(egEnv.aou.tree.id());
+    }));
+
+    it('should provide ancestors', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.ancestors(2, true)).toEqual([2, 1]);
+    }));
+
+    it('should provide descendants', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.descendants(2, true)).toEqual([2, 4]);
+    }));
+
+    it('should provide full path', inject(function(egIDL, egEnv, egOrg) {
+        mkTree(egIDL, egEnv);
+        expect(egOrg.fullPath(4, true)).toEqual([4, 2, 1]);
+    }));
+});
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js b/Open-ILS/web/js/ui/default/staff/test/unit/egPatronApp.js
new file mode 100644 (file)
index 0000000..60e9b15
--- /dev/null
@@ -0,0 +1,31 @@
+'use strict';
+
+describe('egPatronAppTest', function(){
+  beforeEach(module('egPatronApp'));
+
+  // basic controller sanity checks
+  
+  var patronCtrl, patronScope;
+  beforeEach(inject(function ($rootScope, $controller, $location) {
+      patronScope = $rootScope.$new();
+      patronCtrl = $controller('PatronCtrl', {$scope: patronScope});
+  }));
+
+  /** patronSvc tests **/
+  describe('patronSvcTests', function() {
+
+    it('patronSvc should start with empty lists', inject(function(patronSvc) {
+        expect(patronSvc.patrons.length).toEqual(0);
+    }));
+
+    it('patronSvc reset should clear data', inject(function(patronSvc) {
+        patronSvc.checkout_overrides.a = 1;
+        expect(Object.keys(patronSvc.checkout_overrides).length).toBe(1);
+        patronSvc.resetPatronLists();
+        expect(Object.keys(patronSvc.checkout_overrides).length).toBe(0);
+        expect(patronSvc.holds.length).toBe(0);
+    }));
+
+  });
+
+});
diff --git a/Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js b/Open-ILS/web/js/ui/default/staff/test/unit/egStrings.js
new file mode 100644 (file)
index 0000000..fe81e08
--- /dev/null
@@ -0,0 +1,14 @@
+'use strict';
+
+describe('egStrings', function(){
+    beforeEach(module('egCoreMod'));
+
+    it('should interpolate values', inject(function(egStrings) {
+
+        egStrings.FOO = 'Hello, {{planet}}';
+
+        expect(egStrings.$replace(egStrings.FOO, {planet : 'Earth'}))
+       .toBe('Hello, Earth');
+    }));
+
+});
index 54b63fe..2da48ef 100644 (file)
 <!ENTITY staff.patron.user_edit.depth.label "Depth">
 <!ENTITY staff.patron.user_edit.grantable.label "Grantable">
 <!ENTITY staff.patron.user_edit.save.label "Save">
+<!ENTITY staff.patron.user_edit.display_perm.select_one "-- Select One --">
+<!ENTITY staff.patron.user_edit.save_user.depth_required "Depth is required to set the permission.">
+<!ENTITY staff.patron.user_edit.save_user.user_modified_successfully "User successfully modified.">
 <!ENTITY staff.patron.ue.ev_user_editor.label "Evergreen User Editor">
 <!ENTITY staff.patron.ue.user_greeting.label "Welcome ">
 <!ENTITY staff.patron.ue.interface_note.label "Note: required or invalid fields are <span style='border-bottom: 2px solid red;'>marked with color</span>">
diff --git a/Open-ILS/xul/staff_client/server/patron/user_edit.xhtml b/Open-ILS/xul/staff_client/server/patron/user_edit.xhtml
new file mode 100644 (file)
index 0000000..9b7bbf0
--- /dev/null
@@ -0,0 +1,176 @@
+<?xml version='1.0'?>
+
+<!-- ///////////////////////////////////////////////////////////////////////////////////////////////////////////// -->
+<!-- LOCALIZATION -->
+<!DOCTYPE window PUBLIC "" ""[
+    <!--#include virtual="/opac/locale/${locale}/lang.dtd"-->
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xi="http://www.w3.org/2001/XInclude">
+
+        <head>
+                <title>&staff.patron.user_edit.title;</title>
+                <script language='javascript' src='/opac/common/js/utils.js'> </script>
+                <script language='javascript' src='/opac/common/js//config.js'> </script>
+                <script language='javascript' src='/opac/common/js/CGI.js'> </script>
+
+                <script language='javascript' src='/opac/common/js/slimtree.js'> </script>
+                <script language='javascript' src='/opac/common/js/JSON_v1.js'> </script>
+                <script language='javascript' src='/opac/common/js/fmall.js'> </script>
+                <script language='javascript' src='/opac/common/js/fmgen.js'> </script>
+                <script language='javascript' src='/opac/common/js/opac_utils.js'> </script>
+                <script language='javascript' src='/opac/common/js/<!--#echo var="locale"-->/OrgTree.js'> </script>
+                <script language='javascript' src='/opac/common/js/org_utils.js'> </script>
+                <script language='javascript' src='/opac/common/js/init.js'> </script>
+                <script language='javascript' src='/opac/common/js/RemoteRequest.js'> </script>
+                <script language='javascript' src='/opac/common/js/sprintf.js'> </script>
+                <script language='javascript' src='user_edit_xhtml.js'> </script>
+
+
+        <style>
+
+            /*
+            .messagecatalog { -moz-binding: url( /xul/server/main/bindings.xml#messagecatalog ) }
+            */
+
+            .stay_hidden {        visibility: hidden;
+                        display: none;
+
+            }
+
+            .hideme {        visibility: hidden;
+                        display: none;
+            }
+
+            table {        border-collapse: collapse;
+                    margin-bottom: 10px;
+                    margin-top: 10px;
+            }
+
+            th {        
+                    white-space: nowrap;
+                    padding-top: 15px;
+                    padding-bottom: 10px;
+                    text-align: center;
+                    border-top: solid black 1px;
+            }
+
+            td.odd {    background-color: lightcyan; }
+
+            td.label {    text-align: right;
+                    padding-right: 10px;
+            }
+
+            td.value {    text-align: left;
+                    padding-left: 10px;
+            }
+
+            input[disabled='true'] { color: black; }
+
+        </style>
+        </head>
+
+    <div class="messagecatalog" id="patronStrings" src="/xul/server/locale/<!--#echo var='locale'-->/patron.properties" />
+
+    <body onload="try { setTimeout(init_editor,1) } catch(E) { alert(js2JSON(E)); }">
+        <form method="GET" name="editor" id="editor" >
+
+        <table width="100%">
+            <tr>
+                <td class="label">&staff.patron.user_edit.user_name.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.usrname" id="user.usrname"/></td>
+                <td class="label">&staff.patron.user_edit.barcode.label;</td>
+                <td class="value"><input type="text" name="user.card.barcode" id="user.card.barcode" disabled="true"/></td>
+            </tr>
+            <tr>
+                <td class="label">&staff.patron.user_edit.firstname.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.first_given_name" id="user.first_given_name"/></td>
+                <td class="label">&staff.patron.user_edit.middlename.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.second_given_name" id="user.second_given_name"/></td>
+                <td class="label">&staff.patron.user_edit.lastname.label;</td>
+                <td class="value"><input disabled="true" type="text" name="user.family_name" id="user.family_name"/></td>
+            </tr>
+            <tr class='advanced hideme'>
+                <td class="value" colspan="6">
+                    <table width="100%">
+                        <thead>
+                            <tr>
+                                <th></th>
+                                <th>&staff.patron.user_edit.working_location.label;</th>
+                            </tr>
+                        </thead>
+                        <tbody id="work_ous" name="work_ous"/>
+                    </table>
+                </td>
+            </tr>
+            <tr class='advanced hideme'>
+                <td class="value" colspan="6">
+                    <table width="100%">
+                        <thead>
+                            <tr>
+                                <th>&staff.patron.user_edit.permission.label;</th>
+                                <th>&staff.patron.user_edit.applied.label;</th>
+                                <th>&staff.patron.user_edit.depth.label;</th>
+                                <th>&staff.patron.user_edit.grantable.label;</th>
+                            </tr>
+                        </thead>
+                        <tbody id="permissions" name="permissions"/>
+                    </table>
+                </td>
+            </tr>
+
+        </table>
+
+        <button onclick="save_user(); return false;">&staff.patron.user_edit.save.label;</button>
+        </form>
+
+
+        <div class='hideme' id="permission-tmpl">
+            <table>
+                <tr name='prow'>
+                    <td class="value" name='plabel'>
+                        <span name="p.code"></span>
+                    </td>
+                    <td class="value" name='papply'>
+                        <input type="checkbox" name="p.id" onclick="set_perm(this.parentNode.parentNode);"/>
+                    </td>
+                    <td class="value" name='pdepth'>
+                        <select onchange="set_perm(this.parentNode.parentNode);" name="p.depth"/>
+                    </td>
+                    <td class="value" name='pgrant'>
+                        <input type="checkbox" name="p.grantable" onclick="set_perm(this.parentNode.parentNode);"/>
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+
+        <div class='hideme' id="work_ou-tmpl">
+            <table>
+                <tr name='wrow'>
+                    <td class="value" name='wapply'>
+                        <input type="checkbox" name="a.id" onclick="set_work_ou(this.parentNode.parentNode);"/>
+                    </td>
+                    <td class="value" name='label'>
+                        <span name="a.name"></span>
+                        (<span name="a.shortname"></span>)
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+        <div class="hideme"><!-- embedded string -->
+            <span id="staff.patron.user_edit.display_perm.select_one">
+                &staff.patron.user_edit.display_perm.select_one;
+            </span>
+            <span id="staff.patron.user_edit.save_user.depth_required">
+                &staff.patron.user_edit.save_user.depth_required;
+            </span>
+            <span id="staff.patron.user_edit.save_user.user_modified_successfully">
+                &staff.patron.user_edit.save_user.user_modified_successfully;
+            </span>
+        </div>
+
+    </body>
+</html>
+
diff --git a/Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js b/Open-ILS/xul/staff_client/server/patron/user_edit_xhtml.js
new file mode 100644 (file)
index 0000000..3b78ad5
--- /dev/null
@@ -0,0 +1,499 @@
+var cgi;
+var orgTree;
+var user;
+var ses_id;
+var user_groups = [];
+var adv_items = [];
+var user_perms = [];
+var perm_list = [];
+var ou_type_list = [];
+var user_work_ous = [];
+var work_ou_list = [];
+
+function $(id) { return document.getElementById(id); }
+
+function set_work_ou(row) {
+        var wid = findNodeByName(row,'a.id').getAttribute('workou_id');
+        var wapply = findNodeByName(row,'a.id').checked;
+
+        var w;
+        for (var i in user_work_ous) {
+                if (!user_work_ous[i]) continue;
+                if (user_work_ous[i].work_ou() == wid) {
+                        w = user_work_ous[i];
+                        if (wapply) {
+                                w.isdeleted(0);
+                                w.ischanged(1);
+                        } else {
+                                if (w.isnew()) {
+                                        user_work_ous[i] = null;
+                                } else {
+                                        w.isdeleted(1);
+                                }
+                        }
+                        break;
+                }
+        }
+
+        if (!w) {
+                if (wapply) {
+                        p = new puwoum();
+                        p.isnew(1);
+                        p.work_ou(wid);
+                        p.usr(user.id());
+
+                        user_work_ous.push(p);
+                }
+        }
+}
+
+function set_perm(row) {
+    var pid = findNodeByName(row,'p.code').getAttribute('permid');
+    var papply = findNodeByName(row,'p.id').checked;
+    var pdepth = findNodeByName(row,'p.depth').options[findNodeByName(row,'p.depth').selectedIndex].value;
+    var pgrant = findNodeByName(row,'p.grantable').checked;
+
+    var p;
+    for (var i in user_perms) {
+        if (user_perms[i].perm() == pid) {
+            p = user_perms[i];
+            if (papply) {
+                p.isdeleted(0);
+                p.ischanged(1);
+                p.depth(pdepth);
+                p.grantable(pgrant ? 1 : 0);
+            } else {
+                if (p.isnew()) {
+                    user_perms[i] = null;
+                } else {
+                    p.isdeleted(1);
+                }
+            }
+            break;
+        }
+    }
+
+    if (!p) {
+        if (papply) {
+            p = new pupm();
+            p.isnew(1);
+            p.perm(pid);
+            p.usr(user.id());
+            p.depth('' + pdepth);
+            p.grantable(pgrant ? 1 : 0);
+
+            user_perms.push(p);
+        }
+    }
+
+}
+
+function save_user () {
+
+    try {
+
+        var save_perms = [];
+        for (var i in user_perms) {
+            // Group based perm? skip it.
+            if (user_perms[i].id() < 0) continue;
+
+            if (user_perms[i].depth() == null) {
+                var p;
+                for (var j in perm_list) {
+                    if (perm_list[j].id() == user_perms[i].perm()) {
+                        p = perm_list[j];
+                        break;
+                    }
+                }
+
+                alert(
+                    $('staff.patron.user_edit.save_user.depth_required').innerHTML
+                    + '\n' + p.code()
+                );
+
+                throw new Error(
+                    $('staff.patron.user_edit.save_user.depth_required').innerHTML
+                    + '\n' + p.code()
+                );
+            }
+
+            save_perms.push( user_perms[i] );
+        }
+
+        var save_ous = [];
+        for (var i in user_work_ous) {
+            if (!user_work_ous[i]) continue;
+            save_ous.push( user_work_ous[i] );
+        }
+
+        var req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.work_ous.update', ses_id, save_ous );
+        req.send(true);
+        var wok = req.getResultObject();
+
+        if (wok.ilsevent) throw wok;
+
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.permissions.update', ses_id, save_perms );
+        req.send(true);
+        var pok = req.getResultObject();
+
+        if (pok.ilsevent) throw pok;
+
+        if (pok || wok) {
+            alert($('staff.patron.user_edit.save_user.user_modified_successfully').innerHTML);
+            // on_patron_save comes from the browser client
+            if (window.xulG && xulG.on_patron_save) xulG.on_patron_save();
+        }
+
+        init_editor();
+
+    } catch (e) {
+        dump( js2JSON( e ));
+        alert( js2JSON( e ));
+    };
+
+
+
+    return false;
+}
+
+var adv_mode = true;
+function apply_adv_mode (root) {
+    adv_items = findNodesByClass(root,'advanced');
+    for (var i in adv_items) {
+        adv_mode ?
+            removeCSSClass(adv_items[i], 'hideme') :
+            addCSSClass(adv_items[i], 'hideme');
+    }
+}
+
+function init_editor (u) {
+    
+    var x = document.getElementById('editor').elements;
+    
+    cgi = new CGI();
+    if (cgi.param('adv')) adv_mode = true; 
+    try {
+        if (xulG) if (xulG.adv) adv_mode = true;
+        if (xulG) if (xulG.params) if (xulG.params.adv) adv_mode = true;
+    } catch (e) {}
+
+    apply_adv_mode(document.getElementById('editor'));
+
+    ses_id = cgi.param('ses'); 
+    try {
+        if (xulG) if (xulG.ses) ses_id = xulG.ses;
+        if (xulG) if (xulG.params) if (xulG.params.ses) ses_id = xulG.params.ses;
+    } catch (e) {}
+
+    var usr_id = cgi.param('usr'); 
+    try {
+        if (xulG) if (xulG.usr_id) usr_id = xulG.usr_id;
+        if (xulG) if (xulG.params) if (xulG.params.usr_id) usr_id = xulG.params.usr_id;
+    } catch (e) {}
+
+    var usr_barcode = cgi.param('barcode'); 
+    try {
+        if (xulG) if (xulG.usr_barcode) usr_ibarcode = xulG.usr_barcode;
+        if (xulG) if (xulG.params) if (xulG.params.usr_barcode) usr_ibarcode = xulG.params.usr_barcode;
+    } catch (e) {}
+
+    try {
+        var req;
+        if (usr_id) {
+            req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve', ses_id, usr_id );
+        } else {
+            req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.fleshed.retrieve_by_barcode', ses_id, usr_barcode );
+        }
+        req.send(true);
+        user = req.getResultObject();
+    } catch (E) {
+        alert(E);
+    }
+
+    if (user.usrname()) x['user.usrname'].value = user.usrname();
+    x['user.usrname'].setAttribute('onchange','user.usrname(this.value)');
+
+    if (user.card() && user.card().barcode()) x['user.card.barcode'].value = user.card().barcode();
+    x['user.card.barcode'].setAttribute('onchange','user.card().barcode(this.value)');
+
+    if (user.first_given_name()) x['user.first_given_name'].value = user.first_given_name();
+    x['user.first_given_name'].setAttribute('onchange','user.first_given_name(this.value)');
+
+    if (user.second_given_name()) x['user.second_given_name'].value = user.second_given_name();
+    x['user.second_given_name'].setAttribute('onchange','user.second_given_name(this.value);');
+
+    if (user.family_name()) x['user.family_name'].value = user.family_name();
+    x['user.family_name'].setAttribute('onchange','user.family_name(this.value)');
+
+    // grab the editing staff user object
+    req = new RemoteRequest( 'open-ils.auth', 'open-ils.auth.session.retrieve', ses_id );
+    req.send(true);
+    var staff = req.getResultObject();
+
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id );
+    req.send(true);
+    var staff_perms = req.getResultObject();
+
+    // Get the top of the staff perm org for ASSIGN_WORK_ORG_UNIT
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.perm.highest_org', ses_id, staff.id(), 'ASSIGN_WORK_ORG_UNIT' );
+    req.send(true);
+    var top_work_ou = req.getResultObject();
+
+    // and now, the orgs where this staff member can apply the perms
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_tree.descendants.retrieve', top_work_ou);
+    req.send(true);
+    var work_ou_tree = req.getResultObject();
+
+    // and now, the orgs where this staff member can apply the perms
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.user.get_work_ous', ses_id, user.id());
+    req.send(true);
+    user_work_ous = req.getResultObject();
+
+    // and finally, the ou types
+    req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.org_types.retrieve' );
+    req.send(true);
+    ou_type_list = req.getResultObject();
+
+    user_perms = [];
+    perm_list = [];
+    if (user.id() > 0) {
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.user_perms.retrieve', ses_id, user.id() );
+        req.send(true);
+        user_perms = req.getResultObject();
+
+        req = new RemoteRequest( 'open-ils.actor', 'open-ils.actor.permissions.retrieve' );
+        req.send(true);
+        perm_list = req.getResultObject();
+    }
+
+    f = document.getElementById('permissions');
+    while (f.firstChild) f.removeChild(f.lastChild);
+
+    var rcount = 0;
+    for (var i in perm_list.sort(function(a,b){ if (a.code() < b.code()) return -1;return 1; }))
+        display_perm(f,perm_list[i],staff_perms, rcount++);
+
+    f = document.getElementById('work_ous');
+    while (f.firstChild) f.removeChild(f.lastChild);
+
+    //flatten the ou tree, keep only those with can_hav_users = true
+    work_ou_list = [];
+    trim_ou_tree( [work_ou_tree], work_ou_list );
+
+    rcount = 0;
+    for (var i in work_ou_list.sort( function(a,b){ if (a.name() < b.name()) return -1;return 1; }) )
+        display_work_ou(f,work_ou_list[i], rcount++);
+
+    return true;
+}
+
+function grep ( code, list ) {
+    var ret = [];
+    for (var i in list) {
+        if (code(list[i])) ret.push(list[i]);
+    }
+    return ret;
+}
+
+function trim_ou_tree (tree, list) {
+    for (var i in tree) {
+        if (!tree[i]) continue;
+
+        var type = grep( function(x) {return x.id() == tree[i].ou_type()}, ou_type_list )[0];
+        if ( type && type.can_have_users() == 't' )
+            list.push(tree[i]);
+
+        if (tree[i].children()) trim_ou_tree(tree[i].children(), list);
+    }
+}
+
+function display_work_ou (root,ou_def,r) {
+
+    var wrow = findNodeByName(document.getElementById('work_ou-tmpl'), 'wrow').cloneNode(true);
+    root.appendChild(wrow);
+
+    var label_cell = findNodeByName(wrow,'label');
+    findNodeByName(label_cell,'a.name').appendChild(text(ou_def.name()));
+    findNodeByName(label_cell,'a.shortname').appendChild(text(ou_def.shortname()));
+    if (r % 2) label_cell.className += ' odd';
+
+    var apply_cell = findNodeByName(wrow,'wapply');
+    findNodeByName(apply_cell,'a.id').setAttribute('workou_id', ou_def.id());
+    if (r % 2) apply_cell.className += ' odd';
+
+    var has_it = grep(
+        function(x){ return x.work_ou() == ou_def.id() },
+        user_work_ous
+    ).length;
+
+    findNodeByName(apply_cell,'a.id').checked = has_it > 0 ? true : false;
+}
+
+function display_perm (root,perm_def,staff_perms, r) {
+
+    var prow = findNodeByName(document.getElementById('permission-tmpl'), 'prow').cloneNode(true);
+    root.appendChild(prow);
+
+    var all = false;
+    for (var i in staff_perms) {
+        if (staff_perms[i].perm() == -1) {
+            all = true;
+            break;
+        }
+    }
+
+
+    var sp,up;
+    if (!all) {
+        for (var i in staff_perms) {
+            if (perm_def.id() == staff_perms[i].perm() || staff_perms[i].perm() == -1) {
+                sp = staff_perms[i];
+                break;
+            }
+        }
+    }
+
+    for (var i in user_perms) {
+        if (perm_def.id() == user_perms[i].perm())
+            up = user_perms[i];
+    }
+
+
+    var dis = false;
+    if ((up && up.id() < 0) || !sp || !sp.grantable()) dis = true; 
+    if (all) dis = false; 
+
+    var label_cell = findNodeByName(prow,'plabel');
+    findNodeByName(label_cell,'p.code').appendChild(text(perm_def.code()));
+    findNodeByName(label_cell,'p.code').setAttribute('title', perm_def.description());
+    findNodeByName(label_cell,'p.code').setAttribute('permid', perm_def.id());
+    if (r % 2) label_cell.className += ' odd';
+
+    var apply_cell = findNodeByName(prow,'papply');
+    findNodeByName(apply_cell,'p.id').disabled = dis;
+    findNodeByName(apply_cell,'p.id').checked = up ? true : false;
+    if (r % 2) apply_cell.className += ' odd';
+
+    var depth_cell = findNodeByName(prow,'pdepth');
+    findNodeByName(depth_cell,'p.depth').disabled = dis;
+    findNodeByName(depth_cell,'p.depth').id = 'perm-depth-' + perm_def.id();
+    if (r % 2) depth_cell.className += ' odd';
+    selectBuilder(
+        'perm-depth-' + perm_def.id(),
+        globalOrgTypes,
+        (up ? up.depth() : findOrgDepth(user.home_ou())),
+        { label_field        : 'name',
+          value_field        : 'depth',
+          empty_label        : $('staff.patron.user_edit.display_perm.select_one').innerHTML,
+          empty_value        : '',
+          clear            : true }
+    );
+    
+    var grant_cell = findNodeByName(prow,'pgrant');
+    findNodeByName(grant_cell,'p.grantable').disabled = dis;
+    findNodeByName(grant_cell,'p.grantable').checked = up ? (up.grantable() ? true : false) : false;
+    if (r % 2) grant_cell.className += ' odd';
+
+}
+
+
+function selectBuilder (id, objects, def, args) {
+    var label_field = args['label_field'];
+    var value_field = args['value_field'];
+    var depth = args['depth'];
+
+    if (!depth) depth = 0;
+
+    args['depth'] = parseInt(depth) + 1;
+
+    var child_field_name = args['child_field_name'];
+
+    var sel = id;
+    if (typeof sel != 'object')
+        sel = document.getElementById(sel);
+
+    if (args['clear']) {
+        for (var o in sel.options) {
+            sel.options[o] = null;
+        }
+        args['clear'] = false;
+        if (args['empty_label']) {
+            sel.options[0] = new Option( args['empty_label'], args['empty_value'] );
+            sel.selectedIndex = 0;
+        }
+    }
+
+    for (var i in objects) {
+        var l = objects[i][label_field];
+        var v = objects[i][value_field];
+
+        if (typeof l == 'function')
+            l = objects[i][label_field]();
+
+        if (typeof v == 'function')
+            v = objects[i][value_field]();
+
+        var opt = new Option( l, v );
+
+        if (depth) {
+            var d = 10 * depth;
+            opt.style.paddingLeft = '' + d + 'px';
+        }
+
+        sel.options[sel.options.length] = opt;
+
+
+        if (typeof def == 'object') {
+            for (var j in def) {
+                if (v == def[j]) {
+                    opt.selected = true;
+                    sel.value = v;
+                }
+            }
+        } else {
+            if (v == def) {
+                opt.selected = true;
+                sel.value = v;
+            }
+        }
+
+        if (child_field_name) {
+            var c = objects[i][child_field_name];
+            if (typeof c == 'function')
+                c = objects[i][child_field_name]();
+
+            selectBuilder(
+                id,
+                c,
+                def,
+                { label_field        : args['label_field'],
+                  value_field        : args['value_field'],
+                  depth            : args['depth'],
+                  child_field_name    : args['child_field_name'] }
+            );
+        }
+
+    }
+}    
+
+function findNodesByClass(root, nodeClass, list) {
+    if(!list) list = [];
+        if( !root || !nodeClass) {
+        return null;
+    }
+        
+        if(root.nodeType != 1) {
+        return null;
+    }
+        
+        if(root.className.match(nodeClass)) list.push( root );
+
+        var children = root.childNodes;
+        
+        for( var i = 0; i != children.length; i++ ) {
+                findNodesByClass(children[i], nodeClass, list);
+        }                       
+                        
+        return list;            
+}                                       
+