From: Bill Erickson Date: Thu, 24 Oct 2013 18:39:33 +0000 (-0400) Subject: Angular web staff - initial import X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=ea5feaea15bc921553c6ac1caa71b2411f4fc155;p=working%2FEvergreen.git Angular web staff - initial import Batch import of staff client prototype work in progress. Signed-off-by: Bill Erickson --- diff --git a/Open-ILS/examples/apache/eg_vhost.conf.in b/Open-ILS/examples/apache/eg_vhost.conf.in index 1de2212cdf..45487ec704 100644 --- a/Open-ILS/examples/apache/eg_vhost.conf.in +++ b/Open-ILS/examples/apache/eg_vhost.conf.in @@ -787,7 +787,68 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT] +# TODO: as is, each sub-app will require a new Location entry, which +# will quickly grow large (and it's unnecessary and annoying). we need a +# better solution. + + Options -MultiViews + # any reuest that does not map to a template file + # is redirected to the index. This allows us to + # map multiple routes to the same application. + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/index + RewriteCond %{PATH_INFO} !/staff/t_* + RewriteRule .* /eg/staff/index [L,DPI] + + # is this redundant? + + Header append Cache-Control "public" + + + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + Header append Vary User-Agent env=!dont-vary + + + + + Options -MultiViews + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/index + RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/t_* + RewriteRule .* /eg/staff/cat/bucket/record/index [L,DPI] + + + Options -MultiViews + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/circ/patron/index + RewriteCond %{PATH_INFO} !/staff/circ/patron/t_* + RewriteRule .* /eg/staff/circ/patron/index [L,DPI] + + + + + Header append Cache-Control "public" + + + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + Header append Vary User-Agent env=!dont-vary + + + + # Uncomment the following to force SSL for everything. Note that this defeats caching # and you will suffer a performance hit. #RewriteCond %{HTTPS} off #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [NE,R,L] + + diff --git a/Open-ILS/examples/apache_24/eg_vhost.conf.in b/Open-ILS/examples/apache_24/eg_vhost.conf.in index f530f2935e..3e07b09cf5 100644 --- a/Open-ILS/examples/apache_24/eg_vhost.conf.in +++ b/Open-ILS/examples/apache_24/eg_vhost.conf.in @@ -799,6 +799,66 @@ RewriteRule ^/openurl$ ${openurl:%1} [NE,PT] +# TODO: as is, each sub-app will require a new Location entry, which +# will quickly grow large (and it's unnecessary and annoying). we need a +# better solution. + + Options -MultiViews + # any reuest that does not map to a template file + # is redirected to the index. This allows us to + # map multiple routes to the same application. + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/index + RewriteCond %{PATH_INFO} !/staff/t_* + RewriteRule .* /eg/staff/index [L,DPI] + + # is this redundant? + + Header append Cache-Control "public" + + + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + Header append Vary User-Agent env=!dont-vary + + + + + Options -MultiViews + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/index + RewriteCond %{PATH_INFO} !/staff/cat/bucket/record/t_* + RewriteRule .* /eg/staff/cat/bucket/record/index [L,DPI] + + + Options -MultiViews + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/circ/patron/index + RewriteCond %{PATH_INFO} !/staff/circ/patron/t_* + RewriteRule .* /eg/staff/circ/patron/index [L,DPI] + + + + + Header append Cache-Control "public" + + + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSI[E] !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + Header append Vary User-Agent env=!dont-vary + + + + + # Uncomment the following to force SSL for everything. Note that this defeats caching # and you will suffer a performance hit. #RewriteCond %{HTTPS} off diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 66f365d9a6..1c9c72e1bb 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -1294,7 +1294,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - + @@ -1307,6 +1307,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + + + + + @@ -5918,7 +5923,7 @@ SELECT usr, - + @@ -5929,6 +5934,15 @@ SELECT usr, + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/README b/Open-ILS/src/templates/staff/README new file mode 100644 index 0000000000..920630183f --- /dev/null +++ b/Open-ILS/src/templates/staff/README @@ -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/cat/bucket/record/index.tt2 b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 new file mode 100644 index 0000000000..27f4c80876 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/index.tt2 @@ -0,0 +1,50 @@ +[% + WRAPPER "staff/t_base.tt2"; + ctx.page_title = l("Record Buckets"); + ctx.page_app = "egCatRecordBuckets"; + ctx.page_ctrl = "RecordBucketCtrl"; +%] + +[% BLOCK APP_JS %] + + + +[% END %] + + + + +
+
+
+
+
+ +[% 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 index 0000000000..517c35fd72 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_create.tt2 @@ -0,0 +1,37 @@ + + + +
+ +
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 index 0000000000..0ca9887f9a --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_delete.tt2 @@ -0,0 +1,16 @@ + 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 index 0000000000..f0acbf4dc6 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_edit.tt2 @@ -0,0 +1,36 @@ + +
+ +
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 index 0000000000..9a36d09729 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_export.tt2 @@ -0,0 +1,43 @@ + +
+ +
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 index 0000000000..1adee8c5b7 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_info.tt2 @@ -0,0 +1,16 @@ + +
+ [% l('Bucket {{bucket().name()}}') %] + + + + + / [% l('Created {{bucket().create_time() | date}}') %] + / {{bucket().description()}} +
+ +
+ [% l('No Bucket Selected') %] +
+ 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 index 0000000000..1e34f6a696 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_bucket_selector.tt2 @@ -0,0 +1,27 @@ + + 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 index 0000000000..a547cd9dfc --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_load_shared.tt2 @@ -0,0 +1,27 @@ + +
+ +
+ 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 index 0000000000..6fabd1c83b --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_pending.tt2 @@ -0,0 +1,127 @@ +
+ + + +
+
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %] +
+ +
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %] + +
+ + + + + + + + + + +
+ + +
+ + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + +
# + + + {{field.label}} +
{{$index + 1 + pageList.offset}} + + {{rec[field.name]}} +
+
+
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 index 0000000000..05319d7c53 --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_search.tt2 @@ -0,0 +1,156 @@ + +
+ + + +
+
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %] +
+ +
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %] + + +
+
+ +
+
+
+
+
+ [% l('Record Query') %] + +
+
+
+
+ +
+
+
+
+
+ [% l('Searching...') %] +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + +
# + + + {{field.label}} +
{{$index + 1 + pageList.offset}} + + {{rec[field.name]}} +
+
+
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 index 0000000000..53306e4c0c --- /dev/null +++ b/Open-ILS/src/templates/staff/cat/bucket/record/t_view.tt2 @@ -0,0 +1,130 @@ +
+ + + +
+
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_info.tt2' %] +
+ +
+ [% INCLUDE 'staff/cat/bucket/record/t_bucket_selector.tt2' %] + + +
+ + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+ [% l('The selected bucket "{{bucketId}}" is not visible to this login.') %] +
+
+ +
+ + + + + + + + + + + + + + + +
# + + + {{field.label}} +
{{$index + 1 + pageList.offset}} + + {{rec[field.name]}} +
+
+
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 index 0000000000..1130edfe14 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/index.tt2 @@ -0,0 +1,51 @@ +[% + WRAPPER "staff/t_base.tt2"; + ctx.page_title = l("Patron"); + ctx.page_app = "egPatronApp"; + ctx.page_ctrl = "PatronCtrl"; +%] + +[% BLOCK APP_JS %] + + + + +[% END %] + +
+
+ [% INCLUDE 'staff/circ/patron/t_summary.tt2' %] +
+ +
+ +[% END %] 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 index 0000000000..0fff7f3617 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_bills.tt2 @@ -0,0 +1 @@ +BILLS 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 index 0000000000..4a3142d3c6 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2 @@ -0,0 +1,36 @@ + + + + +
+
+
+
+ + + + + +
+
+
+
+
+ [% INCLUDE 'staff/parts/column_picker.tt2' listname='checkouts' %] +
+
+
+ +[% INCLUDE 'staff/circ/patron/t_checkout_table.tt2' %] diff --git a/Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2 new file mode 100644 index 0000000000..13c84800c9 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_checkout_table.tt2 @@ -0,0 +1,50 @@ + +[% +# checkout table columns +COLUMNS = [ +{label => l('Barcode'), name => 'copy_barcode' display => 1}, +{label => l('Circ ID'), name => 'payload.circ.id', display => 1}, +{label => l('Due Date'), name => 'payload.circ.due_date' display => 1}, +# once we are handling all response types, we probably don't need to show +# Response. Or, at least, make it more friendly / localizable +{label => l('Response'), name => 'textcode', display => 1}, +{label => l('Title'), name => 'payload.record.title', display => 1}, +{label => l('Author'), name => 'payload.record.author', display => 1}, +{label => l('Call Number'),name => 'payload.copy.call_number.label', display => 1}, +{label => l('Alert Msg'), name => 'payload.copy.alert_message' display => 1}, +] +%] + + +
+
+ +
+
+ + + + + + + + + + + + + +
# + {{col.label}} +
{{checkouts.count() - $index}} + {{checkouts.fieldValue(checkout, col.name)}} +
+
+
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 index 0000000000..00daeb87e5 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_edit.tt2 @@ -0,0 +1 @@ +EDIT diff --git a/Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2 new file mode 100644 index 0000000000..c2f25698d6 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_event_override_dialog.tt2 @@ -0,0 +1,29 @@ +
+ +
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 index 0000000000..61c85b36a5 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_holds.tt2 @@ -0,0 +1,16 @@ + + + + +
+
+ [% INCLUDE 'staff/circ/patron/t_holds_actions.tt2' %] +
+
+ +[% INCLUDE 'staff/circ/patron/t_holds_table.tt2' %] diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2 new file mode 100644 index 0000000000..5d43ea879a --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_holds_actions.tt2 @@ -0,0 +1,34 @@ +
+ + + + + +
+ + +
+ + [% INCLUDE 'staff/parts/column_picker.tt2' listname='holds' %] +
+ diff --git a/Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2 new file mode 100644 index 0000000000..749afe8438 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_holds_table.tt2 @@ -0,0 +1,59 @@ + + +[% +COLUMNS = [ +{label => l('Hold ID'), name => 'hold.id', display => 1}, +{label => l('Current Copy'), name => 'hold.current_copy.barcode' display => 1}, +{label => l('Request Date'), name => 'hold.request_time' display => 1}, +{label => l('Capture Date'), name => 'hold.capture_time' display => 1}, +{label => l('Available Date'), name => 'hold.shelf_time' display => 1}, +{label => l('Type'), name => 'hold.hold_type' display => 1}, +{label => l('Pickup Library'), name => 'hold.pickup_lib.shortname' display => 1}, +{label => l('Title'), name => 'mvr.title', display => 1}, +{label => l('Author'), name => 'mvr.author', display => 1} +] +%] + + +
+
+ +
+
+ + + + + + + + + + + + + + +
# + {{col.label}} +
{{$index + 1}} + + {{holds.fieldValue(hold_data, col.name)}} +
+
+
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 index 0000000000..395f7abd49 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2 @@ -0,0 +1,16 @@ + + + + +
+
+ [% INCLUDE 'staff/circ/patron/t_items_out_actions.tt2' %] +
+
+ +[% INCLUDE 'staff/circ/patron/t_items_out_table.tt2' %] diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2 new file mode 100644 index 0000000000..c43fd9a96e --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_items_out_actions.tt2 @@ -0,0 +1,34 @@ +
+ + + + + +
+ + +
+ + [% INCLUDE 'staff/parts/column_picker.tt2' listname='items_out' %] +
+ diff --git a/Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2 new file mode 100644 index 0000000000..218549c8b7 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_items_out_table.tt2 @@ -0,0 +1,57 @@ + + +[% +COLUMNS = [ +{label => l('Circ ID'), name => 'id', display => 1}, +{label => l('Barcode'), name => 'target_copy.barcode' display => 1}, +{label => l('Due Date'), name => 'due_date' display => 1}, +{label => l('Checkout/Renewal Library'), name => 'circ_lib.shortname' display => 1}, +{label => l('Renewals Remaining'), name => 'renewal_remaining' display => 1}, +{label => l('Fines Stopped'), name => 'stop_fines' display => 1}, +{label => l('Title'), name => 'target_copy.call_number.record.simple_record.title', display => 1}, +] +%] + + +
+
+ +
+
+ + + + + + + + + + + + + + +
# + {{col.label}} +
{{$index + 1}} + + {{items_out.fieldValue(circ, col.name)}} +
+
+
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 index 0000000000..b0632b00be --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_messages.tt2 @@ -0,0 +1 @@ +MESSAGES diff --git a/Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2 new file mode 100644 index 0000000000..793f974f71 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_precat_dialog.tt2 @@ -0,0 +1,45 @@ + +
+ +
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 index 0000000000..083d2f946b --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_search.tt2 @@ -0,0 +1,40 @@ + +

+
+
+
+ + + + + + +
+
+
+ [% INCLUDE 'staff/circ/patron/t_search_actions.tt2' %] +
+
+ + +
+
+
+
+ +
    +
  1. Click a row to select a patron. This will activate action tabs for the patron.
  2. +
  3. Double-Click a row to focus the checkout tab for a patron.
  4. +
  5. Middle-click to open a patron in a new browser tab.
  6. +
+
+ [% INCLUDE 'staff/circ/patron/t_search_results.tt2' %] +
+
+ + diff --git a/Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2 b/Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2 new file mode 100644 index 0000000000..3cf7724d26 --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_search_actions.tt2 @@ -0,0 +1,40 @@ +
+ + + + + + + [% INCLUDE 'staff/parts/column_picker.tt2' listname='patrons' %] +
+ 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 index 0000000000..2d9b2e1bdd --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_search_results.tt2 @@ -0,0 +1,84 @@ + + +[% +# Default / available display columns +# Since there will be demand for configurable columns in this UI, +# experiment with automagic column creation. +# +# We could autogenerate much of this from the IDL. However, since there +# are special cases to handle (e.g. billing vs mailing address) and +# because table autogeneration will likely evolve over time, go ahead +# and list the columns explicitly for now. +# +# the 'name' field doubles as the path to the value and as a unique +# key for the column picker +COLUMNS = [ + +{label => l('ID'), name => 'id', display => 1}, +{label => l('Card'), name => 'card.barcode', display => 1}, +{label => l('Last Name'), name => 'family_name', display => 1}, +{label => l('First Name'), name => 'first_given_name', display => 1}, +{label => l('Middle Name'), name => 'second_given_name',display => 1}, +{label => l('DoB'), name => 'dob', display => 1}, +{label => l('Created On'), name => 'create_date', display => 1}, + +{label => l('Mailing:Street 1'), name => 'mailing_address.street1', display => 1}, +{label => l('Mailing:Street 2'), name => 'mailing_address.street2'}, +{label => l('Mailing:City'), name => 'mailing_address.city'}, +{label => l('Mailing:County'), name => 'mailing_address.county'}, +{label => l('Mailing:State'), name => 'mailing_address.state'}, +{label => l('Mailing:Zip'), name => 'mailing_address.post_code'}, + +{label => l('Billing:Street 1'), name => 'billing_address.street1'}, +{label => l('Billing:Street 2'), name => 'billing_address.street2'}, +{label => l('Billing:City'), name => 'billing_address.city'}, +{label => l('Billing:County'), name => 'billing_address.county'}, +{label => l('Billing:State'), name => 'billing_address.state'}, +{label => l('Billing:Zip'), name => 'billing_address.post_code'} + +] +%] + + +
+
+ + + + + + + + + + + + + + + + +
# + {{col.label}} +
{{$index + 1}} + + {{patrons.fieldValue(user, col.name)}} +
+ 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 index 0000000000..9edd66ae5b --- /dev/null +++ b/Open-ILS/src/templates/staff/circ/patron/t_summary.tt2 @@ -0,0 +1,150 @@ + + + +
+
+
+ [% l('Patron Summary') %] +
+
+
+
+
+

+[% l('{{patron().first_given_name()}} {{patron().second_given_name()}} {{patron().family_name()}}') %] +

+
+
+
+
+ {{penalty.note() || penalty.standing_penalty().label()}} +
+
+ {{penalty.set_date() | date:'shortDate'}} +
+
+
+
[% l('Profile') %]
+
{{patron().profile().name()}}
+
+
+
[% l('Home Library') %]
+
{{patron().home_ou().shortname()}}
+
+
+
[% l('Net Access') %]
+
{{patron().net_access_level().name()}}
+
+
+
[% l('Last Activity') %]
+
{{patron().usr_activity()[0].event_time() | date:'shortDate'}}
+
+
+
[% l('Last Updated') %]
+
{{patron().last_update_time() | date:'shortDate'}}
+
+
+
[% l('Create Date') %]
+
{{patron().create_date() | date:'shortDate'}}
+
+
+
[% l('Expire Date') %]
+
{{patron().expire_date() | date:'shortDate'}}
+
+
+
[% l('Fines Owed') %]
+
+ {{patron_stats().fines.balance_owed | currency}} +
+
+
+
[% l('Items Out') %]
+
{{patron_stats().checkouts.out}}
+
+
+
[% l('Overdue') %]
+
{{patron_stats().checkouts.overdue}}
+
+
+
[% l('Long Overdue') %]
+
{{patron_stats().checkouts.long_overdue}}
+
+
+
[% l('Claimed Returned') %]
+
{{patron_stats().checkouts.claims_returned}}
+
+
+
[% l('Lost') %]
+
{{patron_stats().checkouts.lost}}
+
+
+
[% l('Holds') %]
+
+ {{patron_stats().holds.total}} / {{patron_stats().holds.ready}} +
+
+
+
[% l('Card') %]
+
{{patron().card().barcode()}}
+
+
+
[% l('Username') %]
+
{{patron().usrname()}}
+
+
+
[% l('Day Phone') %]
+
{{patron().day_phone()}}
+
+
+
[% l('Evening Phone') %]
+
{{patron().evening_phone()}}
+
+
+
[% l('Other Phone') %]
+
{{patron().other_phone()}}
+
+
+
[% l('ID1') %]
+
{{patron().ident_type().name()}}
+
+
+
[% l('ID2') %]
+
{{patron().ident_type2().name()}}
+
+
+
[% l('Email') %]
+
{{patron().email()}}
+
+ +
+ +
+
+
+
+ {{addr.address_type()}} +
{{addr.street1()}} {{addr.street2()}}
+
{{addr.city()}}, {{addr.state()}} {{addr.post_code()}}
+
+
+
+
+
diff --git a/Open-ILS/src/templates/staff/index.tt2 b/Open-ILS/src/templates/staff/index.tt2 new file mode 100644 index 0000000000..478ff2f755 --- /dev/null +++ b/Open-ILS/src/templates/staff/index.tt2 @@ -0,0 +1,17 @@ +[% + WRAPPER "staff/t_base.tt2"; + ctx.page_title = l("Home"); + ctx.page_app = "egHome"; +%] + +[% BLOCK APP_JS %] + + + + +[% END %] + +
+ +[% END %] + diff --git a/Open-ILS/src/templates/staff/parts/column_picker.tt2 b/Open-ILS/src/templates/staff/parts/column_picker.tt2 new file mode 100644 index 0000000000..c690de689d --- /dev/null +++ b/Open-ILS/src/templates/staff/parts/column_picker.tt2 @@ -0,0 +1,43 @@ + +[%# +Must be wrapped in a btn-group/text-left div for correct display. +It's not done here since the caller may wish to add other buttons/ +dropdowns, etc. to the btn-group + +
+ [ INCLUDE 'staff/parts/column_picker.tt2' listname=somelist ] +
+%] + + + diff --git a/Open-ILS/src/templates/staff/t_base.tt2 b/Open-ILS/src/templates/staff/t_base.tt2 new file mode 100644 index 0000000000..325f1d9526 --- /dev/null +++ b/Open-ILS/src/templates/staff/t_base.tt2 @@ -0,0 +1,24 @@ + + + + [% l('Evergreen Staff [_1]', ctx.page_title) %] + + + + + + + + + [% INCLUDE "staff/t_navbar.tt2" %] +
[% content %]
+ + [% + INCLUDE "staff/t_base_js.tt2"; + + # App-specific JS load commands go into an APP_JS block. + PROCESS APP_JS; + %] + diff --git a/Open-ILS/src/templates/staff/t_base_js.tt2 b/Open-ILS/src/templates/staff/t_base_js.tt2 new file mode 100644 index 0000000000..2f39eca7af --- /dev/null +++ b/Open-ILS/src/templates/staff/t_base_js.tt2 @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/templates/staff/t_login.tt2 b/Open-ILS/src/templates/staff/t_login.tt2 new file mode 100644 index 0000000000..506e3eb3e3 --- /dev/null +++ b/Open-ILS/src/templates/staff/t_login.tt2 @@ -0,0 +1,52 @@ +
+
+
+
+
+ [% l('Sign In') %] + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ + [% l('Login Failed') %] +
+
+
+
+
+
+
+
diff --git a/Open-ILS/src/templates/staff/t_navbar.tt2 b/Open-ILS/src/templates/staff/t_navbar.tt2 new file mode 100644 index 0000000000..c894220866 --- /dev/null +++ b/Open-ILS/src/templates/staff/t_navbar.tt2 @@ -0,0 +1,89 @@ + + + + + diff --git a/Open-ILS/src/templates/staff/t_splash.tt2 b/Open-ILS/src/templates/staff/t_splash.tt2 new file mode 100644 index 0000000000..b1e3e193a9 --- /dev/null +++ b/Open-ILS/src/templates/staff/t_splash.tt2 @@ -0,0 +1,53 @@ +
+
+
+ +
+
+
+
+ +
+
+
+
[% l('Circulation and Patrons') %]
+
+ +
+
+ +
+
+
+
[% l('Item Search and Cataloging') %]
+
+ +
+
+ +
+
+
+
[% l('Administration') %]
+
+
+ +
+
+
+ +
+
diff --git a/Open-ILS/web/css/skin/default/staff/base.css b/Open-ILS/web/css/skin/default/staff/base.css new file mode 100644 index 0000000000..f46456026a --- /dev/null +++ b/Open-ILS/web/css/skin/default/staff/base.css @@ -0,0 +1,71 @@ +/* -------------------------------------------------------------------------- + * Simple default navbar style adjustements to apply the Evergreen color. + * TODO: style other components to match EG color scheme + */ +.navbar-default { + background: -webkit-linear-gradient(#00593d, #007a54); + background-color: #007a54; + color: #fff; +} + +.navbar-default .navbar-nav>li>a { + color: #fff; +} + +.navbar-default .navbar-nav>li>a:hover { + color: #ddd; +} + +.navbar-default .navbar-nav>.dropdown>a .caret { + border-top-color: #fff; + border-bottom-color: #fff; +} +.navbar-default .navbar-nav>.dropdown>a:hover .caret { + border-top-color: #ddd; + border-bottom-color: #ddd; +} + +/* -------------------------------------------------------------------------- + * 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; +} 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 index 0000000000..2b4fba5150 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/app.js @@ -0,0 +1,87 @@ +/** + * App to drive the base page. + * Login Form + * Splash Page + */ + +angular.module('egHome', ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod']) + +.config(function($routeProvider, $locationProvider) { + + /** + * Route resolvers allow us to run async commands + * before the page controller is instantiated. + */ + var resolver = {delay : function(egStartup) {return egStartup.go()}}; + + $routeProvider.when('/login', { + templateUrl: './t_login', + controller: 'LoginCtrl', + resolve : {delay : function(egStartup, egAuth) { + // hack for now to kill the base ses cookie where sub-path + // apps were unable to remove it. See note at the top of + // services/auth.js about angular cookies and paths. + egAuth.logout(); + return egStartup.go(); + }} + }); + + // default page + $routeProvider.otherwise({ + templateUrl : './t_splash', + controller : 'SplashCtrl', + resolve : resolver + }); + + // HTML5 pushstate support + $locationProvider.html5Mode(true); +}) + +/** + * 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', 'egAuth', + function($scope, $location, $window, egAuth) { + $scope.focusMe = true; + + // for now, workstations may be passed in via URL param + $scope.args = {workstation : $location.search().ws}; + + $scope.login = function(args) { + args.type = 'staff'; + $scope.loginFailed = false; + + egAuth.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/cat/bucket/record/app.js b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js new file mode 100644 index 0000000000..1497c7fbde --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/cat/bucket/record/app.js @@ -0,0 +1,632 @@ +/** + * 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', 'egListMod']) + +.config(function($routeProvider, $locationProvider) { + $locationProvider.html5Mode(true); + + 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','egList','egNet','egAuth','egIDL','egEvent', +function($q, egList, egNet, egAuth, egIDL, egEvent) { + + 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 : egList.create(), + pendingList : egList.create(), + viewList : egList.create({indexField : 'item_id'}), + + // 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 egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.retrieve_by_class.authoritative', + egAuth.token(), egAuth.user().id(), + 'biblio', 'staff_client' + ).then(function(buckets) { self.allBuckets = buckets }); + }, + + createBucket : function(name, desc) { + var deferred = $q.defer(); + var bucket = new egIDL.cbreb(); + bucket.owner(egAuth.user().id()); + bucket.name(name); + bucket.description(desc || ''); + bucket.btype('staff_client'); + + egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.create', + egAuth.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 egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.update', + egAuth.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(); + + egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.flesh.authoritative', + egAuth.token(), 'biblio', id + ).then(function(bucket) { + var evt = egEvent.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(); + egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.item.delete', + egAuth.token(), 'biblio', itemId + ).then(function(resp) { + var evt = egEvent.parse(resp); + if (evt) { + console.error(evt); + deferred.reject(evt); + return; + } + 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(); + egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.full_delete', + egAuth.token(), 'biblio', id + ).then(function(resp) { + var evt = egEvent.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','egAuth','bucketSvc','egNet','egIDL', +function($scope, $location, $q, $timeout, $modal, + $window, egAuth, bucketSvc, egNet, egIDL) { + + $scope.bucketSvc = bucketSvc; + $scope.bucket = function() { return bucketSvc.currentBucket } + + // tabs: search, pending, view + $scope.setTab = function(tab) { + $scope.tab = tab; + $scope.pageList = bucketSvc[tab + 'List']; + + // for bucket selector; must be called after route resolve + bucketSvc.fetchUserBuckets(); + }; + + $scope.loadBucket = function(id) { + $location.path( + '/cat/bucket/record/' + + $scope.tab + '/' + encodeURIComponent(id)); + } + + $scope.addToBucket = function(all) { + /** TODO: open-ils.actor.container.item.create almost works + * with batches, but not quite ... */ + + var items = all ? $scope.pageList.items : + $scope.pageList.selectedItems(); + if (items.length == 0) return; + + bucketSvc.bucketNeedsRefresh = true; + + angular.forEach(items, + function(rec) { + var item = new egIDL.cbrebi(); + item.bucket(bucketSvc.currentBucket.id()); + item.target_biblio_record_entry(rec.id); + egNet.request( + 'open-ils.actor', + 'open-ils.actor.container.item.create', + egAuth.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.viewList.items.push(resp); + bucketSvc.viewList.totalCount++; + }); + } + ); + } + + + // same for all controllers + $scope.applyRowSelection = function($event, index) { + if ($event.ctrlKey || $event.metaKey) { // metaKey == mac command + $scope.pageList.toggleOneSelection(index); + } else { + $scope.pageList.selectOne(index); + } + } + + + /** ---------------- + * this will all change when we stop using rmsr's + * TODO: stop using rmsr's + */ + $scope.sort = function(field) { + $scope.pageList.offset = 0; + if (typeof $scope.pageList.sort == 'string' && + $scope.pageList.sort == field) { + // already sorting on 'field', now sort descending + $scope.pageList.sort = {}; + $scope.pageList.sort[field] = 'desc'; + } else { + $scope.pageList.sort = field; + } + } + + $scope.setupColumns = function() { + $scope.sortedFields = egIDL.classes.rmsr.fields.sort( + function(a, b) { return a.label < b.label ? -1 : 1 }); + + $scope.fields = {}; + $scope.queryFields = {}; + var cols = []; + angular.forEach($scope.sortedFields, function(field) { + if (field.virtual) return; + cols.push(field); + $scope.fields[field.name] = field; + $scope.queryFields[field.name] = field.name; + }); + + $scope.pageList.setColumns(cols); + } + + $scope.getRecords = function(ids) { + if (ids.length == 0) return $q.when(); + + $scope.pageList.totalCount = ids.length; + + // grab the lot in one go + return egNet.request( + 'open-ils.fielder', + 'open-ils.fielder.flattened_search', + egAuth.token(), "rmsr", $scope.queryFields, + {id : ids}, + { sort : [$scope.pageList.sort || 'id'], + limit : $scope.pageList.limit, + offset : $scope.pageList.offset + } + ).then( + null, // success + null, // error + function(record) { // notify handler + + // apply some data munging to make the list values + // of 'rmsr' more human friendly. + record.isbn = record.isbn.replace(/\{NULL\}/,''); + record.issn = record.issn.replace(/\{NULL\}/,''); + record.isbn = record.isbn.replace(/\{(.*)\}/,'$1'); + record.issn = record.issn.replace(/\{(.*)\}/,'$1'); + $scope.pageList.items.push(record); + } + ); + } + + $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.reset(); + bucketSvc.allBuckets = []; // reset + $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=' + egAuth.token(); + + $timeout(function() { $window.open(url) }); + }); + } +}]) + +.controller('SearchCtrl', + ['$scope','$routeParams','egAuth','egNet','egIDL','bucketSvc', +function($scope, $routeParams, egAuth, egNet, egIDL, bucketSvc) { + $scope.setTab('search'); + $scope.setupColumns(); + $scope.focusMe = true; + + // add selected items directly to the pending list + $scope.addToPending = function(all) { + var recs = all ? $scope.pageList.items : $scope.pageList.selectedItems(); + angular.forEach(recs, function(rec) { + if (bucketSvc.pendingList.items.filter( // remove dupes + function(r) {return r.id == rec.id}).length) return; + bucketSvc.pendingList.items.push(rec); + }); + } + + $scope.search = function() { + $scope.pageList.resetPageData(); + $scope.searchInProgress = true; + bucketSvc.queryRecords = []; + + egNet.request( + 'open-ils.search', + 'open-ils.search.biblio.multiclass.query', { + // full search limit needs to be larger than page list limit + limit : $scope.pageList.limit * 10, + }, bucketSvc.queryString, true + ).then(function(resp) { + $scope.searchInProgress = false; + bucketSvc.queryRecords = resp.ids.map(function(id){return id[0]}); + $scope.pageList.totalCount = bucketSvc.queryRecords.length; + $scope.getRecords(bucketSvc.queryRecords); + }); + } + + $scope.draw = function() { + $scope.pageList.resetPageData(); + $scope.getRecords(bucketSvc.queryRecords); + } + + 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) + .then(function(bucket) { + bucketSvc.viewList.totalCount = bucket.items().length; + }); + } +}]) + +.controller('PendingCtrl', + ['$scope','$routeParams','egAuth','egNet','egIDL','bucketSvc', +function($scope, $routeParams, egAuth, egNet, egIDL, bucketSvc) { + $scope.setTab('pending'); + $scope.setupColumns(); + + $scope.draw = function() { + // only called when sorting an existing list of records + var ids = $scope.pageList.items.map(function(r) {return r.id}); + $scope.pageList.resetPageData(); + $scope.getRecords(ids); + } + + 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) + .then(function(bucket) { + bucketSvc.viewList.totalCount = bucket.items().length; + }); + } +}]) + +.controller('ViewCtrl', + ['$scope','$window','$timeout','$location','$routeParams','egAuth','egNet','egIDL','bucketSvc','egEvent', +function($scope, $window, $timeout, $location, $routeParams, egAuth, egNet, egIDL, bucketSvc, egEvent) { + + $scope.setTab('view'); + $scope.setupColumns(); + + $scope.bucketId = $routeParams.id; + + // no bucket selected, clear out any cached data + if (!$scope.bucketId) { + bucketSvc.currentBucket = null; + bucketSvc.viewList.reset(); + return; + } + + $scope.detachRecords = function() { + var records = $scope.pageList.selectedItems(); + angular.forEach(records, function(rec) { + bucketSvc.detachRecord(rec.item_id).then(function(resp) { + $scope.pageList.removeItem(rec.item_id); + $scope.pageList.totalCount--; + }); + }); + } + + function getBucketRecords(recordIds) { + $scope.getRecords(recordIds).then(function() { + // link the bucket item to the record. + var matched = {}; + angular.forEach($scope.bucket().items(), function(item) { + var rec; + var rid = item.target_biblio_record_entry(); + if (matched[rid]) { + // dupe bib record. clone it into the list + rec = angular.copy(matched[rid]); + $scope.pageList.items.push(rec); + } else { + // find the record in the data we just fetched + // note: don't use getItem, since our index field is 'item_id' + rec = $scope.pageList.items.filter( + function(r) {return r.id == rid})[0]; + matched[rid] = rec; + } + // rec will be unset if the record in question is not + // visible in this page of data. + if (rec) rec.item_id = item.id(); + }); + }); + } + + // fetch the bucket and linked records as needed to + // populate the page list. + $scope.draw = function() { + $scope.pageList.resetPageData(); + bucketSvc.fetchBucket($scope.bucketId).then( + function(bucket) { + ids = bucketSvc.currentBucket.items().map( + function(i){return i.target_biblio_record_entry()} + ); + getBucketRecords(ids); + }, + function(evt) { $scope.forbidden = true } + ); + }; + + // avoid re-fetching the records for a bucket if the bucket + // is already loaded and we are navigating back to the + // view tab. + if (bucketSvc.bucketRefreshLevel($scope.bucketId) == 1 || + bucketSvc.viewList.count() == 0 ) { + $scope.draw(); + } +}]) 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 index 0000000000..3a68131204 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/circ/patron/app.js @@ -0,0 +1,768 @@ +/* + * TODO: + * when this file starts getting too large and we want to add code for + * UIs that are not typically rendered (i.e. we don't necessarily want + * to fetch the code on every page load), we can create tab-specific + * controllers which live in separate JS files which are only fetched + * when the related tab template is fetched. + */ + +angular.module('egPatronApp', ['ngRoute', 'ui.bootstrap', + 'egCoreMod', 'egUiMod', 'egListMod', 'egUserMod']) + +.config(function($routeProvider, $locationProvider) { + $locationProvider.html5Mode(true); + + // 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 : + // TODO: $inject array + function(egAuth, egUser, egNet, egEnv, egPCRUD, egStartup, egOrg) { + + // load needed org unit settings and munge the data into + // key/value pairs for ease of use. + egEnv.classLoaders.aous = function() { + return egNet.request( + 'open-ils.actor', + 'open-ils.actor.ou_setting.ancestor_default.batch', + egAuth.user().ws_ou(), + ['circ.obscure_dob'], + egAuth.token() + ).then(function(blob) { + var settings = {}; + angular.forEach(blob, function(val, key) { + if (val) { settings[key] = val.value } + }); + egEnv.aous = settings; + }); + } + + egEnv.loadClasses.push('aous'); + + // app-globally modify the default flesh fields for + // fleshed user retrieval + egUser.defaultFleshFields.push('profile'); + egUser.defaultFleshFields.push('net_access_level'); + egUser.defaultFleshFields.push('ident_type'); + egUser.defaultFleshFields.push('ident_type2'); + + return egStartup.go() + }}; + + $routeProvider.when('/circ/patron/search', { + templateUrl: './circ/patron/t_search', + controller: 'PatronSearchCtrl', + 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/bills', { + templateUrl: './circ/patron/t_bills', + controller: 'PatronBillsCtrl', + 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 + }); + + + // default page / bucket view + $routeProvider.otherwise({redirectTo : '/circ/patron/search'}); +}) + +/** + * Patron service + */ +.factory('patronSvc', + ['$q','egList','egNet','egAuth','egUser','egEnv','egOrg','egList', +function($q, egList, egNet, egAuth, egUser, egEnv, egOrg, egList) { + + var service = { + // 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. + checkout_overrides : {}, + + // keep a cache of the patron search results + patrons : egList.create({indexFieldAsFunction : true}), // patron.id() + checkouts : egList.create(), + items_out : egList.create({indexFieldAsFunction : true}), // circ.id() + holds : egList.create(), + bills : egList.create(), + messages : egList.create() + }; + + // when we change the default patron, we need to clear out any + // data collected on that patron + service.resetPatronLists = function() { + service.checkouts.reset(); + service.items_out.reset(); + service.holds.reset(); + service.bills.reset(); + service.messages.reset(); + service.checkout_overrides = {}; + } + + // sets the default user, fetching as necessary + service.setDefault = function(id, user, force) { + if (user) { + if (!force && service.current && + service.current.id() == user.id()) return; + + service.resetPatronLists(); + service.current = user; + service.localFlesh(user); + service.fetchUserStats(); + + } else if (id) { + if (!force && service.current && + service.current.id() == id) return; + service.resetPatronLists(); + + egUser.get(id).then( + function(user) { + service.current = user; + service.localFlesh(user); + service.fetchUserStats(); + }, + function(err) { + console.error( + "unable to fetch user "+id+': '+js2JSON(err)) + } + ); + } + } + + // flesh some additional user fields locally + service.localFlesh = function(user) { + if (typeof user.home_ou() != 'object') + user.home_ou(egOrg.get(user.home_ou())); + angular.forEach( + user.standing_penalties(), + function(penalty) { + if (typeof penalty.org_unit() != 'object') + penalty.org_unit(egOrg.get(penalty.org_unit())); + } + ); + } + + // grab additional circ info + service.fetchUserStats = function() { + egNet.request( + 'open-ils.actor', + 'open-ils.actor.user.opac.vital_stats', + egAuth.token(), service.current.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); + service.patron_stats = stats + } + ) + } + + return service; +}]) + +/** + * Manages tabbed patron view + * */ +.controller('PatronCtrl', + ['$scope','$q','$filter','egNet','egAuth','egUser','patronSvc','egEnv','egIDL', +function($scope, $q, $filter, egNet, egAuth, egUser, patronSvc, egEnv, egIDL) { + + // 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 = egEnv.aous; + if (patron_id) { + $scope.patron_id = patron_id + patronSvc.setDefault($scope.patron_id); + } + } + + $scope.patron = function() { return patronSvc.current } + $scope.patron_stats = function() { return patronSvc.patron_stats } +}]) + + +/** + * Manages patron search + */ +.controller('PatronSearchCtrl', + ['$scope','$q','$routeParams','$timeout','$window','$location', + '$filter','egIDL','egNet','egAuth','egEvent','egList','egUser','patronSvc', +function($scope, $q, $routeParams, $timeout, $window, $location, + $filter, egIDL, egNet, egAuth, egEvent, egList, egUser, patronSvc) { + + $scope.initTab('search'); + $scope.focusMe = true; + $scope.patrons = patronSvc.patrons; + + + // TODO: experiment + // if this is useful, it should be moved into a service. + $scope.tips = { + dismiss : function(tip) { + $window.localStorage.setItem('eg.tips.' + tip, 1); + }, + dismissed : function(tip) { + return $window.localStorage.getItem('eg.tips.' + tip); + } + // TODO: function to reset all tips + }; + + // map form arguments into search params + function compileSearch(args) { + var search = {}; + angular.forEach(args, function(val, key) { + if (!val) return; + 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; + } + + // send compiled search; get user IDs + function sendSearch(search) { + search = compileSearch(search); + egNet.request( + 'open-ils.actor', + 'open-ils.actor.patron.search.advanced', + egAuth.token(), search, 100 /* limit */, + [ /* sort */ + "family_name ASC", + "first_given_name ASC", + "second_given_name ASC", + "dob DESC" + ], + null, /* TODO: OU filter */ + search.inactive + + ).then(function(ids) { + retrieveUsers(ids); + }); + }; + + // fetch users by id and add them to the patrons list + function retrieveUsers(ids) { + angular.forEach(ids, function(id, idx) { + // capture idx to maintain search results order + egUser.get(id).then(function(user) { + $scope.patrons.items[idx] = user; + }); + }); + } + + // collect form args fire patron search + $scope.search = function(args) { + if (args && Object.keys(args).length) { + $scope.patrons.reset(); + if (args.id) { + retrieveUsers([args.id]); + } else { + sendSearch(args); + } + } + } + + // manage table row selection + $scope.onPatronClick = function($event, user) { + $scope.lastSelected = user; + + // control-click / command-click (mac) selects + // or deselects a row without altering other rows + if ($event.ctrlKey || $event.metaKey) { + $scope.patrons.toggleOneSelection(user.id()); + + // middle-click opens new tab for the patron + } else if ($event.which == 2) { + + var url = $location.absUrl().replace( + /patron\/search.*$/, + 'patron/' + user.id() + '/checkout' + ); + $window.open(url); + + } else { + // vanilla click selects the patron as the current default + $scope.patrons.selectOne(user.id()); + patronSvc.setDefault(null, user); + } + } + + $scope.onPatronDblClick = function($event, user) { + $location.path('/circ/patron/' + user.id() + '/checkout'); + } + + // opens a new tab for each selected user at /checkout + // TODO: Chrome will only open one tab per user action (click, + // etc.). subsequent tabs open new windows (blocked by default). + // The only way around this I'm seeing is to use a chrome extension + // http://stackoverflow.com/questions/16749907/window-open-behaviour-in-chrome-tabs-windows + // for now, skip this feature and support control-click to open + // multiple patrons instead. + $scope.openSelectedPatrons = function() { + angular.forEach( + $scope.patrons.selectedItems(), + function(patron) { + var url = $location.absUrl(); + url = url.replace(/patron\/search.*$/, + 'patron/' + patron.id() + '/checkout'); + $window.open(url); + } + ); + } + + // handled up/down arrow events while the patrons results table is focused. + // disabled for now, since there are some UI issues to work out first: + // 1. up/down while a browser scroll bar is visible causes the browser to + // scroll, which makes sense, but is a little jarring. An overflow/scroll + // container would be better -- requires a non-table solution.. TODO + // 2. if table hover Bootstrap css is used, even though the currently + // selected row changes with arrow up/down, the mouse continues to + // hover in its original position, making the hovered row appear to be + // selected (style-wise) even when it's not. Disabling table-hover + // CSS works, but table-hover is useful, so... + $scope.navigateResults = function($event) { + // we can't select the next/previous user if we don't know + // which user was selected last. this should never happen, though. + if (!$scope.lastSelected) return; + + var user; + if ($event.which == 40) { // down arrow + angular.forEach( + $scope.patrons.items, + function(item, idx) { + if (item.id() == $scope.lastSelected.id()) + user = $scope.patrons.items[idx+1]; + } + ) + } else if ($event.which == 38) { // up arrow + angular.forEach( + $scope.patrons.items, + function(item, idx) { + if (item.id() == $scope.lastSelected.id()) + user = $scope.patrons.items[idx-1]; + } + ) + } + + if (user) $scope.onPatronClick($event, user); + } + +}]) + +/** * Manages patron summary view + */ +.controller('PatronSummaryCtrl', + ['$scope','$q','egNet','egAuth','egEvent','patronSvc', +function($scope, $q, egNet, egAuth, egEvent, patronSvc) { + // may not need this ctrl at all, since all data + // come directly from the scope +}]) + +/** + * Manages checkout + */ +.controller('PatronCheckoutCtrl', + ['$scope','$q','$modal','$routeParams','egNet','egAuth','egUser','patronSvc','egEnv','egPCRUD','egOrg', +function($scope, $q, $modal, $routeParams, egNet, egAuth, egUser, patronSvc, egEnv, egPCRUD, egOrg) { + $scope.initTab('checkout', $routeParams.id); + + $scope.focusMe = true; + $scope.checkouts = patronSvc.checkouts; + $scope.checkoutArgs = {type : 'barcode'}; + + if (egEnv.cnct) { + $scope.nonCatTypes = egEnv.cnct.list; + } else { + egPCRUD.search('cnct', + {owning_lib : egOrg.fullPath(egAuth.user().ws_ou(), true)}, + null, {atomic : true} + ).then(function(list) { + egEnv.absorbList(list, 'cnct'); + $scope.nonCatTypes = list + }); + } + + egPCRUD.retrieveAll('ccm', null, {atomic : true}).then( + function(list) { $scope.circModifiers = list }); + + // TODO: apply correct response order + $scope.checkout = function(args) { + var type = args.type; + var coArgs = angular.copy(args); + + args.copy_barcode = ''; // reset for UI + delete coArgs.type; // not a valid API arg + + if (type == 'barcode') { + performCheckout(coArgs); + } else { + // noncat checkout + } + + $scope.focusMe; // return focus to barcode input + } + + var index = 0; + function performCheckout(args, override) { + console.debug('checkout: ' + js2JSON(args)); + + var method = 'open-ils.circ.checkout.full'; + if (override) method += '.override'; + + args.patron_id = $scope.patron_id; + + egNet.request( + 'open-ils.circ', method, egAuth.token(), args + ).then(function(evt) { + + if (!evt) { + console.error('no checkout response received'); + return; + } + + // TODO: how best to handle multiple response events? + if (angular.isArray(evt)) evt = evt[0]; + evt.id = index++; + evt.copy_barcode = args.copy_barcode; + handleCheckoutResponse(evt, args, override) + }); + } + + function handleCheckoutResponse(evt, args, override) { + + if (args.precat && evt.payload) { + evt.payload.record = { + title : args.dummy_title, + author : args.dummy_author, + isbn : args.dummy_isbn + } + } + + switch (evt.textcode) { + case 'SUCCESS': + // keep the global patron object in sync with reality + $scope.checkouts.items.push(evt); + patronSvc.patron_stats.checkouts.out++; + patronSvc.refreshItemsOut = true; + break; + + case 'ITEM_NOT_CATALOGED': + openPrecatDialog(evt.copy_barcode); + break; + + case 'PATRON_EXCEEDS_FINES': + if (!override) { + if (patronSvc.checkout_overrides[evt.textcode]) { + performCheckout(args, true); + } else { + openOverrideConfirmDialog(evt, args); + } + } + break; + + /* stuff to consider + PERM_FAILURE + PATRON_EXCEEDS_OVERDUE_COUNT + PATRON_BARRED + CIRC_EXCEEDS_COPY_RANGE + PATRON_ACCOUNT_EXPIRED + ITEM_DEPOSIT_REQUIRED + ITEM_RENTAL_FEE_REQUIRED + ITEM_DEPOSIT_PAID + PATRON_EXCEEDS_LOST_COUNT + ACTION_CIRCULATION_NOT_FOUND + PATRON_EXCEEDS_CHECKOUT_COUNT + COPY_CIRC_NOT_ALLOWED + COPY_NOT_AVAILABLE + COPY_IS_REFERENCE + COPY_NEEDED_FOR_HOLD + MAX_RENEWALS_REACHED + CIRC_CLAIMS_RETURNED + COPY_ALERT_MESSAGE + PATRON_EXCEEDS_FINES + */ + + default: + console.warn('unhandled circ response : ' + evt.textcode); + // push it on the list so the user can at least see + // something happened. + $scope.checkouts.items.push(evt); + + } + } + + // define our modal dialogs + + function openPrecatDialog(copy_barcode) { + $modal.open({ + templateUrl: './circ/patron/t_precat_dialog', + controller: + ['$scope', '$modalInstance', 'circMods', + function($scope, $modalInstance, circMods) { + $scope.focusMe = true; + $scope.precatArgs = { + copy_barcode : copy_barcode, + circ_modifier : circMods.length ? circMods[0].code() : '' + }; + $scope.circModifiers = circMods; + $scope.ok = function(args) { $modalInstance.close(args) } + $scope.cancel = function () { $modalInstance.dismiss() } + }], + // pass the circ mod list into the modal environment + // the angular way. + resolve : { + circMods : function() { return $scope.circModifiers } + } + }).result.then( + function(args) { + $scope.focusMe = true; // main barcode input + if (!args || !args.dummy_title) return; + args.precat = true; + performCheckout(args); + }, + function() { + // dialog was closed without action + $scope.focusMe = true; + } + ); + } + + function openOverrideConfirmDialog(evt, args) { + $modal.open({ + templateUrl: './circ/patron/t_event_override_dialog', + controller: + ['$scope', '$modalInstance', + function($scope, $modalInstance) { + $scope.evt = evt; + $scope.ok = function() { $modalInstance.close() } + $scope.cancel = function () { $modalInstance.dismiss() } + }] + }).result.then( + function() { + $scope.focusMe = true; // main barcode input + patronSvc.checkout_overrides[evt.textcode] = true; + performCheckout(args, true); + }, + function() { + // dialog was closed without action + $scope.focusMe = true; + } + ); + } + + +}]) + +/** + * Manages checkout + */ +.controller('PatronItemsOutCtrl', + ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc','egPCRUD','egOrg', +function($scope, $q, $routeParams, egNet, egAuth, egUser, patronSvc, egPCRUD, egOrg) { + $scope.initTab('items_out', $routeParams.id); + $scope.items_out = patronSvc.items_out; + + $scope.onRowClick = function($event, circ) { + $scope.lastSelected = circ; + // control-click / command-click (mac) selects + // or deselects a row without altering other rows + if ($event.ctrlKey || $event.metaKey) { + $scope.items_out.toggleOneSelection(circ.id()); + } else { + $scope.items_out.selectOne(circ.id()); + } + } + + function fetchItemsOut() { + var newlist = []; + egNet.request('open-ils.actor', + 'open-ils.actor.user.checked_out.authoritative', + egAuth.token(), $scope.patron_id) + .then(function(outs) { + + // put them into a list so we can keep track of the + // default display order + newlist = newlist.concat(outs.out) + .concat(outs.overdue) + .concat(outs.long_overdue) + .concat(outs.lost) + .concat(outs.claims_returned) + + // TODO: Websockets means 1 streaming request instead of + // multiple singles. As is, one response may be too large + // to wait on. + angular.forEach(newlist, function(id) { + + egPCRUD.retrieve('circ', id, { + 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'] } + }).then(function(circ) { + + // local fleshing + circ.circ_lib(egOrg.get(circ.circ_lib())); + + 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()} + }) + } + + angular.forEach(newlist, function(id, idx) { + // insert into the result list in the same + // order as above + if (id == circ.id()) + patronSvc.items_out.items[idx] = circ; + }); + }); + }) + }) + } + + fetchItemsOut(); // TODO: only when necessary +}]) + +/** + * Manages holds + */ +.controller('PatronHoldsCtrl', + ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc','egOrg', +function($scope, $q, $routeParams, egNet, egAuth, egUser, patronSvc, egOrg) { + $scope.initTab('holds', $routeParams.id); + + $scope.holds = patronSvc.holds; + + $scope.onRowClick = function($event, hold) { + $scope.lastSelected = hold; + if ($event.ctrlKey || $event.metaKey) { + $scope.holds.toggleOneSelection(hold.id); + } else { + $scope.holds.selectOne(hold.id); + } + } + + function fetchPatronHolds() { + + egNet.request( + 'open-ils.circ', + 'open-ils.circ.holds.id_list.retrieve.authoritative', + egAuth.token(), $scope.patron_id + + ).then(function(hold_ids) { + angular.forEach(hold_ids, function(id) { + + egNet.request( + 'open-ils.circ', + 'open-ils.circ.hold.details.retrieve.authoritative', + egAuth.token(), id + + ).then(function(hold_data) { + var hold = hold_data.hold; + hold_data.id = hold.id(); + + // flesh + hold.pickup_lib(egOrg.get(hold.pickup_lib())); + + angular.forEach(hold_ids, function(id, idx) { + if (id == hold.id()) // maintain order + patronSvc.holds.items[idx] = hold_data; + }); + }); + }) + }) + } + + fetchPatronHolds(); // TODO: only when necessary + +}]) + +/** + * Manages bills + */ +.controller('PatronBillsCtrl', + ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc', +function($scope, $q, $routeParams, egNet, egAuth, egUser, patronSvc) { + $scope.initTab('bills', $routeParams.id); +}]) + +/** + * Manages messages + */ +.controller('PatronMessagesCtrl', + ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc', +function($scope, $q, $routeParams, egNet, egAuth, egUser, patronSvc) { + $scope.initTab('messages', $routeParams.id); +}]) + +/** + * Manages edit + */ +.controller('PatronEditCtrl', + ['$scope','$q','$routeParams','egNet','egAuth','egUser','patronSvc', +function($scope, $q, $routeParams, egNet, egAuth, egUser, patronSvc) { + $scope.initTab('edit', $routeParams.id); +}]) + diff --git a/Open-ILS/web/js/ui/default/staff/navbar.js b/Open-ILS/web/js/ui/default/staff/navbar.js new file mode 100644 index 0000000000..7e53529a82 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/navbar.js @@ -0,0 +1,45 @@ +/** + * Free-floating controller which can be used by any app. + */ +function NavCtrl($scope, egStartup, egAuth, egEnv) { + + // tied to logout link + $scope.logout = function() { + egAuth.logout(); + return true; + }; + + /** + * Two important things happening here. + * + * 1. Since this is a standalone controller, which may execute at + * any time during page load, we have no gaurantee that needed + * startup actions, session retrieval being the main one, have taken + * place yet. So we kick off the startup chain ourselves and run + * actions when it's done. Note this does not mean startup runs + * multiple times. If it's already started, we just pick up the + * existing startup promise. + * + * 2. We are updating the $scope asynchronously, but since it's + * done inside a promise resolver, another $digest() loop will + * run and pick up our changes. No $scope.$apply() needed. + */ + egStartup.go().then( + function() { + + // login page will not have a cached user + if (!egAuth.user()) return; + + $scope.username = egAuth.user().usrname(); + + // TODO: move workstation into egAuth + if (egEnv.aws) { + $scope.workstation = + egEnv.aws.map[egAuth.user().wsid()].name(); + } + } + ); +} + +// minify-safe dependency injection +NavCtrl.$inject = ['$scope', 'egStartup', 'egAuth', 'egEnv']; 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 index 0000000000..3c4ad88065 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/auth.js @@ -0,0 +1,104 @@ +/* Core Sevice - egAuth + * + * Manages login and auth session retrieval + * + * Angular cookies are still fairly primitive. + * In particular, you can't set the path. + * https://github.com/angular/angular.js/issues/1786 + */ + +angular.module('egCoreMod') + +.constant('EG_AUTH_COOKIE', 'ses') + +.factory('egAuth', + ['$q','$cookies','egNet','EG_AUTH_COOKIE', +function($q, $cookies, egNet, EG_AUTH_COOKIE) { + + var service = { + // expose user and token via function, since we will eventually + // want to support multiple active logins, in which case user() + // and token() will return data for the currently active login. + user : function() { + return this._user; + }, + token : function() { + return $cookies[EG_AUTH_COOKIE]; + } + }; + + /* 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) { + service._user = user; + deferred.resolve(); + } else { + delete $cookies[EG_AUTH_COOKIE]; + deferred.reject(); + } + } + ); + + } else { + 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') { + $cookies[EG_AUTH_COOKIE] = evt.payload.authtoken; + 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; + }; + + service.logout = function() { + if (service.token()) { + egNet.request( + 'open-ils.auth', + 'open-ils.auth.session.delete', + service.token()); // fire and forget + delete $cookies[EG_AUTH_COOKIE]; + } + service._user = null; + }; + + 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 index 0000000000..d862450902 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/core.js @@ -0,0 +1,6 @@ + +/** + * egCoreMod houses all of the services, etc. required by all pages + * for basic functionality. + */ +angular.module('egCoreMod', ['ngCookies']); 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 index 0000000000..0e1f2b7de1 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/env.js @@ -0,0 +1,124 @@ +/** + * Core Service - egEnv + * + * Manages startup data loading. All registered loaders run + * simultaneously. When all promises are resolved, the promise + * returned by egEnv.load() is resolved. + * + * 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','egAuth','egPCRUD','egIDL', +function($q, egAuth, egPCRUD, egIDL) { + + var service = { + // collection of custom loader functions + loaders : [] + }; + + /* 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 = {list : list, map : {}}; + var pkey = egIDL.classes[class_].pkey; + 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', 'aws']; + + /* + * 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() { + return egPCRUD.search('aou', {parent_ou : null}, + {flesh : -1, flesh_fields : {aou : ['children', 'ou_type']}} + ).then( + function(tree) {service.absorbTree(tree, 'aou')} + ); + }, + aws : function() { + // by default, load only the workstation for the authenticated + // user. to load all workstations, override this loader. + // TODO: auth.session.retrieve should be capable of returning + // the session with the workstation fleshed. + if (!egAuth.user().wsid()) { + // nothing to fetch. + return $q.when(); + } + return egPCRUD.retrieve('aws', egAuth.user().wsid()) + .then(function(ws) {service.absorbList([ws], 'aws')}); + } + }; + + 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 index 0000000000..ccec585c38 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/event.js @@ -0,0 +1,55 @@ +/** + * 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.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/flattener.js b/Open-ILS/web/js/ui/default/staff/services/flattener.js new file mode 100644 index 0000000000..2be914021e --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/flattener.js @@ -0,0 +1,51 @@ +/** + * Service for communicating with the Evergreen flattener + * web service. + * + * egFlattener.load({ + * hint : aou, + * map : {shortname : shortname, parent_ou : parent_ou.shortname}, + * where : {id : {'<>' : null}} + * slo : {offset : 0, limit : 20, order_by : ['shortname']} + * }).then( function(data) { console.log(data) } ); + */ +angular.module('egFlattenerMod', ['egCoreMod']) + +.factory('egFlattener', + ['$q','$http','egAuth', +function($q, $http, egAuth) { + + var url = '/opac/extras/flattener'; + + return { + load : function(args) { + args.ses = egAuth.token(); + args.format = args.format || 'application/json'; + + angular.forEach(['map', 'where', 'slo'], function(key) { + args[key] = js2JSON(args[key]); + }); + + /** angular $http uses content type application/json natively. + * flattener / mod_perl (?) does not extract that data, so + * we have to encode it ourselves as x-www-form-urlencoded + * http://victorblog.com/2012/12/20/make-angularjs-http-service-behave-like-jquery-ajax/ + */ + var query = 'ses=' + args.ses; + angular.forEach(args, function(val, key) { + if (key == 'ses' || !val) return; + query += '&' + key + '=' + encodeURIComponent(val); + }); + + return $http({ + url : url, + data : query, + method : 'POST', + headers : { + 'Content-Type': + 'application/x-www-form-urlencoded; charset=UTF-8' + } + }); + } + }; +}]) 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 index 0000000000..3d8892412b --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/idl.js @@ -0,0 +1,62 @@ +/** + * 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; + + // original, global reference no longer needed + $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); + }; + + return service; +}]); + diff --git a/Open-ILS/web/js/ui/default/staff/services/list.js b/Open-ILS/web/js/ui/default/staff/services/list.js new file mode 100644 index 0000000000..9f28fb90aa --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/list.js @@ -0,0 +1,300 @@ +/** + * Service for generating list management objects. + * Each object tracks common list attributes like limit, offset, etc., + * A ListManager is not responsible for collecting data, it's only + * there to allow controllers to have a known consistent API + * for manage list-related information. + * + * The service exports a single attribute, which instantiates + * a new ListManager object. Controllers using ListManagers + * are responsible for providing their own route persistence. + * + * var list = egList.create(); + * if (list.hasNextPage()) { ... } + * + */ + +angular.module('egListMod', ['egCoreMod']) + +.factory('egList', ['$filter', 'egIDL', function($filter, egIDL) { + + function ListManager(args) { + var self = this; + this.limit = 25; + this.offset = 0; + this.sort = null; + this.totalCount = 0; + + // attribute on each item in our items list which + // refers to its unique identifier value + this.indexField = 'id'; + + // true if the index field name refers to a + // function instead of an object attribute + this.indexFieldAsFunction = false; + + // per-page list of items + this.items = []; + + // collect any defaults passed in + if (args) angular.forEach(args, + function(val, key) {self[key] = val}); + + // sorted list of all available display columns + // a column takes form of (at minimum) {name : name, label : label} + this.allColumns = []; + + // {name => true} map of visible columns + this.displayColumns = {}; + + // {index => true} map of selected rows + this.selected = {}; + + this.indexValue = function(item) { + if (this.indexFieldAsFunction) { + return item[this.indexField](); + } else { + return item[this.indexField]; + } + } + + // returns item objects + this.selectedItems = function() { + var items = []; + angular.forEach( + this.items, + function(item) { + if (self.selected[self.indexValue(item)]) + items.push(item); + } + ); + return items; + } + + // remove an item from the items list and return the deleted item + this.removeItem = function(index) { + var deleted; + angular.forEach(this.items, function(item, idx) { + if (self.indexValue(item) == index) { + self.items.splice(idx, 1); + deleted = item; + } + }); + delete this.selected[index]; + return deleted; + } + + // get item by index value + this.getItem = function(index) { + return this.items.filter( + function(item) { return self.indexValue(item) == index } + )[0]; + } + + this.count = function() { return this.items.length } + + this.reset = function() { + this.offset = 0; + this.totalCount = 0; + this.items = []; + this.selected = {}; + } + + // prepare to draw a new page of data + this.resetPageData = function() { + this.items = []; + this.selected = {}; + } + + this.showAllColumns = function() { + angular.forEach(this.allColumns, function(field) { + self.displayColumns[field.name] = true; + }); + } + + this.hideAllColumns = function() { + angular.forEach(this.allColumns, function(field) { + delete self.displayColumns[field.name] + }); + } + + // selects one row after deselecting all of the others + this.selectOne = function(index) { + this.deselectAll(); + this.selected[index] = true; + } + + // selects or deselects a row, without affecting the others + this.toggleOneSelection = function(index) { + if (this.selected[index]) { + delete this.selected[index]; + } else { + this.selected[index] = true; + } + } + + // selects all visible rows + this.selectAll = function() { + angular.forEach(this.items, function(item) { + self.selected[self.indexValue(item)] = true + }); + } + + // if all are selected, deselect all, otherwise select all + this.toggleSelectAll = function() { + if (Object.keys(this.selected).length == this.items.length) { + this.deselectAll(); + } else { + this.selectAll(); + } + } + + // deselects all visible rows + this.deselectAll = function() { + this.selected = {}; + } + + this.addColumn = function(col) { + this.allColumns.push(col); + if (col.display) + this.displayColumns[col.name] = true; + } + + this.defaultColumns = function(list) { + // set the display=true value for the selected columns + angular.forEach(list, function(name) { + self.displayColumns[name] = true + }); + + // default columns may be provided before we + // know what our columns are. Save them for later. + this._defaultColumns = list; + + // setColumns we rearrange the allCollums + // list based on the content of this._defaultColums + if (this.allColumns.length) + this.setColumns(this.allColumns); + } + + this.setColumns = function(list) { + if (this._defaultColumns) { + this.allColumns = []; + + // append the default columns to the front of + // our allColumnst list. Any remaining columns + // are plopped onto the end. + angular.forEach( + this._defaultColumns, + function(name) { + var foundIndex; + angular.forEach(list, function(f, idx) { + if (f.name == name) { + self.allColumns.push(f); + foundIndex = idx; + } + }); + list.splice(foundIndex, 1); + } + ); + this.allColumns = this.allColumns.concat(list); + delete this._defaultColumns; + + } else { + this.allColumns = list; + angular.forEach(this.allColumns, function(col) { + if (col.display) + self.displayColumns[col.name] = true; + }); + } + } + + this.onFirstPage = function() { + return this.offset == 0; + } + + this.hasNextPage = function() { + // we have less data than requested, there must + // not be any more pages + if (this.items.length < this.limit) return false; + + // if the total count is not known, assume that a full + // page of data implies more pages are available. + if (!this.totalCount) return true; + + // we have a full page of data, but is there more? + return this.totalCount > (this.offset + this.items.length); + } + + this.incrementPage = function() { + this.offset += this.limit; + } + + this.decrementPage = function() { + if (this.offset < this.limit) { + this.offset = 0; + } else { + this.offset -= this.limit; + } + } + + // 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. + // TODO: support modifying field values -- + // useful for inline table editing + this.fieldValue = function(obj, dotpath) { + if (!obj) return ''; + if (!dotpath) return obj; + + var idlField; + var parts = dotpath.split('.'); + var cls, clsobj; + + angular.forEach(parts, function(step, idx) { + + if (!obj || typeof obj != 'object') { + // there are valid reasons for paths to be cut + // short, e.g. when data sets have varying contents. + return obj; + } + + cls = obj.classname; + if (cls && (clsobj = egIDL.classes[cls])) { + idlField = clsobj.fields.filter( + function(f) { return f.name == step })[0]; + obj = obj[step](); + } else { + if (typeof obj[step] == 'function') { + obj = obj[step](); + } else { + obj = obj[step]; + } + } + }); + + if (obj === null || obj === undefined || obj === '') + return ''; + + if (!idlField) return obj; + + switch(idlField.datatype) { + case 'timestamp': + return $filter('date')(obj, 'shortDate'); + case 'bool': + // let the browser translate true / false for us + return Boolean(value == 't'); + default: + return obj; + } + } + } + + return { + create : function(args) { + return new ListManager(args) + } + }; +}]); + 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 index 0000000000..e78cab200d --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/net.js @@ -0,0 +1,86 @@ +/** + * 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') { + console.log('BROADCASTING'); + $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); + } + }).send(); + + return deferred.promise; + } + + 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 index 0000000000..c4071fcb43 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/org.js @@ -0,0 +1,61 @@ +/** + * Core Service - egOrg + * + * TODO: more docs + */ +angular.module('egCoreMod') + +.factory('egOrg', ['egEnv', 'egAuth', 'egPCRUD', +function(egEnv, egAuth, egPCRUD) { + + var service = {}; + + 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; + }; + + // 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; + } + + 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 index 0000000000..7caec9ae35 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/pcrud.js @@ -0,0 +1,290 @@ +/** + * 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) { + * ctx.retrieve('aou', 1).then( + * function(org) { + * console.log(org.id()); + * ctx.disconnect(); + * } + * ) + * } + * ); + */ +angular.module('egCoreMod') + +// env fetcher +.factory('egPCRUD', ['$q', 'egAuth', 'egIDL', function($q, 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); + }, + onerror : function(e) { + self.err(method + " failed " + e); + deferred.reject(e); + } + }).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/startup.js b/Open-ILS/web/js/ui/default/staff/services/startup.js new file mode 100644 index 0000000000..644598c681 --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/startup.js @@ -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 (this.promise) { + // startup already started, return our existing promise + return this.promise; + } + + // create a new promise and fire off startup + var deferred = $q.defer(); + this.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 this.promise; + } + + return service; +}]); + 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 index 0000000000..786750f44f --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/ui.js @@ -0,0 +1,56 @@ +/** + * UI tools and directives. + */ +angular.module('egUiMod', []) + + +/** + * + * $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)); + }) + } + }; +}]) + +// +// $scope.iWantToBeSelected = true; +.directive('selectMe', +['$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].select()}); + }); + element.bind('blur', function() { + scope.$apply(model.assign(scope, false)); + }) + } + }; +}]) + + +// 'reverse' filter +//
{{item.name}}
+// 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(); + }; +}) 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 index 0000000000..515b81c3fb --- /dev/null +++ b/Open-ILS/web/js/ui/default/staff/services/user.js @@ -0,0 +1,93 @@ +/** + * Service for fetching fleshed user objects. + * The last user retrieved is kept until replaced by a new user. + */ + +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; + }; + + /* + * 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) { + 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)); + }); + answer[perm] = all; + }); + if (!isArray) answer = answer[permList[0]]; + deferred.resolve(answer); + }); + return deferred.promise; + }; + + return service; +}]); + diff --git a/web-staff-log.txt b/web-staff-log.txt new file mode 100644 index 0000000000..dc8fb498fa --- /dev/null +++ b/web-staff-log.txt @@ -0,0 +1,260 @@ +Browser-Based Staff Client Development Log +========================================== + +2013-11-20 Templates and Apache +------------------------------- + +When a path is requested from the server, e.g. +/eg/staff/circ/patron/search, there are 2 different aspects of the file +that are of intest to us: the content of the HTML file and the path used +to retrieve the file. + +Page Content +~~~~~~~~~~~~ + +For Angular apps, the HTML page only needs to provide enough information +for Angular to load the correct application -- a seed document. A seed +document might look something like this: + +[source,html] +----------------------------------------------------------------------------- + + + + Evergreen Staff Patron + + + + + +
+ + + +----------------------------------------------------------------------------- + +Note the body is a single div with an 'ng-view' tag. + +Building Pages from Seeds +~~~~~~~~~~~~~~~~~~~~~~~~~ + +With the above document we know the Angular application (egPatronApp), +we have an Angular Controller (PatronCtrl -- not strictly required +here). If we assume this page was fetched using the path +'/eg/staff/circ/patron/search', then we have all we need to build the +real page. + +The Angular App will contain a series of app-specific routes based on +the URL path. Here, our (relative) path will be '/circ/patron/search', +since the base path of the application is '/eg/staff/'. For our route +configuration we have a chunk of code like this: + +[source,js] +----------------------------------------------------------------------------- +$routeProvider.when('/circ/patron/search', { + templateUrl: './circ/patron/t_search', + controller: 'PatronSearchCtrl', + resolve : resolver // more on resolvers later... +}); +----------------------------------------------------------------------------- + +When the browser lands on '/eg/staff/circ/patron/search', Angular will +locate the template file at './circ/patron/t_search' (the app-relative +path to a Template Toolkit template), by performing an HTTP request and, +once fetched, will drive the display of the Angular template within with +the JS controller called PatronSearchCtrl and insert the content of the +template into the body of the page at the location of the
+div. + +**** +'Note': For speed, it's sometimes better to include Angular templates +directly in the delivered document so that one less HTTP request is +needed. More on that later. +**** + +Fetching the Same Page at Different URLs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The egPatronApp might support a variety of different route-specific +interfaces that are all driven by the same seed document. This means +we have to tell Apache to always deliver the same file when we access +files within a given range of URL paths. The secret to this is in +Apache Rewrite configuration. + +For example: + +[source,conf] +----------------------------------------------------------------------------- + + Options -MultiViews + RewriteEngine On + RewriteCond %{PATH_INFO} !/staff/circ/patron/index + RewriteCond %{PATH_INFO} !/staff/circ/patron/t_* + RewriteRule .* /eg/staff/index [L,DPI] + +----------------------------------------------------------------------------- + +In short, any URL path that does not map to the index file or to a file +whose name starts with "t_" (more on 't_' below) will result in Apache +rewriting the request to deliver the index file (i.e. our seed +document). + +So, in our example, a request for '/eg/staff/circ/patron/search', will return +the index file found at '/eg/staff/circ/patron/index', which maps on the +server side to the Template Toolkit file at +'/path/to/templates/staff/circ/patron/index.tt2'. + +Two complications arise from this approach. Help appreciated! +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Simpler rewrite rules exist in the wild... +++++++++++++++++++++++++++++++++++++++++++ + +But, they do not take into account that we are fetching Template +Toolkit-generated files instead of vanilla HTML. The rules I found +online take the form of "if it's not a real file, return the index", but +none of the files we fetch are real files, since they are internally +mapped to Template Toolkit files. This is why I'm using the 't_' +prefix. It makes the mapping trivial. I'm all ears for better solution. + +Configuration Explosion ++++++++++++++++++++++++ + +This I see as a real problem, but one that certainly has a solution. +The configuration chunk above is such that we need a new chunk for each +top-level Angular app. This will quickly get out of hand. A single, +dynamic configuration that can map elemenents of arbitrarily-nested +paths (or possibly a small set with predefined path depths) to the +correct index file would be ideal. + + +2013-11-21 Angular $scope inheritance +------------------------------------- + +Consider the following document: + +[source,html] +----------------------------------------------------------------------------- +
+
Top: {{attr1}}
+
+
Sub: {{attr1}}
+
+
+----------------------------------------------------------------------------- + +And the following code: + +[source,js] +----------------------------------------------------------------------------- +.controller('TopCtrl', function($scope) { $scope.attr1 = 'top-attr' }) +.controller('SubCtrl', function($scope) { }) +----------------------------------------------------------------------------- + +The output: + +[source,sh] +----------------------------------------------------------------------------- +Top: top-attr +Sub: top-attr +----------------------------------------------------------------------------- + +Now, if we apply a value in the child: + +[source,js] +----------------------------------------------------------------------------- +.controller('SubCtrl', function($scope) { $scope.attr1 = 'sub-attr' }) +----------------------------------------------------------------------------- +[source,sh] +----------------------------------------------------------------------------- +Top: top-attr +Sub: sub-attr +----------------------------------------------------------------------------- + +Setting a value in the child does not change the value in the parent. +Scopes are inherited prototypically, which means attributes from a +parent scope are copied into the child scope and the child's version of +the attribute masks that of the parent. + +For both scopes to share a single value, either the parent needs to +provide a setter function on the value: + +[source,js] +----------------------------------------------------------------------------- +.controller('TopCtrl', function($scope) { + $scope.attr1 = 'top-attr'; + $scope.setAttr1 = function(val) { + $scope.attr1 = val; + } +}) +.controller('SubCtrl', function($scope) { + $scope.setAttr1('sub-attr'); +}) +----------------------------------------------------------------------------- + +Produces.. + +[source,sh] +----------------------------------------------------------------------------- +Top: sub-attr +Sub: sub-attr +----------------------------------------------------------------------------- + +Or the value in question needs to be stored within a structure. + +[source,html] +----------------------------------------------------------------------------- +
+
Top: {{attrs.attr1}}
+
+
Sub: {{attrs.attr1}}
+
+
+----------------------------------------------------------------------------- + +[source,js] +----------------------------------------------------------------------------- +.controller('TopCtrl', function($scope) { $scope.attrs = {attr1 : 'top-attr'} }) +.controller('SubCtrl', function($scope) { $scope.attrs.attr1 = 'sub-attr' }) +----------------------------------------------------------------------------- + +Also produces.. + +[source,sh] +----------------------------------------------------------------------------- +Top: sub-attr +Sub: sub-attr +----------------------------------------------------------------------------- + +Since the child scope is not clobbering the 'attrs' attribute, both +scopes share the value, which is a reference to a single object. + +This last is approach is the best for providing two-way binding across +both scopes. For example: + +[source,html] +----------------------------------------------------------------------------- +
+
Top:
+
+
Sub:
+
+
+----------------------------------------------------------------------------- + +With this, typing a value into the first input, will set the value +for both scopes. + +For more, see +https://github.com/angular/angular.js/wiki/Understanding-Scopes[Understanding-Scopes]. + + +Future Topics... +---------------- + + * Routing vs Loading + * Angular Services / _Route Persistence_ + * Deep Linking / Managing the _first load_ problem + * When to use Angular Templates vs Template Toolkit Templates + * Displaying bib records in the prototype +