LP#1708291: web staff client serials module
authorGalen Charlton <gmc@equinoxinitiative.org>
Thu, 13 Apr 2017 16:03:52 +0000 (12:03 -0400)
committerDan Wells <dbw2@calvin.edu>
Fri, 1 Sep 2017 16:47:44 +0000 (12:47 -0400)
This patch adds a serials module to the web staff client, implementing
a unified serials interface allowing for the following actions supported
by the XUL staff client:

- creating subscriptions, distributions, and streams
- creating and editing prediction patterns
- receiving serial issues, with or without barcodes (units)
- batch and quick receiving

This module also implements some new features, including

- the ability to save prediction pattern codes as templates
  that can be shared and reused within an Evergreen database
- a more streamlined interface for managing subscriptions,
  distributions, and streams
- it is no longer necessary to create a starting issue in
  order to predict a run of issues; the dialog box for
  generating a set of predicted issues now lets you specify
  the starting point directly.
- the ability to more directly edit MFHDs

The new serials interfaces can be accessed from the record
details page via a Serials drop-down button that links to
a subscription management page, a quick-receive action, and
a MFHD management page. There is also a new Serials Administration
page where prediction pattern and serial copy templates can
be managed.

To test
-------
* Create, edit, and delete subscriptions, distribution streams,
  and routing lists.
* Use the prediction pattern wizard to create patterns.
* Save prediction pattern templates and use them to apply
  a pattern to new subscriptions.
* Verify that sets of issues can be predicted and received.
* Create and apply serial copy templates and verify that
  they are applied when receiving barcoded issues.

This patch represents a group coding effort by Galen Charlton,
Jason Etheridge, and Mike Rylander.

Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Conflicts:
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js

Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Signed-off-by: Dan Wells <dbw2@calvin.edu>
68 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/extras/ils_events.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm
Open-ILS/src/sql/Pg/210.schema.serials.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/live_t/spt-visibility.pg [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial_pattern_templates.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/YYYY.data.spt_perms.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/local/t_splash.tt2
Open-ILS/src/templates/staff/admin/serials/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/serials/pattern_template.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/serials/t_attr_edit.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/serials/t_splash.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/serials/t_template_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/serials/t_templates.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/catalog/index.tt2
Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/src/templates/staff/serials/index.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/share/serials_strings.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_apply_binding_template.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_batch_receive.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_chron_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_clone_subscription.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_day_of_week_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_holding_code_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_item_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_link_mfhd.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_manage.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_mfhd_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_mfhd_tooltip.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_month_day_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_month_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_notes.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_pattern_editor_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_pattern_summary.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_prediction_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_prediction_wizard.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_print_routing_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_receive_alerts.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_routing_list.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_season_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_select_pattern_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_sub_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_subscription_manager.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_view_items_grid.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/serials/t_week_in_month_selector.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_mfhd_create_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_org_select_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_subscription_select_dialog.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/serial/print_routing_list_users.js
Open-ILS/web/js/ui/default/staff/admin/serials/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/admin/serials/pattern_template.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
Open-ILS/web/js/ui/default/staff/serials/app.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/item_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/mfhd_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/prediction_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/prediction_wizard.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/sub_selector.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/subscription_manager.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/serials/services/core.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/mfhd.js [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/services/ui.js

index 7764758..d075d35 100644 (file)
@@ -3956,7 +3956,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        </actions>
                </permacrud>
        </class>
-       <class id="aua" controller="open-ils.cstore" oils_obj:fieldmapper="actor::user_address" oils_persist:tablename="actor.usr_address" reporter:label="User Address">
+       <class id="aua" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::user_address" oils_persist:tablename="actor.usr_address" reporter:label="User Address">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.usr_address_id_seq">
                        <field reporter:label="Type" name="address_type"  reporter:datatype="text"/>
                        <field reporter:label="City" name="city"  reporter:datatype="text"/>
@@ -3977,6 +3977,14 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="usr" reltype="has_a" key="id" map="" class="au"/>
                        <link field="replaces" reltype="has_a" key="id" map="" class="aua"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="UPDATE_USER"><context link="usr" field="home_ou"/></create>
+                               <retrieve permission="VIEW_USER"><context link="usr" field="home_ou"/></retrieve>
+                               <update permission="UPDATE_USER"><context link="usr" field="home_ou"/></update>
+                               <delete permission="UPDATE_USER"><context link="usr" field="home_ou"/></delete>
+                       </actions>
+               </permacrud>
        </class>
        <class id="aal" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::address_alert" oils_persist:tablename="actor.address_alert" reporter:label="Address Alert">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.address_alert_id_seq">
@@ -5069,7 +5077,7 @@ SELECT  usr,
                </permacrud>
        </class>
 
-       <class id="ssubn" controller="open-ils.cstore" oils_obj:fieldmapper="serial::subscription_note" oils_persist:tablename="serial.subscription_note" reporter:label="Subscription Note">
+       <class id="ssubn" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="serial::subscription_note" oils_persist:tablename="serial.subscription_note" reporter:label="Subscription Note">
                <fields oils_persist:primary="id" oils_persist:sequence="serial.subscription_note_id_seq">
                        <field reporter:label="ID" name="id" reporter:datatype="id"/>
                        <field reporter:label="Subscription" name="subscription" reporter:datatype="link"/>
@@ -5084,6 +5092,20 @@ SELECT  usr,
                        <link field="subscription" reltype="has_a" key="id" map="" class="ssub"/>
                        <link field="creator" reltype="has_a" key="id" map="" class="au"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_SERIAL_SUBSCRIPTION" context_field="owning_lib">
+                    <context link="subscription" field="owning_lib"/>
+                </create>
+                               <retrieve />
+                               <update permission="ADMIN_SERIAL_SUBSCRIPTION" context_field="owning_lib">
+                    <context link="subscription" field="owning_lib"/>
+                </update>
+                               <delete permission="ADMIN_SERIAL_SUBSCRIPTION" context_field="owning_lib">
+                    <context link="subscription" field="owning_lib"/>
+                </delete>
+                       </actions>
+               </permacrud>
        </class>
 
        <class id="sdist" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="serial::distribution" oils_persist:tablename="serial.distribution" reporter:label="Distribution">
@@ -5174,7 +5196,7 @@ SELECT  usr,
                </fields>
                <links>
                        <link field="distribution" reltype="has_a" key="id" map="" class="sdist"/>
-                       <link field="items" reltype="has_many" key="id" map="" class="sitem"/>
+                       <link field="items" reltype="has_many" key="stream" map="" class="sitem"/>
                        <link field="routing_list_users" reltype="has_many" key="stream" map="" class="srlu"/>
                </links>
                <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
@@ -5379,7 +5401,7 @@ SELECT  usr,
                </permacrud>
        </class>
 
-       <class id="sin" controller="open-ils.cstore" oils_obj:fieldmapper="serial::item_note" oils_persist:tablename="serial.item_note" reporter:label="Item Note">
+       <class id="sin" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="serial::item_note" oils_persist:tablename="serial.item_note" reporter:label="Item Note">
                <fields oils_persist:primary="id" oils_persist:sequence="serial.item_note_id_seq">
                        <field reporter:label="ID" name="id" reporter:datatype="id"/>
                        <field reporter:label="Item" name="item" reporter:datatype="link"/>
@@ -5394,7 +5416,22 @@ SELECT  usr,
                        <link field="item" reltype="has_a" key="id" map="" class="sitem"/>
                        <link field="creator" reltype="has_a" key="id" map="" class="au"/>
                </links>
-               <!-- Not available via PCRUD at this time -->
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_SERIAL_ITEM">
+                                       <context link="item" jump="stream.distribution" field="holding_lib" />
+                               </create>
+                               <retrieve permission="ADMIN_SERIAL_ITEM">
+                                       <context link="item" jump="stream.distribution" field="holding_lib" />
+                               </retrieve>
+                               <update permission="ADMIN_SERIAL_ITEM">
+                                       <context link="item" jump="stream.distribution" field="holding_lib" />
+                               </update>
+                               <delete permission="ADMIN_SERIAL_ITEM">
+                                       <context link="item" jump="stream.distribution" field="holding_lib" />
+                               </delete>
+                       </actions>
+               </permacrud>
        </class>
        <class id="sasum" controller="open-ils.cstore" oils_obj:fieldmapper="serial::any_summary" oils_persist:tablename="serial.any_summary" reporter:label="All Issues' Summaries" oils_persist:readonly="true">
                <fields>
@@ -5503,6 +5540,27 @@ SELECT  usr,
                </permacrud>
        </class>
 
+       <class id="spt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="serial::pattern_template" oils_persist:tablename="serial.pattern_template" reporter:label="Prediction Pattern Template">
+               <fields oils_persist:primary="id" oils_persist:sequence="serial.pattern_template_id_seq">
+                       <field reporter:label="ID" name="id" reporter:datatype="id" />
+                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Pattern Code" name="pattern_code" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Owning Library" name="owning_lib" reporter:datatype="org_unit" oils_obj:required="true"/>
+                       <field reporter:label="Share Depth" name="share_depth"  reporter:datatype="int"/>
+               </fields>
+               <links>
+                       <link field="owning_lib" reltype="has_a" key="id" map="" class="aou"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_SERIAL_PATTERN_TEMPLATE" context_field="owning_lib"/>
+                               <retrieve/>
+                               <update permission="ADMIN_SERIAL_PATTERN_TEMPLATE" context_field="owning_lib"/>
+                               <delete permission="ADMIN_SERIAL_PATTERN_TEMPLATE" context_field="owning_lib"/>
+                       </actions>
+               </permacrud>
+       </class>
+
        <class id="ascecm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::stat_cat_entry_copy_map" oils_persist:tablename="asset.stat_cat_entry_copy_map" reporter:label="Statistical Category Entry Copy Map">
                <fields oils_persist:primary="id" oils_persist:sequence="asset.stat_cat_entry_copy_map_id_seq">
                        <field name="id" reporter:datatype="id" />
index 570e19b..a4573b4 100644 (file)
     <event code='11009' textcode='SERIAL_STREAM_NOT_EMPTY'>
         <desc xml:lang="en-US">The stream still has dependent objects</desc>
     </event>
+    <event code='11010' textcode='SERIAL_CAPTION_AND_PATTERN_NOT_EMPTY'>
+        <desc xml:lang="en-US">The prediction pattern still has dependent objects</desc>
+    </event>
 </ils_events>
 
 
index 071922c..04f79c0 100644 (file)
@@ -257,6 +257,7 @@ sub fleshed_item_alter {
 
     my %found_sdist_ids;
     my %found_sstr_ids;
+    my %siss_to_potentially_delete;
     for my $item (@$items) {
         my $sstr_id = ref $item->stream ? $item->stream->id : $item->stream;
         if (!exists($found_sstr_ids{$sstr_id})) {
@@ -279,6 +280,8 @@ sub fleshed_item_alter {
         $item->edit_date('now');
 
         if( $item->isdeleted ) {
+            my $siss_id = ref $item->issuance ? $item->issuance->id : $item->issuance;
+            $siss_to_potentially_delete{$siss_id}++;
             $evt = _delete_sitem( $editor, $override, $item);
         } elsif( $item->isnew ) {
             # TODO: reconsider this
@@ -299,6 +302,31 @@ sub fleshed_item_alter {
         $editor->rollback;
         return $evt;
     }
+    if( %siss_to_potentially_delete ) {
+        foreach my $id (keys %siss_to_potentially_delete) {
+            my $issuance = $editor->retrieve_serial_issuance([
+                $id, {
+                    "flesh" => 1, "flesh_fields" => {
+                        "siss" => ["items"],
+                    }
+                }
+            ]);
+            unless ($issuance) {
+                $logger->warn("fleshed item-alter failed to retrieve issuance $id to potenitally delete");
+                $editor->rollback;
+                return $editor->die_event;
+            }
+            unless (@{ $issuance->items }) {
+                $logger->info("fleshed item-alter deleting issuance $id as it has no items left");
+                $evt = _delete_siss( $editor, $override, $issuance);
+                if( $evt ) {
+                    $logger->info("fleshed item-alter failed with event: ".OpenSRF::Utils::JSON->perl2JSON($evt));
+                    $editor->rollback;
+                    return $evt;
+                }
+            }
+        }
+    }
     $logger->debug("item-alter: done updating item batch");
     $editor->commit;
     $logger->info("fleshed item-alter successfully updated ".scalar(@$items)." items");
@@ -894,14 +922,47 @@ __PACKAGE__->register_method(
 sub make_predictions {
     my ($self, $conn, $authtoken, $args) = @_;
 
-    my $editor = OpenILS::Utils::CStoreEditor->new();
     my $ssub_id = $args->{ssub_id};
-    my $mfhd = MFHD->new(MARC::Record->new());
 
+    my $editor = OpenILS::Utils::CStoreEditor->new();
     my $ssub = $editor->retrieve_serial_subscription([$ssub_id]);
-    my $scaps = $editor->search_serial_caption_and_pattern({ subscription => $ssub_id, active => 't'});
     my $sdists = $editor->search_serial_distribution( [{ subscription => $ssub->id }, { flesh => 1, flesh_fields => {sdist => [ qw/ streams / ]} }] ); #TODO: 'deleted' support?
 
+    return store_predictions(
+        $self, $conn, $authtoken, $args, $ssub, $sdists,
+        make_prediction_values($self, $conn, $authtoken, $args, $ssub, $sdists, $editor)
+    );
+}
+
+__PACKAGE__->register_method(
+    method    => 'make_prediction_values',
+    api_name  => 'open-ils.serial.make_prediction_values',
+    api_level => 1,
+    argc      => 1,
+    signature => {
+        desc     => 'Receives an ssub id and returns objects that can be used to populate the issuance and item tables',
+        'params' => [ {
+                 name => 'ssub_id',
+                 desc => 'Serial Subscription ID',
+                 type => 'int'
+            }
+        ]
+    }
+);
+
+sub make_prediction_values {
+    my ($self, $conn, $authtoken, $args, $ssub, $sdists, $editor) = @_;
+    $logger->debug('make_prediction_values with args: ' . OpenSRF::Utils::JSON->perl2JSON($args));
+
+    my $ssub_id = $args->{ssub_id};
+
+    $editor ||= OpenILS::Utils::CStoreEditor->new();
+    $ssub ||= $editor->retrieve_serial_subscription([$ssub_id]);
+    $sdists ||= $editor->search_serial_distribution( [{ subscription => $ssub->id }, { flesh => 1, flesh_fields => {sdist => [ qw/ streams / ]} }] ); #TODO: 'deleted' support?
+
+    my $scaps = $editor->search_serial_caption_and_pattern({ subscription => $ssub_id, active => 't'});
+    my $mfhd = MFHD->new(MARC::Record->new());
+
     my $total_streams = 0;
     foreach (@$sdists) {
         $total_streams += scalar(@{$_->streams});
@@ -942,13 +1003,14 @@ sub make_predictions {
         my $options = {
                 'caption' => $caption_field,
                 'scap_id' => $scap->id,
+                'include_base_issuance' => $args->{include_base_issuance},
                 'num_to_predict' => $args->{num_to_predict},
                 'end_date' => defined $args->{end_date} ?
                     $_strp_date->parse_datetime($args->{end_date}) : undef
                 };
         my $predict_from_siss;
         if ($args->{base_issuance}) { # predict from a given issuance
-            $predict_from_siss = $args->{base_issuance}->holding_code;
+            $predict_from_siss = $args->{base_issuance};
         } else { # default to predicting from last published
             my $last_published = $editor->search_serial_issuance([
                     {'caption_and_pattern' => $scap->id,
@@ -973,16 +1035,25 @@ sub make_predictions {
                 );
             }
         }
+        $logger->debug('make_prediction_values reviving holdings: ' . OpenSRF::Utils::JSON->perl2JSON($predict_from_siss));
         $options->{predict_from} = _revive_holding($predict_from_siss->holding_code, $caption_field, 1); # fresh MFHD Record, so we simply default to 1 for seqno
         if ($fake_chron_needed) {
             $options->{faked_chron_date} = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($predict_from_siss->date_published));
         }
+        $logger->debug('make_prediction_values predicting with options: ' . OpenSRF::Utils::JSON->perl2JSON($options));
         push( @predictions, _generate_issuance_values($mfhd, $options) );
         $link_id++;
     }
 
+    $logger->debug('make_prediction_values predictions: ' . OpenSRF::Utils::JSON->perl2JSON(\@predictions));
+    return \@predictions;
+}
+
+sub store_predictions {
+    my ($self, $conn, $authtoken, $args, $ssub, $sdists, $predictions) = @_;
+
     my @issuances;
-    foreach my $prediction (@predictions) {
+    foreach my $prediction (@$predictions) {
         my $issuance = new Fieldmapper::serial::issuance;
         $issuance->isnew(1);
         $issuance->label($prediction->{label});
@@ -999,7 +1070,7 @@ sub make_predictions {
 
     my @items;
     for (my $i = 0; $i < @issuances; $i++) {
-        my $date_expected = $predictions[$i]->{date_published}->add(seconds => interval_to_seconds($ssub->expected_date_offset))->strftime('%F');
+        my $date_expected = $$predictions[$i]->{date_published}->add(seconds => interval_to_seconds($ssub->expected_date_offset))->strftime('%F');
         my $issuance = $issuances[$i];
         #$issuance->label(interval_to_seconds($ssub->expected_date_offset));
         foreach my $sdist (@$sdists) {
@@ -1038,11 +1109,13 @@ sub _generate_issuance_values {
     my ($mfhd, $options) = @_;
     my $caption = $options->{caption};
     my $scap_id = $options->{scap_id};
+    my $include_base_issuance = $options->{include_base_issuance};
     my $num_to_predict = $options->{num_to_predict};
     my $end_date = $options->{end_date};
     my $predict_from = $options->{predict_from};   # MFHD::Holding to predict from
     my $faked_chron_date = $options->{faked_chron_date};   # serial does not have a (complete) chronology caption, so add one (temporarily) based on this date 
 
+    $logger->debug('_generate_issuance_values predict_from: ' . OpenSRF::Utils::JSON->perl2JSON($predict_from));
 
 # Only needed for 'real' MFHD records, not our temp records
 #    my $link_id = $caption->link_id;
@@ -1082,9 +1155,16 @@ sub _generate_issuance_values {
         # to recreate rather than try to update
         $faked_caption = new MFHD::Caption($faked_caption);
         $predict_from = new MFHD::Holding($predict_from->seqno, new MARC::Field($predict_from->tag, $predict_from->indicator(1), $predict_from->indicator(2), $predict_from->subfields_list), $faked_caption);
+        $logger->debug('_generate_issuance_values fake predict_from: ' . OpenSRF::Utils::JSON->perl2JSON($predict_from));
     }
 
-    my @predictions = $mfhd->generate_predictions({'base_holding' => $predict_from, 'num_to_predict' => $num_to_predict, 'end_date' => $end_date});
+    my @predictions = $mfhd->generate_predictions({
+        'include_base_issuance' => $include_base_issuance,
+        'base_holding' => $predict_from,
+        'num_to_predict' => $num_to_predict,
+        'end_date' => $end_date
+    });
+    $logger->debug('_generate_issuance_values predictions: ' . OpenSRF::Utils::JSON->perl2JSON(\@predictions));
 
     my $pub_date;
     my @issuance_values;
@@ -1169,6 +1249,11 @@ __PACKAGE__->register_method(
                  name => 'donor_unit_ids',
                  desc => 'hash of unit_ids => 1, keyed with ids of any units giving up items',
                  type => 'hash'
+            },
+            {
+                 name => 'extras',
+                 desc => 'hash of hashes, circ_mod code and copy_location id, keyed as above',
+                 type => 'hash'
             }
         ],
         'return' => {
@@ -1204,6 +1289,11 @@ __PACKAGE__->register_method(
                  name => 'donor_unit_ids',
                  desc => 'hash of unit_ids => 1, keyed with ids of any units giving up items',
                  type => 'hash'
+            },
+            {
+                 name => 'extras',
+                 desc => 'hash of hashes, circ_mod code and copy_location id, keyed as above',
+                 type => 'hash'
             }
         ],
         'return' => {
@@ -1236,7 +1326,7 @@ __PACKAGE__->register_method(
 );
 
 sub unitize_items {
-    my ($self, $conn, $auth, $items, $barcodes, $call_numbers, $donor_unit_ids) = @_;
+    my ($self, $conn, $auth, $items, $barcodes, $call_numbers, $donor_unit_ids, $extras) = @_;
 
     my $editor = new_editor("authtoken" => $auth, "xact" => 1);
     return $editor->die_event unless $editor->checkauth;
@@ -1250,6 +1340,7 @@ sub unitize_items {
     }
     my %found_stream_ids;
     my %found_types;
+    my $prev_loc_setting_map = {};
 
     my %stream_ids_by_unit_id;
 
@@ -1295,7 +1386,7 @@ sub unitize_items {
         if (!exists($found_types{$stream_id})) {
             $found_types{$stream_id} = {};
         }
-        $found_types{$stream_id}->{$scap->type} = 1;
+        $found_types{$stream_id}->{$scap->type} = 1 if ($scap);
 
         # create unit if needed
         if ($unit_id == -1 or (!$new_unit_id and $unit_id == -2)) { # create unit per item
@@ -1314,7 +1405,11 @@ sub unitize_items {
                 $unit->{"note"} = "Item ID: " . $item->id;
                 return $unit;
             }
+
             $unit->barcode($barcodes->{$item->id}) if exists($barcodes->{$item->id});
+            $unit->location($extras->{copy_locations}->{$item->id}) if exists($extras->{copy_locations}->{$item->id});
+            $unit->circ_modifier($extras->{circ_mods}->{$item->id}) if exists($extras->{circ_mods}->{$item->id});
+
             my $evt =  _create_sunit($editor, $unit);
             return $evt if $evt;
             if ($unit_id == -2) {
@@ -1349,6 +1444,57 @@ sub unitize_items {
 
         my $evt = _update_sitem($editor, undef, $item);
         return $evt if $evt;
+
+        if ($mode eq 'receive') {
+            my $sdists = $editor->search_serial_distribution([
+                {"+sstr" => {"id" => $stream_id}},
+                {
+                    "join" => {"sstr" => {}},
+                    "flesh" => 1,
+                    "flesh_fields" => {"sdist" => ["subscription"]}
+                }]);
+
+            #-------------------------------------------------------------------------
+            # The following is copied from open-ils.serial.receive_items.one_unit_per
+    
+            # Fetch a list of issuances with received copies already existing
+            # on this distribution (and with the same holding type on the
+            # issuance).  This will be used in up to two places: once when building
+            # a summary, once when changing the copy location of the previous
+            # issuance's copy.
+            my $issuances_received = _issuances_received($editor, $item);
+            if ($U->event_code($issuances_received)) {
+                $editor->rollback;
+                return $issuances_received;
+            }
+    
+            # Find out if we need to to deal with previous copy location changing.
+            my $ou = $sdists->[0]->holding_lib;
+            unless (exists $prev_loc_setting_map->{$ou}) {
+                $prev_loc_setting_map->{$ou} = $U->ou_ancestor_setting_value(
+                    $ou, "serial.prev_issuance_copy_location", $editor
+                );
+            }
+    
+            # If there is a previous copy location setting, we need the previous
+            # issuance, from which we can in turn look up the item attached to the
+            # same stream we're on now.
+            if ($prev_loc_setting_map->{$ou}) {
+                if (my $prev_iss =
+                    _previous_issuance($issuances_received, $item->issuance)) {
+    
+                    # Now we can change the copy location of the previous unit,
+                    # if needed.
+                    return $editor->event if defined $U->event_code(
+                        move_previous_unit(
+                            $editor, $prev_iss, $item, $prev_loc_setting_map->{$ou}
+                        )
+                    );
+                }
+            }
+            #-------------------------------------------------------------------------
+        }
+
     }
 
     # cleanup 'dead' units (units which are now emptied of their items)
@@ -1464,13 +1610,22 @@ sub unitize_items {
 sub _find_or_create_call_number {
     my ($e, $lib, $cn_string, $record) = @_;
 
-    # FIXME: should suffix and prefix come into play here?
-    my $existing = $e->search_asset_call_number({
-        "owning_lib" => $lib,
-        "label" => $cn_string,
-        "record" => $record,
-        "deleted" => "f"
-    }) or return $e->die_event;
+    my ($prefix,$suffix) = ('','');
+    if (ref($cn_string)) {
+        ($prefix,$cn_string,$suffix) = @$cn_string;
+    }
+
+    my $existing = $e->search_asset_call_number([{
+        owning_lib  => $lib,
+        label       => $cn_string,
+        record      => $record,
+        deleted     => "f",
+        '+acnp'     => { label => $prefix },
+        '+acns'     => { label => $suffix },
+        
+    },{
+        join => { acnp => {}, acns => {} }
+    }]) or return $e->die_event;
 
     if (@$existing) {
         return $existing->[0]->id;
@@ -1478,6 +1633,43 @@ sub _find_or_create_call_number {
         return $e->die_event unless
             $e->allowed("CREATE_VOLUME", $lib);
 
+        $prefix = -1 if (!$prefix);
+        $suffix = -1 if (!$suffix);
+
+        if ($prefix ne '-1') {
+            my $acnp = $e->search_asset_call_number_prefix({
+                owning_lib  => $lib,
+                label       => $prefix,
+            })->[0];
+
+            if (!$acnp) {
+                $acnp = new Fieldmapper::asset::call_number_prefix;
+                $acnp->label($prefix);
+                $acnp->owning_lib($lib);
+                $e->create_asset_call_number_prefix($acnp) or return $e->die_event;
+                $prefix = $e->data->id;
+            } else {
+                $prefix = $acnp->id;
+            }
+        }
+
+        if ($suffix ne '-1') {
+            my $acns = $e->search_asset_call_number_suffix({
+                owning_lib  => $lib,
+                label       => $suffix,
+            })->[0];
+
+            if (!$acns) {
+                $acns = new Fieldmapper::asset::call_number_suffix;
+                $acns->label($suffix);
+                $acns->owning_lib($lib);
+                $e->create_asset_call_number_suffix($acns) or return $e->die_event;
+                $suffix = $e->data->id;
+            } else {
+                $suffix = $acns->id;
+            }
+        }
+
         my $acn = new Fieldmapper::asset::call_number;
 
         $acn->creator($e->requestor->id);
@@ -1485,6 +1677,8 @@ sub _find_or_create_call_number {
         $acn->record($record);
         $acn->label($cn_string);
         $acn->owning_lib($lib);
+        $acn->prefix($prefix);
+        $acn->suffix($suffix);
 
         $e->create_asset_call_number($acn) or return $e->die_event;
         return $e->data->id;
@@ -2401,6 +2595,18 @@ __PACKAGE__->register_method(
 
 __PACKAGE__->register_method(
     method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.caption_and_pattern.safe_delete',
+    signature   => q/
+        Deletes an existing caption and pattern object, but only
+        if there are no attached serial issuances. 
+        @param authtoken The login session key
+        @param strid The id of the scap to delete
+        @return 1 on success - Event otherwise.
+        /
+);
+
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
     api_name        =>  'open-ils.serial.subscription.safe_delete.dry_run',
 );
 __PACKAGE__->register_method(
@@ -2411,6 +2617,10 @@ __PACKAGE__->register_method(
     method      => 'safe_delete',
     api_name        =>  'open-ils.serial.stream.safe_delete.dry_run',
 );
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.caption_and_pattern.safe_delete.dry_run',
+);
 
 sub safe_delete {
     my( $self, $conn, $authtoken, $id ) = @_;
@@ -2439,10 +2649,10 @@ sub safe_delete {
 
         foreach my $sitem (@{$sstr->items}) {
             if ($sitem->status ne 'Expected') {
-                return OpenILS::Event->new('SERIAL_STREAM_NOT_EMPTY', payload=>$id);
+                return $e->die_event(OpenILS::Event->new('SERIAL_STREAM_NOT_EMPTY', payload=>$id));
             }
             if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
-                return OpenILS::Event->new('SERIAL_STREAM_NOT_EMPTY', payload=>$id);
+                return $e->die_event(OpenILS::Event->new('SERIAL_STREAM_NOT_EMPTY', payload=>$id));
             }
         }
 
@@ -2465,16 +2675,48 @@ sub safe_delete {
         foreach my $sstr (@{$sdist->streams}) {
             foreach my $sitem (@{$sstr->items}) {
                 if ($sitem->status ne 'Expected') {
-                    return OpenILS::Event->new('SERIAL_DISTRIBUTION_NOT_EMPTY', payload=>$id);
+                    return $e->die_event(OpenILS::Event->new('SERIAL_DISTRIBUTION_NOT_EMPTY', payload=>$id));
                 }
                 if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
-                    return OpenILS::Event->new('SERIAL_DISTRIBUTION_NOT_EMPTY', payload=>$id);
+                    return $e->die_event(OpenILS::Event->new('SERIAL_DISTRIBUTION_NOT_EMPTY', payload=>$id));
                 }
             }
         }
 
         $obj = $sdist;
 
+    } elsif ($type eq 'caption_and_pattern') {
+        my $scap = $e->retrieve_serial_caption_and_pattern([
+            $id,
+            { flesh => 1, flesh_fields => { scap => ['subscription'] } }
+        ]) or return $e->die_event;
+
+        return $e->die_event unless
+            $e->allowed("ADMIN_SERIAL_CAPTION_PATTERN", $scap->subscription->owning_lib);
+
+        my $issuances = $e->search_serial_issuance([{
+            caption_and_pattern => $id
+        },{
+            flesh => 2,
+            flesh_fields => {
+                siss  => ['items'],
+                sitem => ['unit']
+            }
+        }]);
+
+        foreach my $siss (@$issuances) {
+            foreach my $sitem (@{$siss->items}) {
+                if ($sitem->status ne 'Expected') {
+                    return $e->die_event(OpenILS::Event->new('SERIAL_CAPTION_AND_PATTERN_NOT_EMPTY', payload=>$id));
+                }
+                if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
+                    return $e->die_event(OpenILS::Event->new('SERIAL_CAPTION_AND_PATTERN_NOT_EMPTY', payload=>$id));
+                }
+            }
+        }
+
+        $obj = $scap;
+
     } else { # subscription
         my $sub = $e->retrieve_serial_subscription([
             $id, {
@@ -2494,10 +2736,10 @@ sub safe_delete {
             foreach my $sstr (@{$sdist->streams}) {
                 foreach my $sitem (@{$sstr->items}) {
                     if ($sitem->status ne 'Expected') {
-                        return OpenILS::Event->new('SERIAL_SUBSCRIPTION_NOT_EMPTY', payload=>$id);
+                        return $e->die_event(OpenILS::Event->new('SERIAL_SUBSCRIPTION_NOT_EMPTY', payload=>$id));
                     }
                     if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
-                        return OpenILS::Event->new('SERIAL_SUBSCRIPTION_NOT_EMPTY', payload=>$id);
+                        return $e->die_event(OpenILS::Event->new('SERIAL_SUBSCRIPTION_NOT_EMPTY', payload=>$id));
                     }
                 }
             }
@@ -2511,6 +2753,7 @@ sub safe_delete {
         $e->$method($obj) or return $e->die_event;
         $e->commit;
     }
+
     return 1;
 }
 
@@ -4052,4 +4295,40 @@ sub summary_test {
     return;
 }
 
+__PACKAGE__->register_method(
+    "method" => "fetch_pattern_templates",
+    "api_name" => "open-ils.serial.pattern_template.retrieve.at",
+    "stream" => 1,
+    "signature" => {
+        "desc" => q{Return the set of pattern templates that are
+            visible to the specified library.},
+        "params" => [
+            {"desc" => "Authtoken", "type" => "string"},
+            {"desc" => "OU ID", "type" => "number"},
+        ],
+        return => {
+            desc => "stream of pattern templates",
+            type => "object", class => "spt"
+        }
+    }
+);
+
+sub fetch_pattern_templates {
+    my ($self, $client, $auth, $org_unit)  = @_;
+
+    my $e = new_editor("authtoken" => $auth);
+    return $e->die_event unless $e->checkauth;
+
+    my $patterns = $e->json_query({
+        from => [ 'serial.pattern_templates_visible_to' => $org_unit ]
+    });
+$logger->info(Dumper($patterns)); use Data::Dumper;
+
+    $client->respond($e->retrieve_serial_pattern_template($_->{id}))
+        foreach (@$patterns);
+
+    $e->disconnect;
+    return undef;
+}
+
 1;
index 03975cf..bbe3661 100644 (file)
@@ -273,6 +273,7 @@ sub _holding_date {
 # generate_predictions()
 # Accepts a hash ref of options initially defined as:
 # base_holding : reference to the holding field to predict from
+# include_base_issuance : whether to "predict" the startting holding, so as to generate a label for it
 # num_to_predict : the number of issues you wish to predict
 # OR
 # end_holding : holding field ref, keep predicting until you meet or exceed it
@@ -293,6 +294,7 @@ sub generate_predictions {
     my $end_holding    = $options->{end_holding};
     my $end_date       = $options->{end_date};
     my $max_to_predict = $options->{max_to_predict} || 10000; # fail-safe
+    my $include_base_issuance   = $options->{include_base_issuance};
 
     if (!defined($base_holding)) {
         carp("Base holding not defined in generate_predictions, returning empty set");
@@ -305,7 +307,8 @@ sub generate_predictions {
     my $curr_holding = $base_holding->clone; # prevent side-effects
     
     my @predictions;
-        
+    push(@predictions, $curr_holding->clone) if ($include_base_issuance);
+
     if ($num_to_predict) {
         for (my $i = 0; $i < $num_to_predict; $i++) {
             push(@predictions, $curr_holding->increment->clone);
index 2e5af44..8c65f6f 100644 (file)
@@ -195,6 +195,7 @@ CREATE TABLE serial.issuance (
        label           TEXT,
        date_published  TIMESTAMP WITH TIME ZONE,
        caption_and_pattern INT   REFERENCES serial.caption_and_pattern (id)
+                              ON DELETE CASCADE
                                  DEFERRABLE INITIALLY DEFERRED,
        holding_code    TEXT      CONSTRAINT issuance_holding_code_check CHECK (
                                    holding_code IS NULL OR could_be_serial_holding_code(holding_code)
@@ -421,5 +422,26 @@ CREATE INDEX assist_holdings_display
 CREATE TRIGGER materialize_holding_code
     AFTER INSERT OR UPDATE ON serial.issuance
     FOR EACH ROW EXECUTE PROCEDURE serial.materialize_holding_code() ;
+
+CREATE TABLE serial.pattern_template (
+    id            SERIAL PRIMARY KEY,
+    name          TEXT NOT NULL,
+    pattern_code  TEXT NOT NULL,
+    owning_lib    INTEGER REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED,
+    share_depth   INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX serial_pattern_template_name_idx ON serial.pattern_template (evergreen.lowercase(name));
+
+CREATE OR REPLACE FUNCTION serial.pattern_templates_visible_to(org_unit INT) RETURNS SETOF serial.pattern_template AS $func$
+BEGIN
+    RETURN QUERY SELECT *
+           FROM serial.pattern_template spt
+           WHERE (
+             SELECT ARRAY_AGG(id)
+             FROM actor.org_unit_descendants(spt.owning_lib, spt.share_depth)
+           ) @@ org_unit::TEXT::QUERY_INT;
+END;
+$func$ LANGUAGE PLPGSQL;
+
 COMMIT;
 
index f3d5aa5..391ad5a 100644 (file)
@@ -1681,7 +1681,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 591, 'ADMIN_COPY_TAG', oils_i18n_gettext( 591,
     'Administer copy tag', 'ppl', 'description' )),
  ( 592,'CONTAINER_BATCH_UPDATE', oils_i18n_gettext( 592,
-    'Allow batch update via buckets', 'ppl', 'description' ))
+    'Allow batch update via buckets', 'ppl', 'description' )),
+ ( 593, 'ADMIN_SERIAL_PATTERN_TEMPLATE', oils_i18n_gettext( 593,
+    'Administer serial prediction pattern templates', 'ppl', 'description' ))
 ;
 
 SELECT SETVAL('permission.perm_list_id_seq'::TEXT, 1000);
@@ -2489,6 +2491,7 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
                        'ADMIN_SERIAL_CAPTION_PATTERN',
                        'ADMIN_SERIAL_DISTRIBUTION',
                        'ADMIN_SERIAL_ITEM',
+                       'ADMIN_SERIAL_PATTERN_TEMPLATE',
                        'ADMIN_SERIAL_STREAM',
                        'ADMIN_SERIAL_SUBSCRIPTION',
                        'ISSUANCE_HOLDS',
diff --git a/Open-ILS/src/sql/Pg/live_t/spt-visibility.pg b/Open-ILS/src/sql/Pg/live_t/spt-visibility.pg
new file mode 100644 (file)
index 0000000..455877e
--- /dev/null
@@ -0,0 +1,48 @@
+BEGIN;
+
+SELECT plan(6);
+
+INSERT INTO serial.pattern_template(name, pattern_code, owning_lib, share_depth)
+VALUES ('spt-vis-test', '[]', 4, 0);
+
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(4)
+     WHERE name = 'spt-vis-test'),
+    1::BIGINT,
+    'BR1 can see its own pattern at consortial sharing depth'
+);
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(7)
+     WHERE name = 'spt-vis-test'),
+    1::BIGINT,
+    'BR4 can see it as well at consortial sharing depth'
+);
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(8)
+     WHERE name = 'spt-vis-test'),
+    1::BIGINT,
+    'SL1 can see it as well at consortial sharing depth'
+);
+
+UPDATE serial.pattern_template SET share_depth = 2 WHERE name = 'spt-vis-test';
+
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(4)
+     WHERE name = 'spt-vis-test'),
+    1::BIGINT,
+    'BR1 can still see own pattern at branch sharing depth'
+);
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(7)
+     WHERE name = 'spt-vis-test'),
+    0::BIGINT,
+    'BR4 CANNOT see it at branch sharing depth'
+);
+SELECT is(
+    (SELECT COUNT(*) FROM serial.pattern_templates_visible_to(8)
+     WHERE name = 'spt-vis-test'),
+    1::BIGINT,
+    'SL1 can still see it at branch sharing depth'
+);
+
+ROLLBACK;
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial_pattern_templates.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial_pattern_templates.sql
new file mode 100644 (file)
index 0000000..d396682
--- /dev/null
@@ -0,0 +1,25 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+CREATE TABLE serial.pattern_template (
+    id            SERIAL PRIMARY KEY,
+    name          TEXT NOT NULL,
+    pattern_code  TEXT NOT NULL,
+    owning_lib    INTEGER REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED,
+    share_depth   INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX serial_pattern_template_name_idx ON serial.pattern_template (evergreen.lowercase(name));
+
+CREATE OR REPLACE FUNCTION serial.pattern_templates_visible_to(org_unit INT) RETURNS SETOF serial.pattern_template AS $func$
+BEGIN
+    RETURN QUERY SELECT *
+           FROM serial.pattern_template spt
+           WHERE (
+             SELECT ARRAY_AGG(id)
+             FROM actor.org_unit_descendants(spt.owning_lib, spt.share_depth)
+           ) @@ org_unit::TEXT::QUERY_INT;
+END;
+$func$ LANGUAGE PLPGSQL;
+
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.spt_perms.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.spt_perms.sql
new file mode 100644 (file)
index 0000000..2ceef91
--- /dev/null
@@ -0,0 +1,24 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO permission.perm_list ( id, code, description ) VALUES
+ ( 593, 'ADMIN_SERIAL_PATTERN_TEMPLATE', oils_i18n_gettext( 593,
+    'Administer serial prediction pattern templates', 'ppl', 'description' ))
+;
+
+INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
+    SELECT
+        pgt.id, perm.id, aout.depth, FALSE
+    FROM
+        permission.grp_tree pgt,
+        permission.perm_list perm,
+        actor.org_unit_type aout
+    WHERE
+        pgt.name = 'Serials' AND
+        aout.name = 'System' AND
+        perm.code IN (
+            'ADMIN_SERIAL_PATTERN_TEMPLATE'
+        );
+
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql
new file mode 100644 (file)
index 0000000..d27f8bc
--- /dev/null
@@ -0,0 +1,18 @@
+BEGIN;
+
+ALTER TABLE serial.issuance DROP CONSTRAINT IF EXISTS issuance_caption_and_pattern_fkey;
+
+-- Using NOT VALID and VALIDATE CONSTRAINT limits the impact to concurrent work.
+-- For details, see: https://www.postgresql.org/docs/current/static/sql-altertable.html
+
+ALTER TABLE serial.issuance ADD CONSTRAINT issuance_caption_and_pattern_fkey
+    FOREIGN KEY (caption_and_pattern)
+    REFERENCES serial.caption_and_pattern (id)
+    ON DELETE CASCADE
+    DEFERRABLE INITIALLY DEFERRED
+    NOT VALID;
+
+ALTER TABLE serial.issuance VALIDATE CONSTRAINT issuance_caption_and_pattern_fkey;
+
+COMMIT;
+
index cdcccb7..82599b3 100644 (file)
@@ -29,7 +29,6 @@
     ,[ l('Notifications / Action Triggers'), "./admin/local/action_trigger/event_definition" ]
     ,[ l('Patrons with Negative Balances'), "./admin/local/circ/neg_balance_users" ]
     ,[ l('Search Filter Groups'), "./admin/local/actor/search_filter_group" ]
-    ,[ l('Serial Copy Template Editor'), "./admin/local/asset/copy_template" ]
     ,[ l('Standing Penalties'), "./admin/local/config/standing_penalty" ]
     ,[ l('Statistical Categories Editor'), "./admin/local/asset/stat_cat_editor" ]
     ,[ l('Statistical Popularity Badges'), "./admin/local/rating/badge" ]
diff --git a/Open-ILS/src/templates/staff/admin/serials/index.tt2 b/Open-ILS/src/templates/staff/admin/serials/index.tt2
new file mode 100644 (file)
index 0000000..2a54931
--- /dev/null
@@ -0,0 +1,33 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Serials Administration"); 
+  ctx.page_app = "egSerialsAdmin";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/file.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/serials/app.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+    s.SERIALS_TEMPLATE_SUCCESS_SAVE = "[% l('Saved serial template') %]";
+    s.SERIALS_TEMPLATE_SUCCESS_DELETE = "[% l('Deleted serial template') %]";
+    s.SERIALS_TEMPLATE_FAIL_SAVE = "[% l('Failed to save serial template') %]";
+    s.SERIALS_TEMPLATE_FAIL_DELETE = "[% l('Failed to delete serial template') %]";
+    s.LOAN_DURATION_SHORT = "[% l('Short') %]";
+    s.LOAN_DURATION_NORMAL = "[% l('Normal') %]";
+    s.LOAN_DURATION_EXTENDED = "[% l('Extended') %]";
+    s.FINE_LEVEL_LOW = "[% l('Low') %]";
+    s.FINE_LEVEL_NORMAL = "[% l('Normal') %]";
+    s.FINE_LEVEL_HIGH = "[% l('High') %]";
+    s.CONFIRM_DIRTY_EXIT = "[% l('There are unsaved changes; close anyway?') %]";
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
+
diff --git a/Open-ILS/src/templates/staff/admin/serials/pattern_template.tt2 b/Open-ILS/src/templates/staff/admin/serials/pattern_template.tt2
new file mode 100644 (file)
index 0000000..8ff7928
--- /dev/null
@@ -0,0 +1,44 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Prediction Pattern Templates");
+  ctx.page_app = "egAdminConfig";
+  ctx.page_ctrl = 'PatternTemplate';
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/fm_record_editor.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/app.js"></script>
+[% INCLUDE 'staff/serials/share/serials_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/prediction_wizard.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/serials/pattern_template.js"></script>
+<link rel="stylesheet" href="[% ctx.base_path %]/staff/css/admin.css" />
+[% END %]
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    [% l('Prediction Pattern Templates') %]
+  </div>
+</div>
+
+<eg-grid
+    id-field="id"
+    idl-class="spt"
+    grid-controls="gridControls"
+    persist-key="admin.serials.pattern_template">
+
+    <eg-grid-menu-item handler="new_record" label="[% l('New Record') %]"></eg-grid-menu-item>
+    <eg-grid-action handler="edit_record" label="[% l('Edit Record') %]" disabled="need_one_selected"></eg-grid-action>
+    <eg-grid-action handler="delete_selected" label="[% l('Delete Selected') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Name') %]"           path="name"></eg-grid-field>
+    <eg-grid-field label="[% l('Pattern Code') %]"   path="pattern_code"></eg-grid-field>
+    <eg-grid-field label="[% l('Owning Library') %]" path="owning_lib.name"></eg-grid-field>
+    <eg-grid-field label="[% l('Sharing Depth') %]"  path="share_depth"></eg-grid-field>
+    <eg-grid-field label="[% l('ID') %]" path='id' required hidden></eg-grid-field>
+    <eg-grid-field path='*' hidden></eg-grid-field>
+</eg-grid>
+
+[% END %]
diff --git a/Open-ILS/src/templates/staff/admin/serials/t_attr_edit.tt2 b/Open-ILS/src/templates/staff/admin/serials/t_attr_edit.tt2
new file mode 100644 (file)
index 0000000..a4bfecf
--- /dev/null
@@ -0,0 +1,338 @@
+<style>
+    .app-modal-window .modal-dialog {
+      width: 800px;
+    }
+    .vertical-align {
+        display: flex;
+        align-items: center;
+    }
+</style>
+
+<form role="form">
+<div class="container-fluid">
+    <div class="row bg-info vertical-align">
+        <div class="col-md-3">
+            <h4>[% l('Template Name') %]</h4>
+        </div>
+        <div class="col-md-3">
+            <input type="text" class="form-control" ng-model="working.name"></input>
+        </div>
+<!-- FIXME: remove for now; may be nice to have later
+        <div class="col-md-2">
+            <div class="btn-group pull-right">
+                <span class="btn btn-default btn-file">
+                    [% l('Import') %]
+                    <input type="file" eg-file-reader container="imported_template.data">
+                </span>
+                <label class="btn btn-default"
+                    eg-json-exporter container="hashed_template"
+                    default-file-name="'[% l('exported_serials_template.json') %]'">
+                    [% l('Export') %]
+                </label>
+            </div>
+        </div>
+-->
+        <div class="col-md-4">
+            <div class="btn-group pull-right">
+                <button class="btn btn-default" ng-click="clearWorking()" type="button">[% l('Clear') %]</button>
+                <button class="btn btn-primary" ng-disabled="working.name=='' || working.loan_duration == null || working.fine_level == null" ng-click="saveTemplate()" type="button">[% l('Save') %]</label>
+                <button class="btn btn-warning" ng-click="close_modal()" type="button">[% l('Close') %]</label>
+            </div>
+        </div>
+    </div>
+
+    <div class="row pad-vert"></div>
+
+    <div class="row bg-info">
+        <div class="col-md-4">
+            <b>[% l('Circulate?') %]</b>
+        </div>
+        <div class="col-md-4">
+            <b>[% l('Status') %]</b>
+        </div>
+    </div>
+
+    <div class="row">
+        <div class="col-md-8">
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.circulate !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.circulate" ng-model="working.circulate" value="t"/>
+                                [% l('Yes') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.circulate" ng-model="working.circulate" value="f"/>
+                                [% l('No') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.status !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.status" ng-model="working.status"
+                        ng-options="s.id() as s.name() for s in status_list">
+                    </select>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Circulation Library') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('Reference?') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.circ_lib !== undefined}">
+                    <eg-org-selector
+                        alldisabled="{{!defaults.attributes.circ_lib}}"
+                        selected="working.circ_lib"
+                        noDefault
+                        label="[% l('(Unset)') %]"
+                        disable-test="cant_have_vols"
+                    ></eg-org-selector>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.ref !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.ref" ng-model="working.ref" value="t"/>
+                                [% l('Yes') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.ref" ng-model="working.ref" value="f"/>
+                                [% l('No') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Shelving Location') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('OPAC Visible?') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.location !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.location" ng-model="working.location"
+                        ng-options="l.id() as i18n.ou_qualified_location_name(l) for l in location_list"
+                    ></select>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.opac_visible !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.opac_visible" ng-model="working.opac_visible" value="t"/>
+                                [% l('Yes') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.opac_visible" ng-model="working.opac_visible" value="f"/>
+                                [% l('No') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Circulation Modifer') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('Price') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="nullable col-md-6" ng-class="{'bg-success': working.circ_modifier !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.circ_modifier" ng-model="working.circ_modifier"
+                        ng-options="m.code() as m.name() for m in circ_modifier_list"
+                    >
+                        <option value="">[% l('<NONE>') %]</option>
+                    </select>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.price !== undefined}">
+                    <input class="form-control" ng-disabled="!defaults.attributes.price" ng-model="working.price" type="text"/>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Loan Duration') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.loan_duration !== undefined}">
+                    <select class="form-control" ng-disabled="!defaults.attributes.loan_duration" ng-model="working.loan_duration" ng-options="x.v() as x.l() for x in loan_duration_options">
+                    </select>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Circulate as Type') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('Deposit?') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="nullable col-md-6" ng-class="{'bg-success': working.circ_as_type !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.circ_as_type" ng-model="working.circ_as_type"
+                        ng-options="t.code() as t.value() for t in circ_type_list">
+                      <option value="">[% l('<NONE>') %]</option>
+                    </select>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.deposit !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.deposit" ng-model="working.deposit" value="t"/>
+                                [% l('Yes') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.deposit" ng-model="working.deposit" value="f"/>
+                                [% l('No') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Holdable?') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('Deposit Amount') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.holdable !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.holdable" ng-model="working.holdable" value="t"/>
+                                [% l('Yes') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.holdable" ng-model="working.holdable" value="f"/>
+                                [% l('No') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.deposit_amount !== undefined}">
+                    <input class="form-control" ng-disabled="!defaults.attributes.deposit_amount" ng-model="working.deposit_amount" type="text"/>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Age-based Hold Protection') %]</b>
+                </div>
+                <div class="col-md-6">
+                    <b>[% l('Quality') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.age_protect !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.age_protect" ng-model="working.age_protect"
+                        ng-options="a.id() as a.name() for a in age_protect_list"
+                    ></select>
+                </div>
+                <div class="col-md-6" ng-class="{'bg-success': working.mint_condition !== undefined}">
+                    <div class="row">
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.mint_condition" ng-model="working.mint_condition" value="t"/>
+                                [% l('Good') %]
+                            </label>
+                        </div>
+                        <div class="col-xs-3">
+                            <label>
+                                <input type="radio" ng-disabled="!defaults.attributes.mint_condition" ng-model="working.mint_condition" value="f"/>
+                                [% l('Damaged') %]
+                            </label>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Fine Level') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.fine_level !== undefined}">
+                    <select class="form-control" ng-disabled="!defaults.attributes.fine_level" ng-model="working.fine_level" ng-options="x.v() as x.l() for x in fine_level_options">
+                    </select>
+                </div>
+            </div>
+
+            <div class="row pad-vert"></div>
+
+            <div class="row bg-info">
+                <div class="col-md-6">
+                    <b>[% l('Floating') %]</b>
+                </div>
+            </div>
+
+            <div class="row">
+                <div class="col-md-6" ng-class="{'bg-success': working.floating !== undefined}">
+                    <select class="form-control"
+                        ng-disabled="!defaults.attributes.floating" ng-model="working.floating"
+                        ng-options="a.id() as a.name() for a in floating_list"
+                    ></select>
+                </div>
+            </div>
+        </div>
+
+    </div>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/admin/serials/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/serials/t_splash.tt2
new file mode 100644 (file)
index 0000000..308a31d
--- /dev/null
@@ -0,0 +1,38 @@
+
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Serials Administration') %]</span>
+  </div>
+</div>
+
+<div class="container admin-splash-container">
+
+[%
+    interfaces = [
+     [ l('Serial Copy Templates'), "./admin/serials/templates" ]
+     [ l('Prediction Pattern Templates'), "./admin/serials/pattern_template" ]
+   ];
+
+   USE table(interfaces, cols=3);
+%]
+
+<div class="row">
+    [% FOREACH col = table.cols %]
+        <div class="col-md-4">
+        [% FOREACH item = col %][% IF item.1 %]
+        <div class="row new-entry">
+            <div class="col-md-12">
+                <span class="glyphicon glyphicon-pencil"></span>
+                <a target="_self" href="[% item.1 %]">
+                    [% item.0 %]
+                </a>
+            </div>
+        </div>
+        [% END %]
+    [% END %]
+        </div>
+    [% END %]
+</div>
+
+</div>
+
diff --git a/Open-ILS/src/templates/staff/admin/serials/t_template_list.tt2 b/Open-ILS/src/templates/staff/admin/serials/t_template_list.tt2
new file mode 100644 (file)
index 0000000..14f37ce
--- /dev/null
@@ -0,0 +1,54 @@
+<eg-grid
+  id-field="id"
+  idl-class="act"
+  features="-sort,-multisort"
+  grid-controls="grid_controls"
+  persist-key="serials.copy_templates">
+
+  <eg-grid-menu-item handler="grid_actions.create_template" 
+    label="[% l('Create Template') %]"></eg-grid-menu-item>
+
+  <eg-grid-action handler="grid_actions.edit_template"
+    label="[% l('Edit Template') %]"
+    disabled="need_one_selected"></eg-grid-action>
+
+  <eg-grid-action handler="grid_actions.delete_template"
+    label="[% l('Delete Template') %]"></eg-grid-action>
+
+  <eg-grid-field label="[% l('Template ID') %]" path='id' required></eg-grid-field>
+
+  <eg-grid-field label="[% l('Template Name') %]" path='name'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Create Date') %]"
+    path='create_date'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Creator') %]"
+    path='creator.usrname'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Edit Date') %]"
+    path='edit_date'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Editor') %]"
+    path='editor.usrname'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Owning Library') %]"
+    path='owning_lib.shortname'></eg-grid-field>
+
+  <eg-grid-field label="[% l('Circulating Library') %]"
+    path='circ_lib.shortname' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Status') %]"
+    path='status.name' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Circ Modifier') %]"
+    path='circ_modifier.code' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Location') %]"
+    path='location.name' hidden></eg-grid-field>
+
+  <eg-grid-field label="[% l('Floating') %]"
+    path='floating.name' hidden></eg-grid-field>
+
+  <eg-grid-field path='*' hidden></eg-grid-field>
+</eg-grid>
+
diff --git a/Open-ILS/src/templates/staff/admin/serials/t_templates.tt2 b/Open-ILS/src/templates/staff/admin/serials/t_templates.tt2
new file mode 100644 (file)
index 0000000..547b39d
--- /dev/null
@@ -0,0 +1,20 @@
+<div class="container-fluid" style="text-align:center">
+  <div class="alert alert-info alert-less-pad strong-text-2">
+    <span>[% l('Serials Templates') %]</span>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-md-3">
+    <div class="input-group">
+      <span class="input-group-addon">[% l('Owning Library') %]</span>
+      <eg-org-selector selected="context_ou"></eg-org-selector>
+    </div>
+  </div>
+</div>
+
+<div class="pad-vert"></div>
+
+<div>
+[% INCLUDE 'staff/admin/serials/t_template_list.tt2' %]
+</div>
index b98c3f1..3d19ca2 100644 (file)
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/patron_search.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/tagtable.js"></script>
+[% INCLUDE 'staff/serials/share/serials_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/sub_selector.js"></script>
 [% INCLUDE 'staff/cat/share/marcedit_strings.tt2' %]
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/marcedit.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/circ/services/circ.js"></script>
       "[% l('Item Transfer Target set') %]";                
     s.MARK_OVERLAY_TARGET =                                                                                                            
       "[% l('Record Overlay Target set') %]";                
+
+    s.SERIALS_NO_SUBS = "[% l('No subscription selected') %]";
+    s.SERIALS_NO_ITEMS = "[% l('No items expected for the selected subscription') %]";
+
+    s.SERIALS_ISSUANCE_FAIL_SAVE = "[% l('Failed to save issuance') %]";
+    s.SERIALS_ISSUANCE_SUCCESS_SAVE = "[% l('Issuance saved') %]";
+
   }])
 </script>
 
index bcb52df..c1e326e 100644 (file)
         [% l('Add Volumes') %]
     </button>
     <div class="btn-group" uib-dropdown dropdown-append-to-body>
+        <button id="serials-button" type="button" class="btn btn-default" uib-dropdown-toggle>
+            [% l('Serials') %] <span class="caret"></span>
+        </button>
+        <ul uib-dropdown-menu role="menu" aria-labelledby="serials-button">
+             <li role="menuitem">
+                <a ng-click="quickReceive()">[% l('Quick Receive') %]</a>
+            </li>
+             <li role="menuitem">
+                <a target="_self" href="./serials/{{record_id}}">[% l('Manage Subscriptions') %]</a>
+            </li>
+             <li role="menuitem">
+                <a target="_self" href="./serials/{{record_id}}/manage-mfhds">[% l('Manage MFHDs') %]</a>
+            </li>
+        </ul>
+    </div>
+    <div class="btn-group" uib-dropdown dropdown-append-to-body>
         <button id="mark-for-button" type="button" class="btn btn-default" uib-dropdown-toggle>
             [% l('Mark for:') %] <span class="caret"></span>
         </button>
index 16cd665..748ef4b 100644 (file)
             </a>
           </li>
           <li>
+            <a href="./admin/serials/index" target="_self">
+              <span class="glyphicon glyphicon-paperclip"></span>
+              [% l('Serials Administration') %]
+            </a>
+          </li>
+          <li>
             <a href="./admin/booking/index" target="_self">
               <span class="glyphicon glyphicon-calendar"></span>
               [% l('Booking Administration') %]
diff --git a/Open-ILS/src/templates/staff/serials/index.tt2 b/Open-ILS/src/templates/staff/serials/index.tt2
new file mode 100644 (file)
index 0000000..e00e4e7
--- /dev/null
@@ -0,0 +1,76 @@
+[%
+  WRAPPER "staff/base.tt2";
+  ctx.page_title = l("Serials Management"); 
+  ctx.page_app = "egSerialsApp";
+%]
+
+[% BLOCK APP_JS %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/mfhd.js"></script>
+[% INCLUDE 'staff/serials/share/serials_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/services/core.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/app.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/marcrecord.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/tagtable.js"></script>
+[% INCLUDE 'staff/cat/share/marcedit_strings.tt2' %]
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/marcedit.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/subscription_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/sub_selector.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/mfhd_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/prediction_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/prediction_wizard.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/item_manager.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/serials/directives/view-items-grid.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/cat/services/record.js"></script>
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+    s.SERIALS_SUBSCRIPTION_SUCCESS_CLONE = "[% l('Cloned serial subscription') %]";
+    s.SERIALS_SUBSCRIPTION_FAIL_CLONE = "[% l('Failed to clone serial subscription') %]";
+    s.SERIALS_SUBSCRIPTION_SUCCESS_DELETE = "[% l('Deleted serial subscription') %]";
+    s.SERIALS_SUBSCRIPTION_FAIL_DELETE = "[% l('Failed to delete serial subscription') %]";
+    s.SERIALS_DISTRIBUTION_SUCCESS_DELETE = "[% l('Deleted serial distribution') %]";
+    s.SERIALS_DISTRIBUTION_FAIL_DELETE = "[% l('Failed to delete serial distribution') %]";
+    s.SERIALS_STREAM_SUCCESS_DELETE = "[% l('Deleted serial stream') %]";
+    s.SERIALS_STREAM_FAIL_DELETE = "[% l('Failed to delete serial stream') %]";
+    s.SERIALS_SCAP_SUCCESS_DELETE = "[% l('Deleted serial prediction pattern') %]";
+    s.SERIALS_SCAP_FAIL_DELETE = "[% l('Failed to delete serial prediction pattern') %]";
+    s.SERIALS_ISSUANCE_FAIL_SAVE = "[% l('Failed to save issuance') %]";
+    s.SERIALS_ISSUANCE_SUCCESS_SAVE = "[% l('Issuance saved') %]";
+    s.SERIALS_ITEM_NOTE_FAIL_SAVE = "[% l('Failed to save item notes') %]";
+    s.SERIALS_ITEM_NOTE_SUCCESS_SAVE = "[% l('Item notes saved') %]";
+    s.SERIALS_DISTRIBUTION_SUCCESS_LINK_MFHD = "[% l('Distribution linked to MFHD') %]";
+    s.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD = "[% l('Failed to link distribution to MFHD') %]";
+    s.SERIALS_DISTRIBUTION_SUCCESS_BINDING_TEMPLATE = "[% l('Binding unit template applied to Distribution') %]";
+    s.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE = "[% l('Failed to apply binding unit template to distribution') %]";
+    s.SERIALS_EDIT_SISS_HC = "[% l('Edit issue information') %]";
+    s.SERIALS_ISSUANCE_PREDICT = "[% l('Predict New Issues: Initial Values') %]";
+    s.SERIALS_ISSUANCE_ADD = "[% l('Add following issue') %]";
+    s.SERIALS_SPECIAL_ISSUANCE_ADD = "[% l('Add special issue') %]";
+
+    s.CONFIRM_DELETE_SUBSCRIPTION = "[% l('Delete selected subscription(s)?') %]";
+    s.CONFIRM_DELETE_SUBSCRIPTION_MESSAGE = "[% l('Will delete {{count}} subscription(s)') %]";
+    s.CONFIRM_DELETE_DISTRIBUTION = "[% l('Delete selected distribution(s)?') %]";
+    s.CONFIRM_DELETE_DISTRIBUTION_MESSAGE = "[% l('Will delete {{count}} distribution(s)') %]";
+    s.CONFIRM_DELETE_STREAM = "[% l('Delete selected stream(s)?') %]";
+    s.CONFIRM_DELETE_STREAM_MESSAGE = "[% l('Will delete {{count}} stream(s)') %]";
+    s.CONFIRM_DELETE_SCAP = "[% l('Delete prediction pattern?') %]";
+    s.CONFIRM_DELETE_SCAP_MESSAGE = "[% l('Will delete the prediction pattern if there are no attached issuances.') %]";
+
+    s.CONFIRM_CHANGE_ITEMS = {};
+    s.CONFIRM_CHANGE_ITEMS.delete = "[% l('Delete selected item(s)?') %]";
+    s.CONFIRM_CHANGE_ITEMS.reset = "[% l('Reset selected items?') %]"
+    s.CONFIRM_CHANGE_ITEMS.receive = "[% l('Receive selected items?') %]"
+    s.CONFIRM_CHANGE_ITEMS.status = "[% l('Change status selected items?') %]"
+
+    s.CONFIRM_DELETE_MFHDS = "[% l('Delete selected MFHD(s)?') %]";
+    s.CONFIRM_DELETE_MFHDS_MESSAGE = "[% l('Will delete {{items}} MFHD(s).') %]";
+
+}]);
+</script>
+[% END %]
+
+<div ng-view></div>
+
+[% END %]
+
diff --git a/Open-ILS/src/templates/staff/serials/share/serials_strings.tt2 b/Open-ILS/src/templates/staff/serials/share/serials_strings.tt2
new file mode 100644 (file)
index 0000000..80f32ae
--- /dev/null
@@ -0,0 +1,27 @@
+[%# Shared serial strings %]
+
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+    s.SERIALS_ITEM_STATUS = {};
+    s.SERIALS_ITEM_STATUS.Expected = "[% l('Expected') %]";
+    s.SERIALS_ITEM_STATUS.Received = "[% l('Received') %]";
+    s.SERIALS_ITEM_STATUS.Claimed = "[% l('Claimed') %]";
+    s.SERIALS_ITEM_STATUS.Bindery = "[% l('Bindery') %]";
+    s.SERIALS_ITEM_STATUS.Bound = "[% l('Bound') %]";
+    s.SERIALS_ITEM_STATUS.Discarded = "[% l('Discarded') %]";
+    s.SERIALS_ITEM_STATUS['Not Held'] = "[% l('Not Held' ) %]";
+    s.SERIALS_ITEM_STATUS['Not Published'] = "[% l('Not Published') %]";
+
+    s.CHRON_LABEL_YEAR   = "[% l('Year') %]";
+    s.CHRON_LABEL_SEASON = "[% l('Season') %]";
+    s.CHRON_LABEL_MONTH  = "[% l('Month') %]";
+    s.CHRON_LABEL_WEEK   = "[% l('Week') %]";
+    s.CHRON_LABEL_DAY    = "[% l('Day') %]";
+    s.CHRON_LABEL_HOUR   = "[% l('Hour') %]";
+    s.EG_CONFIRM_DELETE_PATTERN_TEMPLATE_TITLE = "[% l('Confirm Prediction Pattern Template Deletion') %]";
+    s.EG_CONFIRM_DELETE_PATTERN_TEMPLATE_BODY = "[% l('Delete {{count}} template(s)?') %]";
+    s.PATTERN_TEMPLATE_SUCCESS_DELETE = "[% l('Deleted prediation pattern template(s)') %]";
+    s.PATTERN_TEMPLATE_FAIL_DELETE = "[% l('Failed to delete prediction template(s)') %]";
+}]);
+</script>
+
diff --git a/Open-ILS/src/templates/staff/serials/t_apply_binding_template.tt2 b/Open-ILS/src/templates/staff/serials/t_apply_binding_template.tt2
new file mode 100644 (file)
index 0000000..dbdb21d
--- /dev/null
@@ -0,0 +1,55 @@
+<form ng-submit="ok(args)" role="form">
+
+<style>
+/* odd/even row styling */
+.modal-body > div:nth-child(odd) {
+  background-color: rgb(248, 248, 248);
+}
+</style>
+
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 class="modal-title" ng-if="rows.length != 1">
+        [% l('Apply Binding Unit Template to [_1] Selected Distributions','{{rows.length}}') %]
+    </h4>
+    <h4 class="modal-title" ng-if="rows.length == 1">
+        [% l('Apply Binding Unit Template to [_1] Selected Distribution','{{rows.length}}') %]
+    </h4>
+</div>
+
+<div class="modal-body">
+    <div class="row">
+        <div class="col-md-8">
+            <label>
+                [% l('Distribution Library') %]
+            </label>
+        </div>
+        <div class="col-md-4">
+            <label>
+                [% l('Binding Unit Template') %]
+            </label>
+        </div>
+    </div>
+    <div class="row" ng-repeat="lib in libs">
+        <div class="col-md-8">
+            <label for="ou_{{lib.id}}">
+                {{lib.name}}
+            </label>
+        </div>
+        <div class="col-md-4">
+            <select id="ou_{{lib.id}}"
+                ng-model="args.bind_unit_template[lib.id]"
+                ng-options="t.id as t.name for t in templates[lib.id]"
+                class="form-control">
+                <option value=""></option>
+            </select>
+        </div>
+    </div>
+</div>
+
+<div class="modal-footer">
+    <input type="submit" class="btn btn-primary" value="[% l('Update') %]"></input>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_batch_receive.tt2 b/Open-ILS/src/templates/staff/serials/t_batch_receive.tt2
new file mode 100644 (file)
index 0000000..cec2820
--- /dev/null
@@ -0,0 +1,183 @@
+<form name="batch_receive_form" ng-submit="ok(items)" role="form">
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 ng-show="force_bind && items.length >  1" class="modal-title">{{ title || "[% l('Bind items') %]" }}</h4>
+    <h4 ng-show="force_bind && items.length <= 1" class="modal-title">{{ title || "[% l('Barcode item') %]" }}</h4>
+    <h4 ng-show="!force_bind" class="modal-title">{{ title || "[% l('Receive items') %]" }}</h4>
+</div>
+
+<div class="modal-body">
+  <div class="row">
+    <div class="col-md-2">
+      <label class="checkbox-inline">
+        <input type="checkbox" ng-model="barcode_items">[% l('Barcode Items') %]
+      </label>
+    </div>
+    <div class="col-md-2">
+      <label class="checkbox-inline">
+        <input type="checkbox" ng-disabled="!barcode_items" ng-model="auto_barcodes">[% l('Auto-Barcode') %]
+      </label>
+    </div>
+    <div class="col-md-2">
+      <label class="checkbox-inline">
+        <input type="checkbox" ng-disabled="" ng-model="print_routing_lists">[% l('Print routing lists') %]
+      </label>
+    </div>
+    <div class="col-md-2">
+      <label class="checkbox-inline" ng-show="items.length > 1">
+        <input type="checkbox" ng-disabled="force_bind" ng-model="bind">[% l('Bind') %]
+      </label>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-12"><hr/></div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-3">
+      <b>[% l('Library : Distribution/Stream') %]</b>
+      <br/>
+      <dl class="dl-horizontal"><dt>[% l('Notes') %]</dt></dl>
+    </div>
+    <div class="col-md-1">
+      <b>[% l('Issuance') %]</b>
+    </div>
+    <div class="col-md-1">
+      <b>[% l('Copy location') %]</b>
+    </div>
+    <div class="col-md-1">
+      <b>[% l('Call number') %]</b>
+    </div>
+    <div class="col-md-2">
+      <b>[% l('Circulation modifier') %]</b>
+    </div>
+    <div class="col-md-1">
+      <b>[% l('Barcode') %]</b>
+    </div>
+    <div class="col-md-1">
+      <b ng-show="!bind">[% l('Receive') %]</b>
+      <b ng-show="bind">[% l('Include') %]</b>
+    </div>
+    <div class="col-md-1">
+      <b>[% l('Routing List') %]</b>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-4"></div>
+    <div class="col-md-1">
+      <select
+        class="form-control"
+        ng-model="selected_copy_location"
+        ng-options="l.id as l.name for l in acpl_list | orderBy:'name'">
+        <option value="">[% l('Template default') %]</option>
+      </select>
+    </div>
+    <div class="col-md-1">
+      <select
+        class="form-control"
+        ng-model="selected_call_number"
+        ng-options="l as fullCNLabel(l) for l in acn_list | orderBy:'label_sortkey'">
+        <option value="">[% l('Default') %]</option>
+      </select>
+    </div>
+    <div class="col-md-1">
+      <select
+        class="form-control"
+        ng-model="selected_circ_mod"
+        ng-options="l.code as l.name for l in ccm_list | orderBy:'name'">
+        <option value="">[% l('Template default') %]</option>
+      </select>
+    </div>
+    <div class="col-md-4"></div>
+    <div class="col-md-1">
+      <div class="btn btn-primary" ng-click="apply_template_overrides()">[% l('Apply') %]</div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-12"><hr/></div>
+  </div>
+
+  <div class="row" ng-repeat="item in items">
+    <div class="col-md-3">
+      {{item.stream().distribution().holding_lib().name()}}: {{item.stream().distribution().label()}}/{{item.stream().routing_label()}}
+      <dl class="dl-horizontal">
+        <div ng-repeat="note in item.stream().distribution().subscription().notes()">
+          <div ng-show="note.alert() == 't'">
+            <dt>{{note.title()}}</dt>
+            <dd>{{note.value()}}</dd>
+          </div>
+        </div>
+        <div ng-repeat="note in item.stream().distribution().notes()">
+          <div ng-show="note.alert() == 't'">
+            <dt>{{note.title()}}</dt>
+            <dd>{{note.value()}}</dd>
+          </div>
+        </div>
+        <div ng-repeat="note in item.notes()">
+          <div ng-show="note.alert() == 't'">
+            <dt>{{note.title()}}</dt>
+            <dd>{{note.value()}}</dd>
+          </div>
+        </div>
+      <dl>
+    </div>
+    <div class="col-md-1">
+      {{item.issuance().label()}}
+    </div>
+    <div class="col-md-1">
+      <select
+        ng-disabled="!item._receive || bind_or_none($index)"
+        class="form-control"
+        ng-model="item._copy_location"
+        ng-options="l.id as l.name for l in acpl_list | orderBy:'name'">
+        <option value="">[% l('Template default') %]</option>
+      </select>
+    </div>
+    <div class="col-md-1">
+      <eg-basic-combo-box eg-disabled="!item._receive || bind_or_none($index)" list="acnp_labels" selected="item._cn_prefix" placeholder="[% l('Prefix') %]"></eg-basic-combo-box>
+      <input ng-disabled="!item._receive || bind_or_none($index)" class="form-control" placeholder="[% l('Label') %]"
+             ng-required="item._receive && !bind_or_none($index)" ng-model="item._call_number" type="text"/>
+      <eg-basic-combo-box eg-disabled="!item._receive || bind_or_none($index)" list="acns_labels" selected="item._cn_suffix" placeholder="[% l('Suffix') %]"></eg-basic-combo-box>
+      <br/>
+    </div>
+    <div class="col-md-1">
+      <select
+        ng-disabled="!item._receive || bind_or_none($index)"
+        class="form-control"
+        ng-model="item._circ_mod"
+        ng-options="l.code as l.name for l in ccm_list | orderBy:'name'">
+        <option value="">[% l('Template default') %]</option>
+      </select>
+    </div>
+    <div class="col-md-2">
+      <input ng-disabled="!item._receive || bind_or_none($index) || (barcode_items && !item.stream().distribution().receive_unit_template())" class="form-control" focus-me="$first"
+             ng-model="item._barcode" type="text" id="item_barcode_{{$index}}"
+             ng-required="item._receive && !bind_or_none($index)" eg-enter="focus_next_barcode($index)"/>
+      <div class="alert alert-warning" ng-show="barcode_items && !item.stream().distribution().receive_unit_template()">
+        [% l('Receiving template not set; needed to barcode while receiving') %]
+      </div>
+    </div>
+    <div class="col-md-1">
+      <input type="checkbox" ng-model="item._receive"/>
+    </div>
+    <div class="col-md-1">
+      <input type="checkbox" ng-disabled="!item._receive || cannot_print($index)" ng-model="item._print_routing_list"/>
+    </div>
+  </div>
+
+</div>
+
+<div class="modal-footer">
+  <div class="row">
+    <div class="col-md-8"></div>
+    <div class="col-md-4">
+      <input type="submit" class="btn btn-primary" ng-disabled="batch_receive_form.$error.required.length" value='{{ save_label || "[% l('Save') %]" }}'></input>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_chron_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_chron_selector.tt2
new file mode 100644 (file)
index 0000000..af5a43c
--- /dev/null
@@ -0,0 +1,5 @@
+<select ng-model="ngModel">
+  <option 
+    ng-repeat="c in options track by c.value" value="{{c.value}}"
+    ng-disabled="c.disabled">{{c.label}}</option>
+</select>
diff --git a/Open-ILS/src/templates/staff/serials/t_clone_subscription.tt2 b/Open-ILS/src/templates/staff/serials/t_clone_subscription.tt2
new file mode 100644 (file)
index 0000000..038a57f
--- /dev/null
@@ -0,0 +1,57 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+        <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+        <h4 ng-show="subs.length==1" class="modal-title">[% l('Clone Subscription') %]</h4>
+        <h4 ng-show="subs.length>1" class="modal-title">[% l('Clone Subscriptions') %]</h4>
+    </div>
+    <div class="modal-body">
+        <p>[% l('This feature will clone the selected subscriptions and all of their subscription notes, distributions, distribution notes, captions and patterns, streams, and routing list users.') %]</p>
+        <p>[% l('Holdings-related objects, like issuances, items, units, and summaries will not be cloned.') %]</p>
+        <p ng-show="subs.length == 1">[% l('To which bibliographic record should the new subscription be attached?') %]</p>
+        <p ng-show="subs.length > 1">[% l('To which bibliographic record should the new subscriptions be attached?') %]</p>
+        <div class="row">
+            <div class="col-md-1">
+                <input type="radio" name="which_radio_button" id="same_bib"
+                    ng-model="args.which_radio_button" value="same_bib">
+                </input>
+            </div>
+            <div class="col-md-11">
+                <label ng-if="subs.length==1" for="same_bib">
+                    [% l('Same record as the selected subscription') %]
+                </label>
+                <label ng-if="subs.length>1" for="same_bib">
+                    [% l('Same record as the selected subscriptions') %]
+                </label>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-1">
+                <input type="radio" name="which_radio_button"
+                    ng-model="args.which_radio_button" value="different_bib">
+                </input>
+            </div>
+            <div class="col-md-3">
+                <label for="different_bib">
+                    [% l('Record specified by this Bid ID:') %]
+                </label>
+            </div>
+            <div class="col-md-8">
+                <input type="number" class="form-control" min="1"
+                    ng-click="args.which_radio_button='different_bib'"
+                    ng-model-options="{ debounce: 1000 }"
+                    id="different_bib" ng-model="args.bib_id"/>
+                <div ng-show="args.bib_id">{{mvr.title}}</div>
+                <div class="alert alert-warning" ng-show="bibNotFound">
+                    [% l('Not Found') %]
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="modal-footer">
+        <input
+            ng-disabled="!args.which_radio_button||(args.which_radio_button=='different_bib'&&(!args.bib_id||bibNotFound))"
+            type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+        <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_day_of_week_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_day_of_week_selector.tt2
new file mode 100644 (file)
index 0000000..1941861
--- /dev/null
@@ -0,0 +1,9 @@
+<select ng-model="ngModel">
+  <option value="mo">[% l('Monday') %]</option>
+  <option value="tu">[% l('Tuesday') %]</option>
+  <option value="we">[% l('Wednesday') %]</option>
+  <option value="th">[% l('Thursday') %]</option>
+  <option value="fr">[% l('Friday') %]</option>
+  <option value="sa">[% l('Saturday') %]</option>
+  <option value="su">[% l('Sunday') %]</option>
+</select>
diff --git a/Open-ILS/src/templates/staff/serials/t_holding_code_dialog.tt2 b/Open-ILS/src/templates/staff/serials/t_holding_code_dialog.tt2
new file mode 100644 (file)
index 0000000..8346f0c
--- /dev/null
@@ -0,0 +1,100 @@
+<form ng-submit="ok(args)" role="form">
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 class="modal-title">{{ title || "[% l('Construct new holding code') %]" }}</h4>
+</div>
+
+<div class="modal-body">
+  <div class="row">
+    <div class="col-md-3">
+      <b>[% l('Publication date') %]</b>
+    </div>
+    <div class="col-md-4">
+      <eg-date-input ng-model="pubdate"></eg-date-input>
+    </div>
+    <div class="col-md-2">
+      <b>[% l('Type') %]</b>
+    </div>
+    <div class="col-md-3">
+      <select
+        class="form-control"
+          ng-model="type"
+          ng-init='types=[{n:"basic",l:"[%l('Basic')%]"},{n:"supplement",l:"[%l('Supplement')%]"},{n:"index",l:"[%l('Index')%]"}]'
+          ng-options='t.n as t.l for t in types'>
+      </select>
+    </div>
+  </div>
+  <div class="row" ng-show="can_change_adhoc">
+    <div class="col-md-3">
+      <b>[% l('Ad hoc issue?') %]</b>
+    </div>
+    <div class="col-md-1">
+      <input type="checkbox" ng-model="args.adhoc">
+    </div>
+  </div>
+
+  <div ng-show="args.adhoc">
+  <div class="pad-vert row">
+    <div class="col-md-3">
+      <b>[% l('Issuance Label') %]</b>
+    </div>
+    <div class="col-md-9">
+      <input class="form-control" type="text" ng-model="label"/>
+    </div>
+  </div>
+  </div>
+
+  <div ng-hide="args.adhoc">
+  <div class="row container" ng-if="args.enums.length">
+    <hr/>
+    <h2>[% l('Enumeration labels') %]</h2>
+  </div>
+
+  <div class="row" ng-repeat="e in args.enums">
+    <div class="col-md-4">
+      [% l('Enumeration level [_1]','{{ $index + 1}}') %]
+    </div>
+    <div class="col-md-4">
+      <input class="form-control" ng-model="e.value" type="text"/>
+    </div>
+    <div class="col-md-4">
+      {{ e.pattern }}
+    </div>
+  </div>
+
+  <div class="row container" ng-if="args.chrons.length">
+    <hr/>
+    <h2>[% l('Chronology labels') %]</h2>
+  </div>
+
+  <div class="row" ng-repeat="c in args.chrons">
+    <div class="col-md-4">
+      [% l('Chronology level [_1]','{{ $index + 1}}') %]
+    </div>
+    <div class="col-md-4">
+      <input class="form-control" ng-model="c.value" type="text"/>
+    </div>
+    <div class="col-md-4">
+      {{ c.pattern }}
+    </div>
+  </div>
+  </div>
+
+</div>
+
+<div class="modal-footer">
+  <div class="row">
+    <div class="col-md-4" ng-show="request_count">
+      <h4>[% l('Prediction count') %]</h4>
+    </div>
+    <div class="col-md-3" ng-show="request_count">
+      <input class="form-control" ng-model="count" type="number"/>
+    </div>
+    <div class="col-md-5">
+      <input type="submit" class="btn btn-primary" value='{{ save_label || "[% l('Save') %]" }}'></input>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_item_manager.tt2 b/Open-ILS/src/templates/staff/serials/t_item_manager.tt2
new file mode 100644 (file)
index 0000000..8c7227a
--- /dev/null
@@ -0,0 +1,7 @@
+<div>
+<eg-sub-selector bib-id="bibId" ssub-id="ssubId"></eg-sub-selector>
+</div>
+
+<div>
+<eg-item-grid bib-id="bibId" ssub-id="ssubId"></eg-item-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_link_mfhd.tt2 b/Open-ILS/src/templates/staff/serials/t_link_mfhd.tt2
new file mode 100644 (file)
index 0000000..03820d2
--- /dev/null
@@ -0,0 +1,35 @@
+<form ng-submit="ok(args)" role="form">
+    <div class="modal-header">
+        <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+        <h4 class="modal-title">[% l('Link MFHD') %]</h4>
+    </div>
+    <div class="modal-body">
+        <div ng-repeat="legacy in legacies">
+            <div uib-tooltip="[% l('Record ID [_1]', '{{legacy.mvr.doc_id}}') %]" tooltip-placement="left">
+                <a target="_blank" href="/eg/staff/cat/catalog/record/{{legacy.mvr.doc_id}}">{{legacy.mvr.title}}</a>
+            </div>
+            <div>
+                {{legacy.mvr.physical_description}}
+            </div>
+            <div ng-repeat="svr in legacy.svrs" uib-tooltip-template="'/eg/staff/serials/t_mfhd_tooltip'" tooltip-placement="left">
+                <input type="radio" name="which_mfhd" ng-model="args.which_mfhd" ng-value="svr.sre_id" id="{{svr.sre_id}}">
+                <label for="{{svr.sre_id}}">
+                    {{svr.location}}
+                </label>
+            </div>
+        </div>
+    <div class="modal-footer">
+        <div class="pull-left">
+            <label>[% l('Summary Display') %]</label>
+            <select ng-model="args.summary_method">
+                <option value="add_to_sre" selected>[% l('Both') %]</option>
+                <option value="merge_with_sre">[% l('Merge') %]</option>
+                <option value="use_sre_only">[% l('MFHD Only') %]</option>
+                <option value="use_sdist_only">[% l('None') %]</option>
+            </select>
+        </div>
+        <input type="submit" class="btn btn-primary" value="[% l('OK') %]" ng-disabled="!args.which_mfhd"/>
+        <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_manage.tt2 b/Open-ILS/src/templates/staff/serials/t_manage.tt2
new file mode 100644 (file)
index 0000000..c919d29
--- /dev/null
@@ -0,0 +1,32 @@
+<div ng-show="bib_id" class="row col-md-12">
+  <eg-record-summary record-id="bib_id" no-marc-link="true" record="summary_record"></eg-record-summary>
+</div>
+
+<div class="row col-md-12 pad-vert">
+  <div class="col-md-12">
+    <uib-tabset active="active_tab"> 
+      <!-- note that non-numeric index values must be enclosed in single-quotes,
+           otherwise selecting the active table won't work cleanly -->
+      <uib-tab index="'manage-subscriptions'" heading="[% l('Manage Subscriptions') %]">
+        <div class="container-fluid">
+        <eg-subscription-manager ng-if="active_tab == 'manage-subscriptions'" bib-id="bib_id"></eg-subscription-manager>
+        </div>
+      </uib-tab>
+      <uib-tab index="'prediction'" heading="[% l('Manage Predictions') %]">
+        <eg-prediction-manager ng-if="active_tab == 'prediction'"
+            bib-id="bib_id" ssub-id="ssub.id">
+        </eg-prediction-manager>
+      </uib-tab>
+      <uib-tab index="'issues'" heading="[% l('Manage Issues') %]">
+        <eg-item-manager ng-if="active_tab == 'issues'"
+            bib-id="bib_id" ssub-id="ssub.id">
+        </eg-item-manager>
+      </uib-tab>
+      <uib-tab index="'manage-mfhds'" heading="[% l('Manage MFHDs') %]">
+        <eg-mfhd-manager ng-if="active_tab == 'manage-mfhds'"
+            bib-id="bib_id">
+        </eg-mfhd-manager>
+      </uib-tab>
+    </uib-tabset>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_mfhd_manager.tt2 b/Open-ILS/src/templates/staff/serials/t_mfhd_manager.tt2
new file mode 100644 (file)
index 0000000..6568fee
--- /dev/null
@@ -0,0 +1,26 @@
+<div>
+  <eg-grid
+    id-field="id"
+    features="-display,-sort,-multisort"
+    items-provider="mfhdGridDataProvider"
+    grid-controls="mfhdGridControls"
+    persist-key="serials.mfhd_grid">
+
+    <eg-grid-menu-item handler="createMfhd"
+      label="[% l('Create MFHD') %]"
+    />
+
+    <eg-grid-action handler="edit_mfhd" disabled="need_one_selected"
+      label="[% l('Edit MFHD') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_mfhds"
+      label="[% l('Delete Selected MFHDs') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('ID') %]"             path="id"              visible></eg-grid-field>
+    <eg-grid-field label="[% l('Owning Library') %]" path="owning_lib.name" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Basic Holdings') %]" path="basic_holdings" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Index Holdings') %]" path="index_holdings" hidden></eg-grid-field>
+    <eg-grid-field label="[% l('Supplement Holdings') %]" path="supplement_holdings" hidden></eg-grid-field>
+
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_mfhd_tooltip.tt2 b/Open-ILS/src/templates/staff/serials/t_mfhd_tooltip.tt2
new file mode 100644 (file)
index 0000000..aa79e28
--- /dev/null
@@ -0,0 +1,77 @@
+<div class="row">
+    <div class="col-md-4">
+        [% l('Record ID') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.sre_id }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Basic Holdings') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.basic_holdings | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+    </div>
+    <div class="col-md-8">
+        {{ svr.basic_holdings_add | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Supplement Holdings') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.supplement_holdings | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+    </div>
+    <div class="col-md-8">
+        {{ svr.supplement_holdings_add | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Index Holdings') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.index_holdings | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+    </div>
+    <div class="col-md-8">
+        {{ svr.index_holdings_add | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Online') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.online | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Missing') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.missing | join:' ; ' }}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-4">
+        [% l('Incomplete') %]
+    </div>
+    <div class="col-md-8">
+        {{ svr.incomplete | join:' ; ' }}
+    </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_month_day_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_month_day_selector.tt2
new file mode 100644 (file)
index 0000000..5a1a38f
--- /dev/null
@@ -0,0 +1,17 @@
+<div class="input-group">
+  <input type="text"
+    class="form-control"
+    ng-show="!hideDatePicker"
+    uib-datepicker-popup="MMMM d"
+    is-open="datePickerIsOpen"
+    ng-model="dt"
+    datepicker-options="options"
+    show-button-bar="false"
+  />
+  <span class="input-group-btn">
+    <button type="button" class="btn btn-default"
+      ng-click="datePickerIsOpen=!datePickerIsOpen">
+      <i class="glyphicon glyphicon-calendar"></i>
+    </button>
+  </span>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_month_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_month_selector.tt2
new file mode 100644 (file)
index 0000000..a9329f0
--- /dev/null
@@ -0,0 +1,14 @@
+<select ng-model="ngModel">
+  <option value="01">[% l('January') %]</option>
+  <option value="02">[% l('February') %]</option>
+  <option value="03">[% l('March') %]</option>
+  <option value="04">[% l('April') %]</option>
+  <option value="05">[% l('May') %]</option>
+  <option value="06">[% l('June') %]</option>
+  <option value="07">[% l('July') %]</option>
+  <option value="08">[% l('August') %]</option>
+  <option value="09">[% l('September') %]</option>
+  <option value="10">[% l('October') %]</option>
+  <option value="11">[% l('November') %]</option>
+  <option value="12">[% l('December') %]</option>
+</select>
diff --git a/Open-ILS/src/templates/staff/serials/t_notes.tt2 b/Open-ILS/src/templates/staff/serials/t_notes.tt2
new file mode 100644 (file)
index 0000000..06ed074
--- /dev/null
@@ -0,0 +1,103 @@
+<form ng-submit="ok(note)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 ng-if="note_type == 'subscription'" class="modal-title">[% l('New Subscription Note') %]</h4>
+      <h4 ng-if="note_type == 'distribution'" class="modal-title">[% l('New Distribution Note') %]</h4>
+      <h4 ng-if="note_type == 'item'"         class="modal-title">[% l('New Item Note') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-6">
+          <input class="form-control" type="text"
+            ng-model="note.title" placeholder="[% l('Title...') %]"/>
+        </div>
+        <div class="col-md-3">
+          <label>
+            <input type="checkbox" ng-model="note.pub"/>
+            [% l('Public Note') %]
+          </label>
+          <label>
+            <input type="checkbox" ng-model="note.alert"/>
+            [% l('Alert Note') %]
+          </label>
+        </div>
+      </div>
+      <div class="row pad-vert">
+        <div class="col-md-12">
+          <textarea class="form-control" 
+            ng-model="note.value" placeholder="[% l('Note...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <div class="col-md-2">
+          <input type="text" class="form-control" ng-hide="!require_initials" 
+            ng-model="initials" placeholder="[% l('Initials') %]" ng-required="require_initials"/>
+        </div>
+        <div class="col-md-10 pull-right">
+          <input type="submit" class="btn btn-primary" value="[% l('OK') %]"/>
+          <button class="btn btn-warning" ng-click="cancel($event)">[% l('Cancel') %]</button>
+        </div>
+      </div>
+
+      <div class="row pad-vert" ng-if="note_list.length &gt; 0"> 
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-12">
+              <hr/>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">
+              <h4 ng-if="note_type == 'subscription'" class="pull-left">[% l('Existing Subscription Notes') %]</h4>
+              <h4 ng-if="note_type == 'distribution'" class="pull-left">[% l('Existing Distribution Notes') %]</h4>
+              <h4 ng-if="note_type == 'item'"         class="pull-left">[% l('Existing Item Notes') %]</h4>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="row" ng-repeat="n in note_list" ng-init="pub = n.pub() == 't'; alert = n.alert() == 't'; title = n.title(); value = n.value(); deleted = n.isdeleted()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6">
+              <input class="form-control" type="text" ng-change="n.title(title) && n.ischanged(1)"
+                ng-model="title" placeholder="[% l('Title...') %]" ng-disabled="deleted"/>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="pub" ng-change="n.pub(pub) && n.ischanged(1)" ng-disabled="deleted"/>
+                [% l('Public Note') %]
+              </label>
+              <label>
+                <input type="checkbox" ng-model="alert" ng-change="n.alert(alert) && n.ischanged(1)" ng-disabled="deleted"/>
+                [% l('Alert Note') %]
+              </label>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="deleted" ng-change="n.isdeleted(deleted)"/>
+                [% l('Deleted?') %]
+              </label>
+            </div>
+          </div>
+          <div class="row pad-vert">
+            <div class="col-md-12">
+              <textarea class="form-control" ng-change="n.value(value) && n.ischanged(1)"
+                ng-model="value" placeholder="[% l('Note...') %]" ng-disabled="deleted">
+              </textarea>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">
+              <hr/>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_pattern_editor_dialog.tt2 b/Open-ILS/src/templates/staff/serials/t_pattern_editor_dialog.tt2
new file mode 100644 (file)
index 0000000..c19ef8c
--- /dev/null
@@ -0,0 +1,15 @@
+<!-- use <form> so we get submit-on-enter for free -->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Edit Prediction Pattern') %]</h4>
+  </div>
+  <div class="modal-body">
+    <div class="container-fluid">
+      <eg-prediction-wizard pattern-code="patternCode" on-save="ok"
+                            show-share="showShare" view-only="viewOnly"
+      ></eg-prediction-wizard pattern-code>
+   </div>
+ </div>
+</div> <!-- modal-content -->
diff --git a/Open-ILS/src/templates/staff/serials/t_pattern_summary.tt2 b/Open-ILS/src/templates/staff/serials/t_pattern_summary.tt2
new file mode 100644 (file)
index 0000000..ce98556
--- /dev/null
@@ -0,0 +1,48 @@
+<div class="container prediction_pattern_summary">
+  <div class="row" ng-if="pattern.use_enum">
+    [% l('Enumeration captions:') %]
+    {{pattern.display_enum_captions()}}
+  </div>
+  <div class="row" ng-if="pattern.use_alt_enum">
+    [% l('Alternative enumeration captions:') %]
+    {{pattern.display_alt_enum_captions()}}
+  </div>
+  <div class="row" ng-if="pattern.use_chron">
+    [% l('Chronology captions:') %]
+    {{pattern.display_chron_captions()}}
+  </div>
+  <div class="row" ng-if="pattern.use_alt_chron">
+    [% l('Alternative chronology captions:') %]
+    {{pattern.display_alt_chron_captions()}}
+  </div>
+  <div class="row">
+    [% l('Frequency:') %]
+    <span ng-if="pattern.frequency_type == 'preset'">
+      <span ng-switch="pattern.frequency_preset">
+        <span ng-switch-when="d">[% l('Daily') %]</span>
+        <span ng-switch-when="w">[% l('Weekly (Weekly)') %]</span>
+        <span ng-switch-when="c">[% l('2 x per week (Semiweekly)') %]</span>
+        <span ng-switch-when="i">[% l('3 x per week (Three times a week)') %]</span>
+        <span ng-switch-when="e">[% l('Every two weeks (Biweekly)') %]</span>
+        <span ng-switch-when="m">[% l('Monthly') %]</span>
+        <span ng-switch-when="s">[% l('2 x per month (Semimonthly)') %]</span>
+        <span ng-switch-when="j">[% l('3 x per month (Three times a month)') %]</span>
+        <span ng-switch-when="b">[% l('Every other month (Bimonthly)') %]</span>
+        <span ng-switch-when="q">[% l('Quarterly') %]</span>
+        <span ng-switch-when="f">[% l('2 x per year (Semiannual)') %]</span>
+        <span ng-switch-when="t">[% l('3 x per year (Three times a year)') %]</span>
+        <span ng-switch-when="a">[% l('Yearly (Annual)') %]</span>
+        <span ng-switch-when="g">[% l('Every other year (Biennial)') %]</span>
+        <span ng-switch-when="h">[% l('Every three years (Triennial)') %]</span>
+        <span ng-switch-when="x">[% l('Completely irregular') %]</span>
+        <span ng-switch-when="k">[% l('Continuously updated') %]</span>
+      </span>
+    </span>
+    <span ng-if="pattern.frequency_type == 'numeric'">
+      [% l('[_1] issues per year', '{{pattern.frequency_numeric}}') %]
+    </span>
+  </div>
+  <div class="row" ng-if="pattern.use_regularity">
+    [% l('Specifies regularity adjustments') %]
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_prediction_manager.tt2 b/Open-ILS/src/templates/staff/serials/t_prediction_manager.tt2
new file mode 100644 (file)
index 0000000..f28b1b6
--- /dev/null
@@ -0,0 +1,73 @@
+<div>
+<eg-sub-selector bib-id="bibId" ssub-id="ssubId"></eg-sub-selector>
+</div>
+
+<div>
+  <div class="form-inline pad-vert">
+    <button class="btn btn-warning" ng-click="startNewScap()">[% l('Add New') %]</button>
+    <button class="btn btn-warning" ng-click="importScapFromBibRecord()" ng-disabled="!has_pattern_to_import">[% l('Import from Bibliographic and/or MFHD Records') %]</button>
+    <button class="btn btn-warning" ng-click="importScapFromSpt()">[% l('Create from Template') %]</button>
+    <select class="form-control" ng-model="active_pattern_template.id" ng-options="spt.id as spt.name for spt in pattern_templates | orderBy:'name'"> 
+    </select>
+  </div>
+  <div class="row" ng-if="new_prediction">
+    <ng-form name="forms.newpredform" class="form-inline">
+      <div class="col-md-1"></div>
+      <div class="col-md-1">
+        <label class="checkbox-inline">
+          <input type="checkbox" ng-model="new_prediction.active">[% l('Active') %]
+        </label>
+      </div>
+      <div class="col-md-2">
+        <label>[% l('Start Date') %]</label>
+          {{new_prediction.create_date | date:"shortDate"}}
+      </div>
+      <div class="col-md-3">
+          <label>[% l('Type') %]</label>
+          <select class="form-control" ng-model="new_prediction.type">
+              <option value="basic">[% l('Basic') %]</option>
+              <option value="supplement">[% l('Supplement') %]</option>
+              <option value="index">[% l('Index') %]</option>
+          </select>
+          <button class="btn btn-default" ng-if="new_prediction.pattern_code === null"
+                  ng-click="openPatternEditorDialog(new_prediction, forms.newpredform)">[% l('Create Pattern') %]</button>
+          <button class="btn btn-default" ng-if="new_prediction.pattern_code !== null"
+                  ng-click="openPatternEditorDialog(new_prediction, forms.newpredform)">[% l('Edit Pattern') %]</button>
+        </div>
+      <div>
+          <button type="submit" class="btn btn-default" ng-click="cancelNewScap()">[% l('Cancel') %]</button>
+          <button type="submit" class="btn btn-primary" ng-disabled="(new_prediction.pattern_code === null) || !forms.newpredform.$dirty" ng-click="createScap(new_prediction)">[% l('Create') %]</button>
+      </div>
+    </form>
+  </div>
+  <h3>[% l('Existing Prediction Patterns') %]</h3>
+  <div class="row" ng-repeat="pred in predictions | orderBy: 'id' as filtered track by pred.id">
+    <ng-form name="forms['predform' + pred.id]" class="form-inline">
+    <div class="col-md-1"><label>[% l('ID') %] {{pred.id}}</label></div>
+    <div class="col-md-1">
+      <label class="checkbox-inline">
+        <input type="checkbox" ng-model="pred.active">[% l('Active') %]
+      </label>
+    </div>
+    <div class="col-md-2">
+      <label>[% l('Start Date') %]</label>
+        {{pred.create_date | date:"shortDate"}}
+    </div>
+    <div class="col-md-3">
+        <label>[% l('Type') %]</label>
+        <select class="form-control" ng-model="pred.type">
+            <option value="basic">[% l('Basic') %]</option>
+            <option value="supplement">[% l('Supplement') %]</option>
+            <option value="index">[% l('Index') %]</option>
+        </select>
+        <button class="btn btn-default" ng-click="openPatternEditorDialog(pred, forms['predform' + pred.id], false)" ng-if=" pred._can_edit_or_delete">[% l('Edit Pattern') %]</button>
+        <button class="btn btn-default" ng-click="openPatternEditorDialog(pred, forms['predform' + pred.id], true)"  ng-if="!pred._can_edit_or_delete">[% l('View Pattern') %]</button>
+    </div>
+    <div>
+        <button class="btn btn-default" ng-disabled="forms['predform' + pred.id].$dirty" ng-click="add_issuances()">[% l('Predict New Issues') %]</button>
+        <button type="submit" class="btn btn-default" ng-disabled="!pred._can_edit_or_delete" ng-click="deleteScap(pred)">[% l('Delete') %]</button>
+        <button type="submit" class="btn btn-primary" ng-disabled="!forms['predform' + pred.id].$dirty" ng-click="updateScap(pred)">[% l('Save') %]</button>
+    </div>
+    </form>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_prediction_wizard.tt2 b/Open-ILS/src/templates/staff/serials/t_prediction_wizard.tt2
new file mode 100644 (file)
index 0000000..cd97232
--- /dev/null
@@ -0,0 +1,461 @@
+<div>
+   <div class="pull-right">
+      <div>
+        <button class="btn btn-warning" ng-click="tab.active = tab.active - 1"
+                ng-disabled="tab.active <= 0">
+            [% l('Back') %]
+        </button>
+        <button class="btn btn-success" ng-click="tab.active = tab.active + 1"
+                ng-disabled="!viewOnly && ((tab.active == 0 && tab.enum_form.$invalid) || (tab.active == 1 && tab.chron_form.$invalid) || (tab.active == 3 && tab.freq_form.$invalid))"
+                ng-if="tab.active != 4">
+            [% l('Next') %]
+        </button>
+        <button class="btn btn-primary" ng-click="handle_save()"
+                ng-if="!viewOnly && tab.active == 4">
+            [% l('Save') %]
+        </button>
+      </div>
+  </div>
+  <uib-tabset active="tab.active">
+    <uib-tab index="0" disable="tab.active != 0" heading="[% l('Enumeration Labels') %]">
+      <form name="tab.enum_form">
+      <fieldset ng-disabled="viewOnly">
+      <div class="row">
+         <div class="radio">
+           <label>
+             <input type="radio" ng-model="pattern.use_enum" ng-value="True">
+             [% l('Use Enumeration (e.g., v.1, no. 1)') %]
+           </label>
+           <eg-help-popover help-text="[% l('Use this if the serial includes volume or some other form of numbering.') %]">
+         </div>
+         <div class="radio">
+           <label>
+              <input type="radio" ng-model="pattern.use_enum" ng-value="False">
+              [% l('Use Calendar Dates Only (e.g., April 10)') %]
+            </label>
+            <eg-help-popover help-text="[% l('Use this if serial issues are referred to only by publication dates (or months or seasons).') %]">
+         </div>
+         <div class="row" ng-if="pattern.use_enum">
+            <div class="row" ng-repeat="enum_level in pattern.enum_levels">
+                <div class="col-md-1"></div>
+                <div class="col-md-1">[% l('Level [_1]', '{{$index + 1}}')  %]</div>
+                <div class="col-md-2"><input type="text" ng-model="enum_level.caption" required></div>
+                <div ng-if="$index > 0">
+                  <div class="col-md-3">
+                    <select ng-model="enum_level.units_per_next_higher.type">
+                      <option value="number">[% l('Number') %]</option>
+                      <option value="var">[% l('Varies') %]</option>
+                      <option value="und">[% l('Undetermined') %]</option>
+                    </select>
+                    <input type="number" step="1" 
+                           ng-model="enum_level.units_per_next_higher.value"
+                           ng-hide="enum_level.units_per_next_higher.type != 'number'"
+                    >
+                  </div>
+                  <div class="col-md-2">
+                    <div class="radio">
+                      <label>
+                        <input type="radio" ng-model="enum_level.restart" ng-value="True">
+                        [% l('Restarts at unit completion') %]
+                      </label>
+                    </div>
+                    <div class="radio">
+                      <label>
+                        <input type="radio" ng-model="enum_level.restart" ng-value="False">
+                        [% l('Increments continuously') %]
+                      </label>
+                    </div>
+                  </div>
+                </div>
+                <div class="col-md-3" ng-if="$last">
+                  <button class="btn btn-warning btn-sm"
+                      ng-if="pattern.enum_levels.length > 1"
+                      ng-click="pattern.drop_enum_level()">
+                      [% ('Remove Level') %]
+                  </button>
+                  <button class="btn btn-warning btn-sm"
+                      ng-disabled="pattern.enum_levels.length >= 6"
+                      ng-click="pattern.add_enum_level()">
+                      [% ('Add Level') %]
+                  </button>
+                </div>
+            </div>
+         </div>
+      </div>
+      <div ng-if="pattern.use_enum" class="row">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="pattern.use_alt_enum">
+            [% l('Add alternative enumeration') %]
+          </label>
+          <eg-help-popover help-text="[% l('If a serials is labeled in two different ways, use this to specify the second set of enumeration labels') %]">
+         </div>
+         <div class="row" ng-if="pattern.use_alt_enum">
+            <div class="row" ng-repeat="alt_enum_level in pattern.alt_enum_levels">
+                <div class="col-md-1"></div>
+                <div class="col-md-1">[% l('Level [_1]', '{{$index + 1}}')  %]</div>
+                <div class="col-md-2"><input type="text" required ng-model="alt_enum_level.caption"></div>
+                <div ng-if="$index > 0">
+                  <div class="col-md-3">
+                    <select ng-model="alt_enum_level.units_per_next_higher.type">
+                      <option value="number">[% l('Number') %]</option>
+                      <option value="var">[% l('Varies') %]</option>
+                      <option value="und">[% l('Undetermined') %]</option>
+                    </select>
+                    <input type="number" step="1" 
+                           ng-model="alt_enum_level.units_per_next_higher.value"
+                           ng-hide="alt_enum_level.units_per_next_higher.type != 'number'"
+                    >
+                  </div>
+                  <div class="col-md-2">
+                    <div class="radio">
+                      <label>
+                        <input type="radio" ng-model="alt_enum_level.restart" ng-value="True">
+                        [% l('Restarts at unit completion') %]
+                      </label>
+                    </div>
+                    <div class="radio">
+                      <label>
+                        <input type="radio" ng-model="alt_enum_level.restart" ng-value="False">
+                        [% l('Increments continuously') %]
+                      </label>
+                    </div>
+                  </div>
+                </div>
+                <div class="col-md-3" ng-if="$last">
+                  <button class="btn btn-warning btn-sm"
+                      ng-if="pattern.alt_enum_levels.length > 1"
+                      ng-click="pattern.drop_alt_enum_level()">
+                      [% ('Remove Level') %]
+                  </button>
+                  <button class="btn btn-warning btn-sm" 
+                      ng-disabled="pattern.alt_enum_levels.length >= 2"
+                      ng-click="pattern.add_alt_enum_level()">
+                      [% ('Add Level') %]
+                  </button>
+                </div>
+            </div>
+         </div>
+      </div>
+      <div ng-if="pattern.use_enum" class="row">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="pattern.use_calendar_change">
+            [% l('First level enumeration changes during subscription year') %]
+          </label>
+          <eg-help-popover help-text="[% l('For example, if the title has two volumes a year, use this to specify the month that the next volume starts.') %]">
+         </div>
+         <div ng-if="pattern.use_calendar_change">
+         <div class="row" ng-repeat="chg in pattern.calendar_change">
+           <div class="col-md-1"></div>
+           <div class="col-md-2">
+             <label>[% l('Change occurs') %]
+               <select ng-model="chg.type">
+                 <option value="date">[% l('Specific date') %]</option>
+                 <option value="month">[% l('Start of month') %]</option>
+                 <option value="season">[% l('Start of season') %]</option>
+               </select>
+             </label>
+           </div>
+           <div class="col-md-3">
+             <eg-month-selector     ng-model="chg.month"  ng-if="chg.type == 'month'" ></eg-month-selector>
+             <eg-season-selector    ng-model="chg.season" ng-if="chg.type == 'season'"></eg-season-selector>
+             <eg-month-day-selector day="chg.day" month="chg.month" ng-if="chg.type == 'date'"  ></eg-month-day-selector>
+           </div>
+           <div class="col-md-2">
+              <button ng-click="pattern.remove_calendar_change($index)" class="btn btn-sm btn-warning">[% l('Delete') %]</button>
+              <button ng-click="pattern.add_calendar_change()" ng-hide="!$last" class="btn btn-sm btn-warning">[% l('Add more') %]</button>
+           </div>
+         </div>
+         </div>
+      </div>
+      </fieldset>
+      </form>
+    </uib-tab>
+    <uib-tab index="1" disable="tab.active != 1" heading="[% l('Chronology Display') %]">
+      <form name="tab.chron_form">
+      <fieldset ng-disabled="viewOnly">
+      <div>
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="pattern.use_chron">
+            [% l('Use Chronology Captions?') %]
+          </label>
+        </div>
+        <div  ng-if="pattern.use_chron">
+          <div class="row">
+            <div class="col-md-4"></div>
+            <div class="col-md-4">[% l('Display level descriptor? E.g., "Year: 2017, Month: Feb" (not recommended)') %]</div>
+          </div>
+          <div class="row" ng-repeat="chron in pattern.chron_levels">
+            <div class="col-md-1"></div>
+            <div class="col-md-1">[% l('Level [_1]', '{{$index + 1}}')  %]</div>
+            <div class="col-md-2">
+              <eg-chron-selector ng-model="chron.caption" required chron-level="$index" linked-selector="chron_captions">
+            </div>
+            <div class="col-md-2">
+              <input type="checkbox" ng-model="chron.display_caption"></input>
+            </div>
+            <div class="col-md-4">
+              <button ng-if="$index > 0 && $last" ng-click="pattern.drop_chron_level()" class="btn btn-sm btn-warning">
+                [% l('Remove Level') %]
+              </button>
+              <button ng-if="$last && pattern.chron_levels.length < 4" ng-click="pattern.add_chron_level()" class="btn btn-sm btn-warning">
+                [% l('Add Level') %]
+              </button>
+            </div>
+          </div>
+          <div>
+            <div class="checkbox">
+              <label>
+                <input type="checkbox" ng-model="pattern.use_alt_chron">
+                [% l('Use Alternative Chronology Captions?') %]
+              </label>
+            </div>
+            <div ng-if="pattern.use_alt_chron">
+              <div class="row" ng-repeat="chron in pattern.alt_chron_levels">
+                <div class="col-md-1"></div>
+                <div class="col-md-1">[% l('Level [_1]', '{{$index + 1}}')  %]</div>
+                <div class="col-md-2">
+                  <eg-chron-selector ng-model="chron.caption" required chron-level="$index" linked-selector="alt_chron_captions">
+                </div>
+                <div class="col-md-2">
+                  <input type="checkbox" ng-model="chron.display_caption"></input>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      </fieldset>
+      </form>
+    </uib-tab>
+    <uib-tab index="2" disable="tab.active != 2" heading="[% l('MFHD Indicators') %]">
+      <form name="tab.ind_form">
+      <fieldset ng-disabled="viewOnly">
+      <div class="row">
+        <div class="col-md-6">
+          <label for="selectCompressExpand">[% l('Compression Display Options') %]
+            <eg-help-popover help-link="https://www.loc.gov/marc/holdings/hd853855.html"
+               help-text="[% l('Whether the pattern can be used to compress and expand detailed holdings statements.') %]">
+          </label>
+          <select ng-model="pattern.compress_expand">
+            <option value="0">[% l('Cannot compress or expand') %]</option>
+            <option value="1">[% l('Can compress but not expand') %]</option>
+            <option value="2">[% l('Can compress or expand') %]</option>
+            <option value="3">[% l('Unknown') %]</option>
+          </select>
+        </div>
+        <div class="col-md-6">
+          <label for="selectCompressExpand">[% l('Caption Evaluation') %]
+            <eg-help-popover help-link="https://www.loc.gov/marc/holdings/hd853855.html"
+               help-text="[% l('Completeness of the caption levels and whether the captions used actually appear on the bibliographic item.') %]">
+          </label>
+          <select ng-model="pattern.caption_evaluation">
+            <option value="0">[% l('Captions verified; all levels present') %]</option>
+            <option value="1">[% l('Captions verified; all levels may not be present') %]</option>
+            <option value="2">[% l('Captions unverified; all levels present') %]</option>
+            <option value="3">[% l('Captions unverified; all levels may not be present') %]</option>
+          </select>
+        </div>
+      </div>
+      </fieldset>
+      </form>
+    </uib-tab>
+    <uib-tab index="3" disable="tab.active != 3" heading="[% l('Frequency and Regularity') %]">
+      <form name="tab.freq_form">
+      <fieldset ng-disabled="viewOnly">
+      <div class="row">
+        <div class="col-md-2">
+          <div class="radio">
+            <label>
+              <input type="radio" ng-model="pattern.frequency_type" value="preset">
+              [% l('Pre-selected') %]
+            </label>
+          </div>
+          <div class="radio">
+            <label>
+              <input type="radio" ng-model="pattern.frequency_type" value="numeric">
+              [% l('Use number of issues per year') %]
+            </label>
+          </div>
+        </div>
+        <div class="col-md-2">
+          <div ng-if="pattern.frequency_type == 'preset'">
+            <select ng-model="pattern.frequency_preset" required>
+              <option value="d">[% l('Daily') %]</option>
+              <option value="w">[% l('Weekly (Weekly)') %]</option>
+              <option value="c">[% l('2 x per week (Semiweekly)') %]</option>
+              <option value="i">[% l('3 x per week (Three times a week)') %]</option>
+              <option value="e">[% l('Every two weeks (Biweekly)') %]</option>
+              <option value="m">[% l('Monthly') %]</option>
+              <option value="s">[% l('2 x per month (Semimonthly)') %]</option>
+              <option value="j">[% l('3 x per month (Three times a month)') %]</option>
+              <option value="b">[% l('Every other month (Bimonthly)') %]</option>
+              <option value="q">[% l('Quarterly') %]</option>
+              <option value="f">[% l('2 x per year (Semiannual)') %]</option>
+              <option value="t">[% l('3 x per year (Three times a year)') %]</option>
+              <option value="a">[% l('Yearly (Annual)') %]</option>
+              <option value="g">[% l('Every other year (Biennial)') %]</option>
+              <option value="h">[% l('Every three years (Triennial)') %]</option>
+              <option value="x">[% l('Completely irregular') %]</option>
+              <option value="k">[% l('Continuously updated') %]</option>
+            </select>
+          </div>
+          <div ng-if="pattern.frequency_type == 'numeric'">
+            <input ng-model="pattern.frequency_numeric" type="number" step="1" required>
+          </div>
+        </div>
+      </div>
+      <div class="row">
+        <div class="checkbox">
+          <label>
+            <input type="checkbox" ng-model="pattern.use_regularity">
+            [% l('Use specific regularity information?') %]
+          </label>
+            <em>[% l('(combined issues, skipped issues, etc.)') %]</em>
+         </div>
+         <div class="row" ng-if="pattern.use_regularity">
+            <div class="row pad-vert" ng-repeat="reg in pattern.regularity">
+               <div class="col-md-2">
+                 <button ng-click="pattern.remove_regularity($index)"
+                         class="btn btn-sm btn-warning">
+                   [% l('Remove Regularity') %]
+                 </button>
+                 <button ng-if="$last" ng-click="pattern.add_regularity()"
+                         class="btn btn-sm btn-warning">
+                   [% l('Add Regularity') %]
+                 </button>
+               </div>
+               <div class="col-md-1">
+                 <select ng-model="reg.regularity_type">
+                   <option value="p">[% l('Published') %]</option>
+                   <option value="o">[% l('Omitted') %]</option>
+                   <option value="c">[% l('Combined') %]</option>
+                 </select>
+               </div>
+               <div class="col-md-1">
+                 <select ng-model="reg.chron_type">
+                   <option value="d">[% l('Day') %]</option>
+                   <option value="w">[% l('Week') %]</option>
+                   <option value="m">[% l('Month') %]</option>
+                   <option value="s">[% l('Season') %]</option>
+                   <option value="y">[% l('Year') %]</option>
+                 </select>
+               </div>
+               <div class="col-md-6">
+                 <div class="row" ng-repeat="part in reg.parts">
+                   <div class="col-md-8" ng-if="reg.regularity_type == 'c'">
+                     <label>[% l('Combined issue code') %] <input type="text" ng-model="part.combined_code"></label>
+                   </div>
+                   <div class="col-md-8" ng-if="reg.regularity_type != 'c'">
+                     <div ng-if="reg.chron_type == 's'">
+                       <label>[% l('Every') %] <eg-season-selector ng-model="part.season"></eg-season-selector></label>
+                     </div>
+                     <div ng-if="reg.chron_type == 'm'">
+                       <label>[% l('Every') %] <eg-month-selector ng-model="part.month"></eg-month-selector></label>
+                     </div>
+                     <div ng-if="reg.chron_type == 'd'">
+                       <select ng-model="part.sub_type">
+                         <option value="day_of_month">[% l('On day of month') %]</option>
+                         <option value="specific_date">[% l('On specific date') %]</option>
+                         <option value="day_of_week">[% l('On day of week') %]</option>
+                       </select>
+                       <div ng-if="part.sub_type == 'day_of_month'">
+                         <input type="number" step="1" min="1" max="31" ng-model="part.day_of_month">
+                       </div>
+                       <div ng-if="part.sub_type == 'specific_date'">
+                          <eg-month-day-selector day="part.day" month="part.month"></eg-month-day-selector>
+                       </div>
+                       <div ng-if="part.sub_type == 'day_of_week'">
+                          <eg-day-of-week-selector ng-model="part.day_of_week"></eg-day-of-week-selector>
+                       </div>
+                     </div>
+                     <div ng-if="reg.chron_type == 'w'">
+                       <select ng-model="part.sub_type">
+                         <option value="week_in_month">[% l('Week and month') %]</option>
+                         <option value="week_day">[% l('Week and day') %]</option>
+                         <option value="week_day_in_month">[% l('Week, month, and day') %]</option>
+                       </select>
+                       <div ng-if="part.sub_type == 'week_in_month'">
+                         <eg-week-in-month-selector ng-model="part.week"></eg-week-in-month-selector>
+                         [% l('week in') %]
+                         <eg-month-selector ng-model="part.month"></eg-month-selector>
+                       </div>
+                       <div ng-if="part.sub_type == 'week_day'">
+                         <eg-week-in-month-selector ng-model="part.week"></eg-week-in-month-selector>
+                         [% l('week on') %]
+                         <eg-day-of-week-selector ng-model="part.day"></eg-day-of-week-selector>
+                       </div>
+                       <div ng-if="part.sub_type == 'week_day_in_month'">
+                         <eg-week-in-month-selector ng-model="part.week"></eg-week-in-month-selector>
+                         [% l('week on') %]
+                         <eg-day-of-week-selector ng-model="part.day"></eg-day-of-week-selector>
+                         [% l('in') %]
+                         <eg-month-selector ng-model="part.month"></eg-month-selector>
+                       </div>
+                     </div>
+                     <div ng-if="reg.chron_type == 'y'">
+                       <input type="number" min="1" max="9999" ng-model="part.year">
+                     </div>
+                   </div>
+                   <div class="col-md-4">
+                     <button  ng-click="pattern.remove_regularity_part(reg, $index)"
+                             class="btn btn-xs btn-warning">
+                       [% l('Remove Part') %]
+                     </button>
+                     <button ng-if="$last" ng-click="pattern.add_regularity_part(reg)"
+                             class="btn btn-xs btn-warning">
+                       [% l('Add Part') %]
+                     </button>
+                   </div>
+                 </div>
+               </div>
+            </div>
+         </div>
+      </div>
+      </fieldset>
+      </form>
+    </uib-tab>
+    <uib-tab index="4" disable="tab.active != 4" heading="[% l('Review') %]">
+      <div class="row">
+        <div class="col-md-2">
+          <span class="strong-text-2">[% l('Raw Pattern Code') %]</span>
+          <a class="pull-right" href ng-click="show_pattern_code = false"
+              title="[% l('Hide Raw Pattern Code') %]"
+              ng-show="show_pattern_code">
+              <span class="glyphicon glyphicon-resize-small"></span>
+          </a>
+          <a class="pull-right" href ng-click="show_pattern_code = true"
+              title="[% l('Show Raw Pattern Code') %]"
+              ng-hide="show_pattern_code">
+              <span class="glyphicon glyphicon-resize-full"></span>
+          </a>
+        </div>
+        <div class="col-md-6" ng-show="show_pattern_code">
+          <pre>{{pattern.compile_stringify()}}</pre>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-md-2">
+          <span class="strong-text-2">[% l('Pattern Summary') %]</span>
+        </div>
+        <div class="col-md-6">
+          <eg-prediction-pattern-summary pattern="pattern"></eg-prediction-pattern-summary>
+        </div>
+      </div>
+      <hr/>
+      <div class="row" ng-if="showShare && !viewOnly">
+        <div class="col-md-6">
+          <label for="pattern_name">[% l('Share this pattern using name') %]</label>
+          <input id="pattern_name" type="text" ng-model="share.pattern_name">
+        </div>
+        <div class="col-md-6">
+          <label for="share_depth">[% l('Share with') %]</label>
+          <eg-share-depth-selector id="share_depth" ng-model="share.depth"></eg-share-depth-selector>
+        </div>
+      </div>
+      <hr/>
+    </uib-tab>
+  </uib-tabset>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_print_routing_list.tt2 b/Open-ILS/src/templates/staff/serials/t_print_routing_list.tt2
new file mode 100644 (file)
index 0000000..e5da6f1
--- /dev/null
@@ -0,0 +1,15 @@
+<form ng-submit="ok()" role="form">
+<div class="modal-body">
+  <eg-embed-frame handlers="xulg" url="url" afterload="page_init"/>
+</div>
+
+<div class="modal-footer">
+  <div class="row">
+    <div class="col-md-10"></div>
+    <div class="col-md-2">
+      <input type="submit" ng-show="last" class="btn btn-primary" value='[% l('Done') %]'></input>
+      <input type="submit" ng-show="!last" class="btn btn-primary" value='[% l('Next') %]'></input>
+    </div>
+  </div>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_receive_alerts.tt2 b/Open-ILS/src/templates/staff/serials/t_receive_alerts.tt2
new file mode 100644 (file)
index 0000000..28c9b90
--- /dev/null
@@ -0,0 +1,76 @@
+<form ng-submit="ok(list)" role="form">
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 class="modal-title">{{ title }}</h4>
+</div>
+
+<div class="modal-body">
+  <div class="row">
+    <div class="col-md-12">
+      <span ng-show="{{mode == 'delete'}}">[% l('Will delete {{items}} item(s).') %]</span>
+      <span ng-show="{{mode == 'reset'}}">[% l('Will reset {{items}} item(s) to Expected and remove unit(s).') %]</span>
+      <span ng-show="{{mode == 'receive'}}">[% l('Will receive {{items}} item(s) without barcoding.') %]</span>
+      <span ng-show="{{mode == 'status'}}">[% l('Will change status of {{items}} item(s).') %]</span>
+    </div>
+  </div>
+
+  <div ng-show="{{ssub_alerts.length > 0}}">
+    <div class="pad-vert row">
+      <div class="col-md-12">
+        <b>[% l('Subscription alerts') %]</b>
+      </div>
+    </div>
+    <div class="row" ng-repeat="note in ssub_alerts">
+      <div class="col-md-12">
+        <dl class="dl-horizontal">
+          <dt>{{note.title()}}</dt>
+          <dd>{{note.value()}}</dd>
+        <dl>
+      </div>
+    </div>
+  </div>
+
+  <div ng-show="{{sdist_alerts.length > 0}}">
+    <div class="pad-vert row">
+      <div class="col-md-12">
+        <b>[% l('Item alerts') %]</b>
+      </div>
+    </div>
+    <div class="row" ng-repeat="note in sdist_alerts">
+      <div class="col-md-12">
+        <dl class="dl-horizontal">
+          <dt>{{note.title()}}</dt>
+          <dd>{{note.value()}}</dd>
+        <dl>
+      </div>
+    </div>
+  </div>
+
+  <div ng-show="{{sitem_alerts.length > 0}}">
+    <div class="pad-vert row">
+      <div class="col-md-12">
+        <b>[% l('Item alerts') %]</b>
+      </div>
+    </div>
+    <div class="row" ng-repeat="note in sitem_alerts">
+      <div class="col-md-12">
+        <dl class="dl-horizontal">
+          <dt>{{note.title()}}</dt>
+          <dd>{{note.value()}}</dd>
+        <dl>
+      </div>
+    </div>
+  </div>
+
+</div>
+
+<div class="modal-footer">
+  <div class="row">
+    <div class="col-md-12">
+      <input type="submit" class="btn btn-primary" value='[% l('OK/Continue') %]'></input>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_routing_list.tt2 b/Open-ILS/src/templates/staff/serials/t_routing_list.tt2
new file mode 100644 (file)
index 0000000..1520c5c
--- /dev/null
@@ -0,0 +1,118 @@
+<form ng-submit="ok(args)" role="form">
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 class="modal-title">
+        [% l('Manage Routing List for [_1]','{{stream_label}}') %]
+    </h4>
+</div>
+<style>
+/* odd/even row styling */
+.modal-header > div:nth-child(odd) {
+  background-color: rgb(248, 248, 248);
+}
+.strike {
+    text-decoration: line-through;
+}
+</style>
+<div class="modal-header">
+    <div ng-repeat="route in routes">
+        <div class="row">
+            <div class="col-md-2">
+                <span>
+                    [% l('[_1].','{{route.pos + 1}}') %]
+                </span>
+            </div>
+            <div class="col-md-8">
+                <span ng-show="route.reader" ng-class="route.delete_me ? 'strike' : ''">
+                {{route.reader.family_name}}, {{route.reader.first_given_name}}
+                ({{route.reader.home_ou.shortname}})
+                </span>
+                <span ng-show="route.department" ng-class="route.delete_me ? 'strike' : ''">
+                {{route.department}}
+                </span>
+            </div>
+            <div class="col-md-2">
+                <span>
+                    <a href ng-click="move_route_up(route)">&uarr;</a>
+                    <a href ng-click="move_route_down(route)">&darr;</a>
+                    <a href ng-click="toggle_delete(route)">&times;</a>
+                </span>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-2">
+            </div>
+            <div class="col-md-8" ng-class="route.delete_me ? 'strike' : ''">
+                {{route.note}}
+            </div>
+            <div class="col-md-2">
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal-body">
+    <div class="row">
+        <div class="col-md-1">
+            <input type="radio" name="which_radio_button"
+                ng-model="args.which_radio_button" value="reader">
+            </input>
+        </div>
+        <div class="col-md-3">
+            <label for="reader">
+                [% l('Reader (barcode):') %]
+            </label>
+        </div>
+        <div class="col-md-8">
+            <input type="text" ng-model="args.reader" id="reader" class="form-control"
+                ng-click="args.which_radio_button='reader'" focus-me="readerInFocus"
+                ng-model-options="{ debounce: 1000 }">
+            </input>
+            <span ng-show="args.reader && !readerNotFound">{{reader_obj.family_name}}, {{reader_obj.first_given_name}}</span>
+            <span class="alert alert-warning" ng-show="readerNotFound">
+                [% l('Not Found') %]
+            </span>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-1">
+            <input type="radio" name="which_radio_button"
+                ng-model="args.which_radio_button" value="department">
+            </input>
+        </div>
+        <div class="col-md-3">
+            <label for="department">
+                [% l('Department:') %]
+            </label>
+        </div>
+        <div class="col-md-8">
+            <input type="text" ng-model="args.department" id="department" class="form-control"
+                ng-click="args.which_radio_button='department'">
+            </input>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-1">
+        </div>
+        <div class="col-md-3">
+            <label for="note">[% l('Note:') %]</label>
+        </div>
+        <div class="col-md-8">
+            <input ng-model="args.note" type="text" id="note" class="form-control"></input>
+        </div>
+    </div>
+</div>
+
+<div class="modal-footer">
+    <button type="button" class="btn btn-primary pull-left"
+        ng-click="add_route()"
+        ng-disabled="(args[args.which_radio_button] == '')||(args.which_radio_button=='reader'&&readerNotFound)"
+    >
+        [% l('Add Route') %]
+    </button>
+    <input type="submit" class="btn btn-primary" ng-disabled="!model_has_changed"
+        value="[% l('Update') %]"></input>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_season_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_season_selector.tt2
new file mode 100644 (file)
index 0000000..f939503
--- /dev/null
@@ -0,0 +1,6 @@
+<select ng-model="ngModel">
+  <option value="21">[% l('Spring') %]</option>
+  <option value="22">[% l('Summer') %]</option>
+  <option value="23">[% l('Autumn') %]</option>
+  <option value="24">[% l('Winter') %]</option>
+</select>
diff --git a/Open-ILS/src/templates/staff/serials/t_select_pattern_dialog.tt2 b/Open-ILS/src/templates/staff/serials/t_select_pattern_dialog.tt2
new file mode 100644 (file)
index 0000000..1f900d7
--- /dev/null
@@ -0,0 +1,32 @@
+<form ng-submit="ok()" role="form">
+<div class="modal-header">
+    <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Select Patterns to Import') %]</h4>
+</div>
+
+<div class="modal-body">
+  <div ng-repeat="pot in potentials" class="row">
+    <div>
+      <div class="col-md-1">
+        <input type="checkbox" ng-model="pot.selected">
+      </div>
+      <div class="col-md-11">
+        <span ng-if="pot._classname == 'bre'">[% l('Bibliographic record [_1]', '{{pot.id}}') %]</span>
+        <span ng-if="pot._classname == 'sre'">[% l('MFHD record [_1]', '{{pot.id}}') %]</span>
+      </div>
+    </div>
+    <div>
+      <div class="col-md-1"></div>
+      <div class="col-md-11">
+        <pre>{{pot.desc}}</pre>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="modal-footer">
+  <input type="submit" class="btn btn-primary" value="[% l('Import') %]"></input>
+  <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+</div>
+</form>
diff --git a/Open-ILS/src/templates/staff/serials/t_sub_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_sub_selector.tt2
new file mode 100644 (file)
index 0000000..7995ed1
--- /dev/null
@@ -0,0 +1,17 @@
+<div class="form-inline">
+<label for="choose-subscription-ou-filter">[% l('At') %]</label>
+<eg-org-selector selected="owning_ou" onchange="owning_ou_changed"
+                 sticky-setting="serials.sub_selector.owning_ou_selector"
+>
+</eg-org-selector>
+<label for="choose-subscription">[% l('select subscription to work on') %]</label>
+<select class="form-control" id="choose-subscription" ng-model="ssubId">
+  <option ng-repeat="ssub in subscriptions | orderBy: 'id' as filtered track by ssub.id"
+          value="{{ssub.id}}">
+    [% l('Subscription [_1] at [_2] ([_3] - [_4])',
+        '{{ssub.id}}', '{{ssub.owning_lib.shortname()}}',
+        '{{ssub.start_date | date:"shortDate"}}',
+        '{{ssub.end_date | date:"shortDate"}}') %]
+  </option>
+</select>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_subscription_manager.tt2 b/Open-ILS/src/templates/staff/serials/t_subscription_manager.tt2
new file mode 100644 (file)
index 0000000..c104e9f
--- /dev/null
@@ -0,0 +1,157 @@
+<div>
+  <label>[% l('Subscriptions owned by or below') %]</label>
+  <eg-org-selector selected="owning_ou" onchange="owning_ou_changed"
+                   sticky-setting="serials.ssub_owning_lib_filter">
+  </eg-org-selector>
+  <span class="alert alert-warning" ng-show="subscriptions.length == 0">
+    [% l('No subscriptions are owned by this library') %]
+  </span>
+</div>
+<form name="ssubform" class="pad-vert">
+  <div class="form-inline" ng-repeat="ssub in subscriptions">
+    <div class="row form-inline">
+      <div class="form-group col-sm-2">
+        [% l('#[_1]', '{{ssub.id}}') %]
+        <label>[% l('Owned By') %]</label>
+        <eg-org-selector selected="ssub.owning_lib"></eg-org-selector>
+      </div>
+      <div class="form-group col-sm-3">
+        <div class="row">
+          <div class="form-group col-lg-6">
+            <label class="pull-right">[% l('Start Date') %]</label>
+          </div>
+          <div class="form-group col-lg-6">
+            <div class="pull-left"><eg-date-input ng-model="ssub.start_date" focus-me="ssub._focus_me"></eg-date-input></div>
+          </div>
+        </div>
+      </div>
+      <div class="form-group col-sm-3">
+        <div class="row">
+          <div class="form-group col-lg-6">
+            <label class="pull-right">[% l('End Date') %]</label>
+          </div>
+          <div class="form-group col-lg-6">
+            <div class="pull-left"><eg-date-input ng-model="ssub.end_date"></eg-date-input></div>
+          </div>
+        </div>
+      </div>
+      <div class="form-group col-sm-3">
+        <label>[% l('Expected Offset') %]
+          <eg-help-popover help-text="[% l('The difference between the nominal publishing date of an issue and the date that you expect to receive your copy.') %]">
+        </label>
+        <input class="form-control" type="text" ng-model="ssub.expected_date_offset"></input>
+      </div>
+      <div class="form-group col-sm-1">
+        <button class="btn btn-sm btn-warning" ng-click="add_distribution(ssub, true)">[% l('Add distribution') %]</button>
+      </div>
+    </div>
+    <div class="row form-inline pad-vert" ng-repeat="sdist in ssub.distributions">
+      <div class="row">
+        <div class="col-sm-1">
+            <button class="btn btn-xs btn-danger" ng-if="sdist._isnew && ssub.distributions.length > 1"
+                    ng-click="remove_pending_distribution(ssub, sdist)"
+            >[% l('Remove') %]</button>
+        </div>
+        <div class="col-sm-2">
+          <label>[% l('Distributed At') %]</label>
+          <eg-org-selector selected="sdist.holding_lib"></eg-org-selector>
+        </div>
+        <div class="col-sm-3">
+          <label>[% l('Label') %]</label>
+          <input class="form-control" type="text" required ng-model="sdist.label" focus-me="sdist._focus_me"></input>
+        </div>
+        <div class="col-sm-2">
+          <label>[% l('OPAC Display') %]
+            <eg-help-popover help-text="[% l('Whether the public catalog display of issues should be grouped by chronology (e.g., years) or enumeration (e.g., volume and number).') %]">
+          </label>
+          <select class="form-control" required ng-model="sdist.display_grouping">
+            <option value="chron">[% l('Chronological') %]</option>
+            <option value="enum" >[% l('Enumeration') %]</option>
+          </select>
+        </div>
+        <div class="col-sm-3">
+          <label>[% l('Receiving Template') %]</label>
+          <select class="form-control" ng-model="sdist.receive_unit_template"
+              ng-options="t.id as t.name for t in receiving_templates[sdist.holding_lib.id()]">
+              <option value=""></option>
+          </select>
+        </div>
+        <div class="col-sm-1" style="padding-left:0"><!-- Yes, it's terrible. But, nested grid alignment... -->
+          <button class="btn btn-sm btn-info" ng-click="add_stream(sdist, true)">[% l('Add copy stream') %]</button>
+        </div>
+      </div>
+      <div class="row form-inline pad-vert">
+        <div class="row form-inline" ng-repeat="sstr in sdist.streams">
+          <div class="col-sm-1"></div>
+          <div class="col-sm-1">
+            <button class="btn btn-xs btn-danger" ng-if="sstr._isnew && sdist.streams.length > 1"
+                    ng-click="remove_pending_stream(sdist, sstr)"
+            >[% l('Remove') %]</button>
+          </div>
+          <div class="col-sm-8">
+            <label>[% l('Send to') %]</label>
+            <eg-basic-combo-box list="localStreamNames" on-select="dirtyForm" selected="sstr.routing_label" focus-me="sstr._focus_me"></eg-basic-combo-box>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="row form-inline pad-vert"></div>
+  </div>
+  <div class="row form-inline">
+    <button class="btn btn-warning pull-left" ng-click="add_subscription()">[% l('New Subscription') %]</button>
+    <div class="btn-group pull-right">
+      <button class="btn btn-default" ng-disabled="!ssubform.$dirty" ng-click="abort_changes(ssubform)">[% l('Cancel') %]</button>
+      <button class="btn btn-primary" ng-disabled="!ssubform.$dirty" ng-click="save_subscriptions(ssubform)">[% l('Save') %]</button>
+    </div>
+  </div>
+  <div class="row pad-vert"></div>
+</form>
+<div>
+  <eg-grid
+    id-field="index"
+    features="-display,-sort,-multisort"
+    items-provider="distStreamGridDataProvider"
+    grid-controls="distStreamGridControls"
+    persist-key="serials.dist_stream_grid">
+
+    <eg-grid-action handler="apply_binding_template"
+      label="[% l('Apply Binding Template') %]"></eg-grid-action>
+    <eg-grid-action handler="additional_routing" disabled="need_one_selected"
+      label="[% l('Additional Routing') %]"></eg-grid-action>
+    <eg-grid-action handler="subscription_notes" disabled="need_one_selected"
+      label="[% l('Subscription Notes') %]"></eg-grid-action>
+    <eg-grid-action handler="distribution_notes" disabled="need_one_selected"
+      label="[% l('Distribution Notes') %]"></eg-grid-action>
+    <eg-grid-action handler="link_mfhd" disabled="need_one_selected"
+      label="[% l('Link MFHD') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_subscription"
+      label="[% l('Delete Subscription') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_distribution"
+      label="[% l('Delete Distribution') %]"></eg-grid-action>
+    <eg-grid-action handler="delete_stream"
+      label="[% l('Delete Stream') %]"></eg-grid-action>
+    <eg-grid-action handler="clone_subscription"
+      label="[% l('Clone Subscription') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Owning Library') %]" path="owning_lib.name" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Distribution Library') %]" path="sdist.holding_lib.name" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Distribution Label') %]" path="sdist.label" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Copy Stream') %]" path="sstr.id" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Offset') %]" path="expected_date_offset" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Start Date') %]" path="start_date" datatype="timestamp" visible></eg-grid-field>
+    <eg-grid-field label="[% l('End Date') %]" path="end_date" datatype="timestamp" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Route To') %]" path="sstr.routing_label" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Additional Routing') %]" path="sstr.additional_routing" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Receiving Template') %]" path="sdist.receive_unit_template.name"></eg-grid-field>
+    <eg-grid-field label="[% l('MFHD ID') %]" path="sdist.record_entry" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Summary Display') %]" path="sdist.summary_method" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Receiving Call Number') %]" path="sdist.receive_call_number.label"></eg-grid-field>
+    <eg-grid-field label="[% l('Binding Call Number') %]" path="sdist.bind_call_number.label"></eg-grid-field>
+    <eg-grid-field label="[% l('Binding Template') %]" path="sdist.bind_unit_template.name"></eg-grid-field>
+    <eg-grid-field label="[% l('Unit Label Prefix') %]" path="sdist.unit_label_prefix"></eg-grid-field>
+    <eg-grid-field label="[% l('Unit Label Suffix') %]" path="sdist.unit_label_suffix"></eg-grid-field>
+    <eg-grid-field label="[% l('Display Grouping') %]" path="sdist.display_grouping"></eg-grid-field>
+    <eg-grid-field label="[% l('Subscription ID') %]" path="id"></eg-grid-field>
+    <eg-grid-field label="[% l('Distribution ID') %]" path="sdist.id"></eg-grid-field>
+  </eg-grid>
+</div>
diff --git a/Open-ILS/src/templates/staff/serials/t_view_items_grid.tt2 b/Open-ILS/src/templates/staff/serials/t_view_items_grid.tt2
new file mode 100644 (file)
index 0000000..189e8ce
--- /dev/null
@@ -0,0 +1,117 @@
+<div>
+  <eg-grid
+    id-field="id"
+    features="-display,-sort,-multisort"
+    items-provider="itemGridProvider"
+    grid-controls="itemGridControls"
+    menu-label="[% l('Filter items... ') %]"
+    persist-key="serials.view_item_grid">
+
+    <eg-grid-menu-item handler="filter_items_all"
+      label="[% l('All') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item handler="filter_items_have"
+      label="[% l('Held') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item handler="filter_items_dont_have"
+      label="[% l('Not Held') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item divider="true"></eg-grid-menu-item>
+
+    <eg-grid-menu-item ng-repeat="status in svc.item_status_i18n"
+      label="[% l('Status:') %] {{status.label}}" handler-data="status"
+      handler="filter_items_by_status"></eg-grid-menu-item>
+
+
+    <eg-grid-menu-item handler="receive_next" standalone="true"
+        label="[% l('Receive Next') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item handler="add_issuances" standalone="true"
+        label="[% l('Predict New Issues') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item handler="add_special_issuance" standalone="true"
+        label="[% l('Add Special Issue') %]"></eg-grid-menu-item>
+
+    <eg-grid-menu-item handler="checkbox_handler"
+      label="[% l('Barcode on receive') %]"
+      checkbox="receive_and_barcode"
+      checked="receive_and_barcode"/>
+
+    <eg-grid-menu-item handler="checkbox_handler"
+      label="[% l('Print routing lists') %]"
+      checkbox="do_print_routing_lists"
+      checked="do_print_routing_lists"/>
+
+
+<!-- Hiding this for now ... seems unnecessary?
+    <eg-grid-menu-item handler="checkbox_handler"
+      label="[% l('Bind on receive') %]"
+      checkbox="receive_and_bind"
+      checked="receive_and_bind"/>
+-->
+
+
+    <eg-grid-action handler="menu_print_routing_lists"
+      label="[% l('Print routing lists') %]"></eg-grid-action>
+
+    <eg-grid-action handler="receive_selected"
+      disabled="need_expected"
+      label="[% l('Receive selected') %]"></eg-grid-action>
+
+    <eg-grid-action handler="bind_selected"
+      disabled="need_one_selected"
+      label="[% l('Barcode selected') %]"></eg-grid-action>
+
+    <eg-grid-action handler="bind_selected"
+      disabled="need_many_selected"
+      label="[% l('Bind selected') %]"></eg-grid-action>
+
+    <eg-grid-action handler="following_issuance"
+      disabled="need_one_selected"
+      label="[% l('Add following issue') %]"></eg-grid-action>
+
+    <eg-grid-action handler="edit_issuance_holding_code"
+      label="[% l('Edit issue holding codes') %]"></eg-grid-action>
+
+    <eg-grid-action handler="set_selected_as_claimed"
+      label="[% l('Mark as claimed') %]"></eg-grid-action>
+    <eg-grid-action handler="set_selected_as_discarded"
+      label="[% l('Mark as discarded') %]"></eg-grid-action>
+    <eg-grid-action handler="set_selected_as_not_published"
+      label="[% l('Mark as not published') %]"></eg-grid-action>
+    <eg-grid-action handler="set_selected_as_not_held"
+      label="[% l('Mark as not held') %]"></eg-grid-action>
+
+    <eg-grid-action handler="item_notes"
+      label="[% l('Item Notes') %]"></eg-grid-action>
+
+    <eg-grid-action handler="reset_selected"
+      label="[% l('Reset items') %]"></eg-grid-action>
+
+    <eg-grid-action handler="delete_items"
+      label="[% l('Delete items') %]"></eg-grid-action>
+
+    <eg-grid-field label="[% l('Distribution Library') %]" path="stream.distribution.holding_lib.name" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Issuance') %]" path="issuance.label" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Barcode') %]" path="unit.barcode" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Publication Date') %]" path="issuance.date_published" visible>{{item.issuance.date_published|date:'shortDate'}}</eg-grid-field>
+    <eg-grid-field label="[% l('Status') %]" path="status" sortable visible></eg-grid-field>
+    <eg-grid-field label="[% l('Date Expected') %]" path="date_expected" sortable visible>{{item.date_expected|date:'shortDate'}}</eg-grid-field>
+    <eg-grid-field label="[% l('Date Received') %]" path="date_received" sortable visible>{{item.date_received|date:'shortDate'}}</eg-grid-field>
+    <eg-grid-field label="[% l('Holding Type') %]" path="issuance.holding_type" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Route To') %]" path="stream.routing_label"></eg-grid-field>
+    <eg-grid-field label="[% l('Receiving Template') %]" path="stream.distribution.receive_unit_template.name" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Summary Display') %]" path="stream.distribution.summary_method" visible></eg-grid-field>
+    <eg-grid-field label="[% l('Receiving Call Number') %]" path="stream.distribution.receive_call_number.label"></eg-grid-field>
+    <eg-grid-field label="[% l('Binding Call Number') %]" path="stream.distribution.bind_call_number.label"></eg-grid-field>
+    <eg-grid-field label="[% l('Binding Template') %]" path="stream.distribution.bind_unit_template.name"></eg-grid-field>
+    <eg-grid-field label="[% l('Unit Label Prefix') %]" path="stream.distribution.unit_label_prefix"></eg-grid-field>
+    <eg-grid-field label="[% l('Unit Label Suffix') %]" path="stream.distribution.unit_label_suffix"></eg-grid-field>
+    <eg-grid-field label="[% l('Display Grouping') %]" path="stream.distribution.display_grouping"></eg-grid-field>
+    <eg-grid-field label="[% l('Subscription ID') %]" path="stream.distribution.subscription.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Distribution ID') %]" path="stream.distribution.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Stream ID') %]" path="stream.id"></eg-grid-field>
+    <eg-grid-field label="[% l('Item ID') %]" path="id"></eg-grid-field>
+  </eg-grid>
+</div>
+
diff --git a/Open-ILS/src/templates/staff/serials/t_week_in_month_selector.tt2 b/Open-ILS/src/templates/staff/serials/t_week_in_month_selector.tt2
new file mode 100644 (file)
index 0000000..56b1f55
--- /dev/null
@@ -0,0 +1,11 @@
+<select ng-model="ngModel">
+  <option value="99">[% l('Last') %]</option>
+  <option value="98">[% l('Next to Last') %]</option>
+  <option value="97">[% l('Third to Last') %]</option>
+  <option value="00">[% l('Every') %]</option>
+  <option value="01">[% l('First') %]</option>
+  <option value="02">[% l('Second') %]</option>
+  <option value="03">[% l('Third') %]</option>
+  <option value="04">[% l('Fourth') %]</option>
+  <option value="05">[% l('Fifth') %]</option>
+</select>
diff --git a/Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2 b/Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2
new file mode 100644 (file)
index 0000000..633445c
--- /dev/null
@@ -0,0 +1,14 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close"
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title">[% l('Edit MARC Holdings Record') %]</h4>
+  </div>
+  <div class="modal-body">
+    <eg-marc-edit-record dirty-flag="dirty_flag" marc-xml="args.marc_xml"
+        on-save="ok" in-place-mode="true" record-type="sre" save-label="[% l('Save') %]" />
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_mfhd_create_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_mfhd_create_dialog.tt2
new file mode 100644 (file)
index 0000000..c04d958
--- /dev/null
@@ -0,0 +1,25 @@
+<!--
+  MFHD creation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">Create new MFHD</h4> 
+  </div>
+  <div class="modal-body">
+    <label for="mfhd_lib_selector">
+      [% l('Select a library') %]
+    </label>
+    <eg-org-selector id="mfhd_lib_selector"
+      selected="mfhd_lib">
+    </eg-org-selector>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('Create') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_org_select_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_org_select_dialog.tt2
new file mode 100644 (file)
index 0000000..7981165
--- /dev/null
@@ -0,0 +1,22 @@
+<!--
+  Org selection interstitial
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{ title || '[% l('Select library') %]'}}</h4> 
+  </div>
+  <div class="modal-body">
+    <div class="row">
+      <div class="col-md-12">
+        <eg-org-selector sticky-setting="{{rememberMe}}" selected="ws_ou" focus-me="focus"></eg-org-selector>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
diff --git a/Open-ILS/src/templates/staff/share/t_subscription_select_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_subscription_select_dialog.tt2
new file mode 100644 (file)
index 0000000..eeea5d8
--- /dev/null
@@ -0,0 +1,22 @@
+<!--
+  Org selection interstitial
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">&times;</button>
+    <h4 class="modal-title alert alert-info">{{ title || '[% l('Select subscription') %]'}}</h4> 
+  </div>
+  <div class="modal-body">
+    <div class="row">
+      <div class="col-md-12">
+       <eg-sub-selector bib-id="record_id" ssub-id="ssubId"></eg-sub-selector>
+      </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
index 917ff75..dabd885 100644 (file)
@@ -169,16 +169,14 @@ function ListRenderer() {
     this._init.apply(this, arguments);
 }
 
+function page_init() {
+    list_renderer = new ListRenderer(xulG.routing_list_data);
+    list_renderer.render().print();
+}
+
 openils.Util.addOnLoad(
     function() {
-        if (!xulG) {
-            alert(
-                "This interface is not designed for use outside " +
-                "the staff client." /* XXX i18n */
-            );
-        } else {
-            list_renderer = new ListRenderer(xulG.routing_list_data);
-            list_renderer.render().print();
-        }
+        // assume we're NOT in the web staff client if we have xulG
+        if (typeof xulG !== 'undefined') return page_init();
     }
 );
diff --git a/Open-ILS/web/js/ui/default/staff/admin/serials/app.js b/Open-ILS/web/js/ui/default/staff/admin/serials/app.js
new file mode 100644 (file)
index 0000000..81f68e4
--- /dev/null
@@ -0,0 +1,592 @@
+angular.module('egSerialsAdmin',
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod', 'egUiMod', 'egGridMod'])
+
+.config(['$routeProvider','$locationProvider','$compileProvider', 
+ function($routeProvider , $locationProvider , $compileProvider) {
+
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); 
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/admin/serials/templates', {
+        templateUrl: './admin/serials/t_templates',
+        controller: 'TemplatesCtrl',
+        resolve : resolver
+    });
+
+    // default page 
+    $routeProvider.otherwise({
+        templateUrl : './admin/serials/t_splash',
+        resolve : resolver
+    });
+}])
+
+// cheating
+.factory("sharedScope",function(){
+    return {};
+})
+
+.factory('templateSvc', 
+       ['egCore','$q','$uibModal','ngToast',
+function(egCore , $q , $uibModal , ngToast ) {
+
+    var service = {
+    };
+
+    service.create_or_edit_template = function(id,ou,cb) {
+        $uibModal.open({
+            template: '<eg-serials-template template_id="' + id + '" owning_lib="' + ou + '"></eg-serials-template>',
+            controller:
+                   ['sharedScope','$uibModalInstance',
+            function(sharedScope , $uibModalInstance ) {
+                sharedScope.close_modal = function(count) { $uibModalInstance.close({}) }
+            }],
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(
+            function(args) {
+                if (cb) { cb(); }
+            }
+        );
+    }
+
+    service.delete_template = function(id,cb) {
+        return egCore.pcrud.search('act',
+            {id : id},
+            null, {atomic : true}
+        ).then(function(resp) {
+            var evt = egCore.evt.parse(resp);
+            if (evt) { console.log(evt); }
+            if (!evt && resp && resp.length > 0) {
+                return resp[0];
+            }
+        }).then(function(resp) {
+            resp.isdeleted(true); // needed?
+            return egCore.pcrud.remove(resp);
+        }).then(
+            function(resp) {
+                console.log(resp);
+                ngToast.success(egCore.strings.SERIALS_TEMPLATE_SUCCESS_DELETE);
+            },function(resp) {
+                console.log(resp);
+                ngToast.danger(egCore.strings.SERIALS_TEMPLATE_FAIL_DELETE);
+            }
+        ).finally(function() {
+            if (cb) { cb(); }
+        });
+    }
+
+    return service;
+}])
+
+.factory('itemSvc', 
+       ['egCore','$q',
+function(egCore , $q) {
+
+    var service = {
+    };
+
+    service.get_locations = function(orgs) {
+        return egCore.pcrud.search('acpl',
+            {
+                owning_lib : orgs,
+                deleted    : 'f'
+            },
+            {
+                flesh : 1,
+                flesh_fields : {
+                    'acpl' : ['owning_lib']
+                },
+                order_by : { acpl : 'name' }
+            }, {atomic : true}
+        );
+    };
+
+    service.get_statuses = function() {
+        if (egCore.env.ccs)
+            return $q.when(egCore.env.ccs.list);
+
+        return egCore.pcrud.retrieveAll('ccs', {order_by : { ccs : 'name' }}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'ccs');
+                return list;
+            }
+        );
+
+    };
+
+    service.get_circ_mods = function() {
+        if (egCore.env.ccm)
+            return $q.when(egCore.env.ccm.list);
+
+        return egCore.pcrud.retrieveAll('ccm', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'ccm');
+                return list;
+            }
+        );
+
+    };
+
+    service.get_circ_types = function() {
+        if (egCore.env.citm)
+            return $q.when(egCore.env.citm.list);
+
+        return egCore.pcrud.retrieveAll('citm', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'citm');
+                return list;
+            }
+        );
+
+    };
+
+    service.get_age_protects = function() {
+        if (egCore.env.crahp)
+            return $q.when(egCore.env.crahp.list);
+
+        return egCore.pcrud.retrieveAll('crahp', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'crahp');
+                return list;
+            }
+        );
+
+    };
+
+    service.get_floating_groups = function() {
+        if (egCore.env.cfg)
+            return $q.when(egCore.env.cfg.list);
+
+        return egCore.pcrud.retrieveAll('cfg', {}, {atomic : true}).then(
+            function(list) {
+                egCore.env.absorbList(list, 'cfg');
+                return list;
+            }
+        );
+
+    };
+
+    service.bmp_parts = {};
+    service.get_parts = function(rec) {
+        if (service.bmp_parts[rec])
+            return $q.when(service.bmp_parts[rec]);
+
+        return egCore.pcrud.search('bmp',
+            {record : rec, deleted : 'f'},
+            null, {atomic : true}
+        ).then(function(list) {
+            service.bmp_parts[rec] = list;
+            return list;
+        });
+
+    };
+
+    return service;
+}])
+
+.controller('TemplatesCtrl', 
+       ['$scope','$q','$window','$routeParams','$location','$timeout','egCore','egNet','itemSvc','templateSvc',
+        'egGridDataProvider',
+function($scope , $q , $window , $routeParams , $location , $timeout , egCore , egNet , itemSvc , templateSvc ,
+         egGridDataProvider ) {
+
+    function current_query() {
+        var filter = {
+            'owning_lib' : egCore.org.descendants($scope.context_ou.id(), true)
+        };
+        return filter;
+    }
+
+    function refresh_page() {
+        $scope.grid_controls.setQuery(current_query());
+    }
+
+    $scope.grid_actions = {
+        create_template : function() {
+            templateSvc.create_or_edit_template(null,$scope.context_ou.id(),refresh_page);
+        },
+        edit_template : function(items) {
+            templateSvc.create_or_edit_template(items[0].id,$scope.context_ou.id(),refresh_page);
+        },
+        delete_template : function(items) {
+            var promises = [];
+            angular.forEach(items,function(item) {
+                promises.push(templateSvc.delete_template(item.id));
+            });
+            $q.all(promises).then(function() {
+                refresh_page();
+            });
+        }
+    }
+    $scope.grid_controls = {
+        activateItem : function(item) {
+            templateSvc.create_or_edit_template(item.id,$scope.context_ou.id(),refresh_page);
+        },
+        setQuery : function(x) { return x || current_query(); },
+        setSort : function() { return ['name','id'] }
+    }
+
+    $scope.need_one_selected = function() {
+        var items = $scope.grid_controls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    // called after any egGridActions action occurs
+    $scope.grid_actions.refresh = refresh_page;
+
+    // re-draw the grid when user changes the org selector
+    $scope.context_ou = egCore.org.get(egCore.auth.user().ws_ou());
+    $scope.$watch('context_ou', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) 
+            refresh_page();
+    });
+
+    refresh_page();
+
+}])
+
+.directive("egSerialsTemplate", function () {
+    return {
+        restrict: 'E',
+        replace: true,
+        template: '<div ng-include="'+"'/eg/staff/admin/serials/t_attr_edit'"+'"></div>',
+        scope: {
+            templateId: '=',
+             owningLib: '='
+        },
+        controller : ['$scope','$q','$window','itemSvc','egCore','ngToast','sharedScope',
+            function ( $scope , $q , $window , itemSvc , egCore , ngToast , sharedScope ) {
+
+                $scope.close_modal = function() {
+                    if ($scope.dirty && !window.confirm(egCore.strings.CONFIRM_DIRTY_EXIT)) {
+                        return;
+                    }
+                    //console.log('unsetting dirty for close_modal');
+                    $scope.dirty = false;
+                    sharedScope.close_modal();
+                };
+
+                $scope.defaults = { // If defaults are not set at all, allow everything
+                    attributes : {
+                        status : true,
+                        loan_duration : true,
+                        fine_level : true,
+                        alerts : true,
+                        deposit : true,
+                        deposit_amount : true,
+                        opac_visible : true,
+                        price : true,
+                        circulate : true,
+                        mint_condition : true,
+                        circ_lib : true,
+                        ref : true,
+                        circ_modifier : true,
+                        circ_as_type : true,
+                        location : true,
+                        holdable : true,
+                        age_protect : true,
+                        floating : true
+                    }
+                };
+
+                $scope.fetchDefaults = function () {
+                    egCore.hatch.getItem('serials.copy.defaults').then(function(t) {
+                        if (t) {
+                            $scope.defaults = t;
+                        }
+                    });
+                }
+                $scope.fetchDefaults();
+
+                //console.log('unsetting dirty by default');
+                $scope.dirty = false;
+                $scope.$watch('dirty',
+                    function(newVal, oldVal) {
+                        //console.log('watching dirty');
+                        //console.log('...oldVal',oldVal);
+                        //console.log('...newVal',newVal);
+                        //console.log('...fetching',$scope.fetching);
+                        if (newVal && $scope.fetching) {
+                            // KLUDGY
+                            // so after fetchTemplate -> applyTemplate
+                            // the working watches will fire and set
+                            // dirty to true.  We'll undo that at this
+                            // point.
+                            //console.log('unsetting dirty via kludge');
+                            $scope.fetching = false;
+                            $scope.dirty = false;
+                            newVal = false;
+                        }
+                        if (newVal && newVal != oldVal) {
+                            $($window).on('beforeunload.template', function(){
+                                return 'There is unsaved template data!'
+                            });
+                        } else {
+                            $($window).off('beforeunload.template');
+                        }
+                    }
+                );
+
+                $scope.applyTemplate = function() {
+                    //console.log('applying...');
+                    angular.forEach($scope.hashed_template, function (v,k) {
+                        //console.log(k,v);
+                        if (k == 'circ_lib') {
+                            $scope.working[k] = egCore.org.get(v);
+                        } else if (!angular.isObject(v)) {
+                            $scope.working[k] = angular.copy(v);
+                        } else {
+                            angular.forEach(v, function (sv,sk) {
+                                if (!(k in $scope.working))
+                                    $scope.working[k] = {};
+                                $scope.working[k][sk] = angular.copy(sv);
+                            });
+                        }
+                    });
+                    //console.log('unsetting dirty via applyTemplate');
+                    $scope.dirty = false;
+                }
+
+                $scope.fetching = false;
+                $scope.fetchTemplate = function () {
+                    $scope.fetching = true;
+                    return egCore.pcrud.search('act',
+                        {id : $scope.templateId},
+                        null, {atomic : true}
+                    ).then(function(resp) {
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) { console.log(evt); }
+                        if (!evt && resp && resp.length > 0) {
+                            $scope.fm_template =  resp[0];
+                            $scope.hashed_template = egCore.idl.toHash(resp[0]); 
+                            $scope.applyTemplate();
+                        } else {
+                            console.log('new template');
+                        }
+                    });
+                }
+                $scope.saveTemplate = function() {
+                    var tmpl = {};
+        
+                    angular.forEach($scope.working, function (v,k) {
+                        if (angular.isObject(v)) { // we'll use the pkey
+                            if (v.id) v = v.id();
+                            else if (v.code) v = v.code();
+                        }
+        
+                        tmpl[k] = v;
+                    });
+        
+                    $scope.hashed_template = tmpl;
+
+                    var act_obj = $scope.fm_template || new egCore.idl.act() ;
+                    //console.log('consuming...');
+                    angular.forEach($scope.hashed_template, function (v,k) {
+                        //console.log(k,v);
+                        if (typeof act_obj[k] == 'function') {
+                            act_obj[k](v);
+                        } else {
+                            console.log('something wrong here',k,act_obj[k]);
+                        }
+                    });
+                    if ($scope.fm_template) {
+                        console.log('edit');
+                        act_obj.ischanged('t');
+                        act_obj.editor( egCore.auth.user().id() );
+                        act_obj.edit_date( new Date() );
+                    } else {
+                        console.log('create');
+                        act_obj.isnew('t');
+                        act_obj.creator( egCore.auth.user().id() );
+                        act_obj.owning_lib( $scope.owningLib );
+                        act_obj.create_date( new Date() );
+                    }
+                    var some_failure = false;
+                    var some_success = false;
+                    egCore.net.request(
+                        'open-ils.cat', // worth replacing with pcrud?
+                        'open-ils.cat.asset.copy_template.create_or_update',
+                        egCore.auth.token(),
+                        act_obj
+                    ).then(
+                        function(resp) {
+                            var evt = egCore.evt.parse(resp);
+                            if (evt) { // any way to just throw or return this to the error handler?
+                                console.log('failure',resp);
+                                some_failure = true;
+                                ngToast.danger(egCore.strings.SERIALS_TEMPLATE_FAIL_SAVE);
+                            } else {
+                                console.log('success',resp);
+                                some_success = true;
+                                ngToast.success(egCore.strings.SERIALS_TEMPLATE_SUCCESS_SAVE);
+                            }
+                        },
+                        function(resp) {
+                            console.log('failure',resp);
+                            some_failure = true;
+                            ngToast.danger(egCore.strings.SERIALS_TEMPLATE_FAIL_SAVE);
+                        }
+                    ).then(function(){
+                        if (some_success && !some_failure) {
+                            //console.log('unsetting dirty for save');
+                            $scope.dirty = false;
+                            $scope.close_modal();
+                        }
+                    });
+                }
+            
+                $scope.hashed_template = {};
+                $scope.imported_template = { data : '' };
+                $scope.fetchTemplate();
+
+                // FIXME - leaving this for now
+                $scope.$watch('imported_template.data', function(newVal, oldVal) {
+                    if (newVal && newVal != oldVal) {
+                        try {
+                            var newTemplate = JSON.parse(newVal);
+                            if (!Object.keys(newTemplate).length) return;
+                            $scope.hashed_template = newTemplate;
+                        } catch (E) {
+                            console.log('tried to import an invalid serials template file');
+                        }
+                    }
+                });
+
+                $scope.orgById = function (id) { return egCore.org.get(id) }
+                $scope.statusById = function (id) {
+                    return $scope.status_list.filter( function (s) { return s.id() == id } )[0];
+                }
+                $scope.locationById = function (id) {
+                    return $scope.location_cache[''+id];
+                }
+            
+                createSimpleUpdateWatcher = function (field) {
+                    $scope.$watch('working.' + field, function () {
+                        var newval = $scope.working[field];
+            
+                        if (typeof newval != 'undefined') {
+                            //console.log('setting dirty for field',field);
+                            $scope.dirty = true;
+                            if (angular.isObject(newval)) { // we'll use the pkey
+                                if (newval.id) $scope.working[field] = newval.id();
+                                else if (newval.code) $scope.working[field] = newval.code();
+                            }
+            
+                            if (""+newval == "" || newval == null) {
+                                $scope.working[field] = undefined;
+                            }
+            
+                        }
+                    });
+                }
+
+                $scope.clearWorking = function () {
+                    angular.forEach($scope.working, function (v,k,o) {
+                        if (!angular.isObject(v)) {
+                            if (typeof v != 'undefined')
+                                $scope.working[k] = undefined;
+                        } else if (k != 'circ_lib') {
+                            angular.forEach(v, function (sv,sk) {
+                                $scope.working[k][sk] = undefined;
+                            });
+                        }
+                    });
+                    $scope.working.circ_lib = undefined; // special
+                    $scope.working.loan_duration = 2;
+                    $scope.working.fine_level    = 2;
+                    //console.log('unsetting dirty for clearWorking');
+                    $scope.dirty = false;
+                }
+
+                $scope.working = {
+                    loan_duration : 2,
+                    fine_level    : 2
+                };
+                $scope.location_orgs = [];
+                $scope.location_cache = {};
+
+                $scope.i18n = egCore.i18n;
+                $scope.location_list = [];
+                itemSvc.get_locations(
+                    egCore.org.fullPath( egCore.auth.user().ws_ou(), true )
+                ).then(function(list){
+                    $scope.location_list = list;
+                });
+                createSimpleUpdateWatcher('location');
+
+                $scope.status_list = [];
+                itemSvc.get_statuses().then(function(list){
+                    $scope.status_list = list;
+                });
+                createSimpleUpdateWatcher('status');
+            
+                $scope.circ_modifier_list = [];
+                itemSvc.get_circ_mods().then(function(list){
+                    $scope.circ_modifier_list = list;
+                });
+                createSimpleUpdateWatcher('circ_modifier');
+            
+                $scope.circ_type_list = [];
+                itemSvc.get_circ_types().then(function(list){
+                    $scope.circ_type_list = list;
+                });
+                createSimpleUpdateWatcher('circ_as_type');
+            
+                $scope.age_protect_list = [];
+                itemSvc.get_age_protects().then(function(list){
+                    $scope.age_protect_list = list;
+                });
+                createSimpleUpdateWatcher('age_protect');
+            
+                createSimpleUpdateWatcher('circulate');
+                createSimpleUpdateWatcher('holdable');
+
+                $scope.loan_duration_options = [
+                    {
+                        v: function(){return 1;},
+                        l: function(){return egCore.strings.LOAN_DURATION_SHORT;}
+                    },
+                    {
+                        v: function(){return 2;},
+                        l: function(){return egCore.strings.LOAN_DURATION_NORMAL;}
+                    },
+                    {
+                        v: function(){return 3;},
+                        l: function(){return egCore.strings.LOAN_DURATION_EXTENDED;}
+                    }
+                ];
+                createSimpleUpdateWatcher('loan_duration');
+
+                $scope.fine_level_options = [
+                    {
+                        v: function(){return 1;},
+                        l: function(){return egCore.strings.FINE_LEVEL_LOW;}
+                    },
+                    {
+                        v: function(){return 2;},
+                        l: function(){return egCore.strings.FINE_LEVEL_NORMAL;}
+                    },
+                    {
+                        v: function(){return 3;},
+                        l: function(){return egCore.strings.FINE_LEVEL_HIGH;}
+                    }
+                ];
+                createSimpleUpdateWatcher('fine_level');
+
+                createSimpleUpdateWatcher('name');
+                createSimpleUpdateWatcher('price');
+                createSimpleUpdateWatcher('deposit');
+                createSimpleUpdateWatcher('deposit_amount');
+                createSimpleUpdateWatcher('mint_condition');
+                createSimpleUpdateWatcher('opac_visible');
+                createSimpleUpdateWatcher('ref');
+            }
+        ]
+    }
+})
+
+
diff --git a/Open-ILS/web/js/ui/default/staff/admin/serials/pattern_template.js b/Open-ILS/web/js/ui/default/staff/admin/serials/pattern_template.js
new file mode 100644 (file)
index 0000000..1585bf4
--- /dev/null
@@ -0,0 +1,135 @@
+angular.module('egAdminConfig',
+    ['ngRoute','ui.bootstrap','egCoreMod','egUiMod','egGridMod','egFmRecordEditorMod','egSerialsMod','egSerialsAppDep'])
+
+.controller('PatternTemplate',
+       ['$scope','$q','$timeout','$location','$window','$uibModal','egCore','egGridDataProvider',
+        'egConfirmDialog','ngToast',
+function($scope , $q , $timeout , $location , $window , $uibModal , egCore , egGridDataProvider ,
+         egConfirmDialog , ngToast) {
+
+    egCore.startup.go(); // standalone mode requires manual startup
+
+    $scope.new_record = function() {
+        spawn_editor();
+    }
+
+    $scope.need_one_selected = function() {
+        var items = $scope.gridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.edit_record = function(items) {
+        if (items.length != 1) return;
+        spawn_editor(items[0].id);
+    }
+
+    spawn_editor = function(id) {
+        var templ;
+        if (arguments.length == 1) {
+            templ = '<eg-edit-fm-record idl-class="spt" mode="update" record-id="id" on-save="ok" on-cancel="cancel" custom-field-templates="customFieldTemplates"></eg-edit-fm-record>';
+        } else {
+            templ = '<eg-edit-fm-record idl-class="spt" mode="create" on-save="ok" on-cancel="cancel" custom-field-templates="customFieldTemplates" org-default-allowed="owning_lib"></eg-edit-fm-record>';
+        }
+        gridControls = $scope.gridControls;
+        $uibModal.open({
+            template : templ,
+            controller : [
+                        '$scope', '$uibModalInstance',
+                function($scope ,  $uibModalInstance) {
+                    $scope.id = id;
+
+                    $scope.openPatternEditorDialog = function(pred) {
+                        $uibModal.open({
+                            templateUrl: './serials/t_pattern_editor_dialog',
+                            size: 'lg',
+                            windowClass: 'eg-wide-modal',
+                            backdrop: 'static',
+                            controller:
+                                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                                $scope.focusMe = true;
+                                $scope.showShare = false;
+                                $scope.patternCode = pred.pattern_code;
+                                $scope.ok = function(patternCode) { $uibModalInstance.close(patternCode) }
+                                $scope.cancel = function () { $uibModalInstance.dismiss() }
+                            }]
+                        }).result.then(function (patternCode) {
+                            if (pred.pattern_code !== patternCode) {
+                                pred.pattern_code = patternCode;
+                            }
+                        });
+                    }
+
+                    $scope.customFieldTemplates = {
+                        share_depth : {
+                            template : '<eg-share-depth-selector ng-model="rec_flat[field.name]">'
+                        },
+                        pattern_code : {
+                            handlers : {
+                                openPatternEditorDialog : $scope.openPatternEditorDialog
+                            },
+                            template : '<button class="btn btn-default" ng-click="field.handlers.openPatternEditorDialog(rec_flat)">Pattern Wizard</button>' + // FIXME i18n
+                                       // using a required hidden input as a way to ensure that
+                                       // the pattern wizard has been used
+                                       '<input type="hidden" required ng-model="rec_flat[field.name]">'
+                        }
+                    }
+
+                    $scope.ok = function($event) {
+                        $uibModalInstance.close();
+                        gridControls.refresh();
+                    }
+    
+                    $scope.cancel = function($event) {
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        });
+    }
+
+    $scope.delete_selected = function(selected) {
+        if (!selected || !selected.length) return;
+        var ids = selected.map(function(rec) { return rec.id });
+
+        egConfirmDialog.open(
+            egCore.strings.EG_CONFIRM_DELETE_PATTERN_TEMPLATE_TITLE,
+            egCore.strings.EG_CONFIRM_DELETE_PATTERN_TEMPLATE_BODY,
+            { count : ids.length }
+        ).result.then(function() {
+            var promises = [];
+            var list = [];
+            angular.forEach(selected, function(rec) {
+                promises.push(
+                    egCore.pcrud.retrieve('spt', rec.id).then(function(r) {
+                        list.push(r);
+                    })
+                );
+            })
+            $q.all(promises).then(function() {
+                egCore.pcrud.remove(list).then(function() {
+                    ngToast.success(egCore.strings.PATTERN_TEMPLATE_SUCCESS_DELETE);
+                    $scope.gridControls.refresh();
+                },
+                function() {
+                    ngToast.success(egCore.strings.PATTERN_TEMPLATE_FAIL_DELETE);
+                });
+            });
+        });
+    }
+
+    function generateQuery() {
+        return {
+            'id' : { '!=' : null },
+        }
+    }
+
+    $scope.gridControls = {
+        setQuery : function() {
+            return generateQuery();
+        },
+        setSort : function() {
+            return ['owning_lib.name','name'];
+        }
+    }
+}])
index 8b541f5..faa0954 100644 (file)
@@ -7,7 +7,8 @@
  *
  */
 
-angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','ngLocationUpdate','egCoreMod','egGridMod', 'egMarcMod', 'egUserMod', 'egHoldingsMod', 'ngToast','egPatronSearchMod'])
+angular.module('egCatalogApp', ['ui.bootstrap','ngRoute','ngLocationUpdate','egCoreMod','egGridMod', 'egMarcMod', 'egUserMod', 'egHoldingsMod', 'ngToast','egPatronSearchMod',
+'egSerialsMod','egSerialsAppDep'])
 
 .config(['ngToastProvider', function(ngToastProvider) {
   ngToastProvider.configure({
@@ -246,10 +247,10 @@ function($scope , $routeParams , $location , $window , $q , egCore) {
 .controller('CatalogCtrl',
        ['$scope','$routeParams','$location','$window','$q','egCore','egHolds','egCirc','egConfirmDialog','ngToast',
         'egGridDataProvider','egHoldGridActions','egProgressDialog','$timeout','$uibModal','holdingsSvc','egUser','conjoinedSvc',
-        '$cookies',
+        '$cookies','egSerialsCoreSvc',
 function($scope , $routeParams , $location , $window , $q , egCore , egHolds , egCirc , egConfirmDialog , ngToast ,
          egGridDataProvider , egHoldGridActions , egProgressDialog , $timeout , $uibModal , holdingsSvc , egUser , conjoinedSvc,
-         $cookies
+         $cookies , egSerialsCoreSvc
 ) {
 
     var holdingsSvcInst = new holdingsSvc();
@@ -365,6 +366,62 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
     $scope.current_voltransfer_target = egCore.hatch.getLocalItem('eg.cat.marked_volume_transfer_record');
     $scope.current_conjoined_target   = egCore.hatch.getLocalItem('eg.cat.marked_conjoined_record');
 
+    $scope.quickReceive = function () {
+        var list = [];
+        var next_per_stream = {};
+
+        var recId = $scope.record_id;
+        return $uibModal.open({
+            templateUrl: './share/t_subscription_select_dialog',
+            controller: ['$scope', '$uibModalInstance',
+                function($scope, $uibModalInstance) {
+
+                    $scope.focus = true;
+                    $scope.rememberMe = 'eg.serials.quickreceive.last_org';
+                    $scope.record_id = recId;
+                    $scope.ssubId = null;
+
+                    $scope.ok = function() { $uibModalInstance.close($scope.ssubId) }
+                    $scope.cancel = function() { $uibModalInstance.dismiss(); }
+                }
+            ]
+        }).result.then(function(ssubId) {
+            if (ssubId) {
+                var promises = [];
+                promises.push(egSerialsCoreSvc.fetchItemsForSub(ssubId,{status:'Expected'}).then(function(){
+                    angular.forEach(egSerialsCoreSvc.itemTree, function (item) {
+                        if (next_per_stream[item.stream().id()]) return;
+                        if (item.status() == 'Expected') {
+                            next_per_stream[item.stream().id()] = item;
+                            list.push(egCore.idl.Clone(item));
+                        }
+                    });
+                }));
+
+                return $q.all(promises).then(function() {
+
+                    if (!list.length) {
+                        ngToast.warning(egCore.strings.SERIALS_NO_ITEMS);
+                        return $q.reject();
+                    }
+
+                    return egSerialsCoreSvc.process_items(
+                        'receive',
+                        $scope.record_id,
+                        list,
+                        true, // barcode
+                        false,// bind
+                        false, // print by default
+                        function() { $scope.holdings_record_id_changed($scope.record_id) }
+                    );
+                });
+            } else {
+                ngToast.warning(egCore.strings.SERIALS_NO_SUBS);
+                return $q.reject();
+            }
+        });
+    }
+
     $scope.markConjoined = function () {
         $scope.current_conjoined_target = $scope.record_id;
         egCore.hatch.setLocalItem('eg.cat.marked_conjoined_record',$scope.record_id);
diff --git a/Open-ILS/web/js/ui/default/staff/serials/app.js b/Open-ILS/web/js/ui/default/staff/serials/app.js
new file mode 100644 (file)
index 0000000..31f925e
--- /dev/null
@@ -0,0 +1,69 @@
+angular.module('egSerialsApp', ['ui.bootstrap','ngRoute','egCoreMod','egGridMod','ngToast','egSerialsMod','egMfhdMod','egMarcMod','egSerialsAppDep']);
+angular.module('egSerialsAppDep', []);
+
+angular.module('egSerialsApp')
+.config(['ngToastProvider', function(ngToastProvider) {
+  ngToastProvider.configure({
+    verticalPosition: 'bottom',
+    animation: 'fade'
+  });
+}])
+
+.config(function($routeProvider, $locationProvider, $compileProvider) {
+    $locationProvider.html5Mode(true);
+    $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|blob):/); // grid export
+
+    var resolver = {delay : function(egStartup) {return egStartup.go()}};
+
+    $routeProvider.when('/serials/:bib_id', {
+        templateUrl: './serials/t_manage',
+        controller: 'ManageCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/serials/:bib_id/:active_tab', {
+        templateUrl: './serials/t_manage',
+        controller: 'ManageCtrl',
+        resolve : resolver
+    });
+
+    $routeProvider.when('/serials/:bib_id/:active_tab/:subscription_id', {
+        templateUrl: './serials/t_manage',
+        controller: 'ManageCtrl',
+        resolve : resolver
+    });
+})
+
+.controller('ManageCtrl',
+       ['$scope','$routeParams','$location','egSerialsCoreSvc',
+function($scope , $routeParams , $location , egSerialsCoreSvc) {
+    $scope.bib_id = $routeParams.bib_id;
+    $scope.active_tab = $routeParams.active_tab ?  $routeParams.active_tab : 'manage-subscriptions';
+    $scope.ssub = {id : null};
+    if ($routeParams.subscription_id) {
+        egSerialsCoreSvc.verify_subscription_id($scope.bib_id, $routeParams.subscription_id)
+        .then(function(verified) {
+            if (verified) {
+                $scope.ssub.id = $routeParams.subscription_id;
+            } else {
+                // subscription ID is no good, so drop it from the URL
+                $location.path('/serials/' + $scope.bib_id + '/' + $scope.active_tab);
+            }
+        });
+    }
+    $scope.$watch('ssub.id', function(newVal, oldVal) {
+        if (oldVal != newVal) {
+            $location.path('/serials/' + $scope.bib_id + '/' + $scope.active_tab +
+                           '/' + $scope.ssub.id);
+        }
+    });
+    $scope.$watch('active_tab', function(newVal, oldVal) {
+        if (oldVal != newVal) {
+                var new_path = '/serials/' + $scope.bib_id + '/' + $scope.active_tab;
+                if ($scope.ssub.id && $scope.active_tab != 'manage-subscriptions') {
+                    new_path += '/' + $scope.ssub.id;
+                }
+                $location.path(new_path);
+        }
+    });
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/item_manager.js b/Open-ILS/web/js/ui/default/staff/serials/directives/item_manager.js
new file mode 100644 (file)
index 0000000..bedc656
--- /dev/null
@@ -0,0 +1,20 @@
+angular.module('egSerialsAppDep')
+
+.directive('egItemManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+            ssubId : '='
+        },
+        templateUrl: './serials/t_item_manager',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','$uibModal',
+function($scope , $q , egSerialsCoreSvc , egCore , $uibModal) {
+
+    egSerialsCoreSvc.fetch($scope.bibId);
+
+}]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/mfhd_manager.js b/Open-ILS/web/js/ui/default/staff/serials/directives/mfhd_manager.js
new file mode 100644 (file)
index 0000000..c754cf8
--- /dev/null
@@ -0,0 +1,97 @@
+angular.module('egSerialsAppDep')
+
+.directive('egMfhdManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+        },
+        templateUrl: './serials/t_mfhd_manager',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','$timeout','egMfhdCreateDialog','egConfirmDialog',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , $timeout , egMfhdCreateDialog , egConfirmDialog) {
+
+    function reload() {
+        egSerialsCoreSvc.fetch_mfhds($scope.bibId).then(function() {
+            $scope.mfhdGridDataProvider.refresh();
+        });
+    }
+    reload();
+
+    $scope.mfhdGridControls = {
+        activateItem : function (item) { } // TODO
+    };
+    $scope.mfhdGridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier(egSerialsCoreSvc.flatMfhdList, offset, count);
+        }
+    });
+    $scope.need_one_selected = function() {
+        var items = $scope.mfhdGridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.createMfhd = function() {
+        egMfhdCreateDialog.open($scope.bibId).result.then(function() {
+            reload();
+        });
+    };
+
+    $scope.edit_mfhd = function() {
+        var items = $scope.mfhdGridControls.selectedItems();
+        if (items.length != 1) return;
+        var args = {
+            'marc_xml' : items[0].marc_xml
+        }
+        $uibModal.open({
+            templateUrl: './share/t_edit_mfhd',
+            size: 'lg',
+            controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.focusMe = true;
+                $scope.args = args;
+                $scope.dirty_flag = false;
+                $scope.ok = function() { $uibModalInstance.close($scope.args) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }]
+        }).result.then(function (args) {
+            egCore.pcrud.retrieve('sre', items[0].id).then(function(sre) {
+                sre.marc(args.marc_xml);
+                egCore.pcrud.update(sre).then(function() {
+                    reload();
+                });
+            });
+        });
+    };
+
+    $scope.delete_mfhds = function() {
+        var items = $scope.mfhdGridControls.selectedItems();
+        if (items.length <= 0) return;
+        
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_MFHDS,
+            egCore.strings.CONFIRM_DELETE_MFHDS_MESSAGE,
+            {items : items.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(items, function(mfhd) {
+                var promise = $q.defer();
+                promises.push(promise.promise);    
+                egCore.pcrud.retrieve('sre', mfhd.id).then(function(sre) {
+                    egCore.pcrud.remove(sre).then(function() {
+                        promise.resolve();
+                    });
+                })
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+}]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_manager.js b/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_manager.js
new file mode 100644 (file)
index 0000000..d0cd44f
--- /dev/null
@@ -0,0 +1,203 @@
+angular.module('egSerialsAppDep')
+
+.directive('egPredictionManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+            ssubId : '='
+        },
+        templateUrl: './serials/t_prediction_manager',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','$timeout','$location','egConfirmDialog','ngToast',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , $timeout , $location , egConfirmDialog , ngToast) {
+
+    $scope.has_pattern_to_import = false;
+    $scope.forms = [];
+    egSerialsCoreSvc.fetch($scope.bibId).then(function() {
+        reload($scope.ssubId);
+        egSerialsCoreSvc.fetch_patterns_from_bibs_mfhds($scope.bibId).then(function() {
+            if (egSerialsCoreSvc.potentialPatternList.length > 0) {
+                $scope.has_pattern_to_import = true;
+            }
+        });
+    });
+
+    function reload(ssubId) {
+        if (!ssubId) return;
+        var ssub = egSerialsCoreSvc.get_ssub(ssubId);
+        $scope.predictions = egCore.idl.toTypedHash(ssub.scaps());
+        angular.forEach($scope.predictions, function(pred) {
+            pred._can_edit_or_delete = false;
+            egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.caption_and_pattern.safe_delete.dry_run',
+                egCore.auth.token(),
+                pred.id
+            ).then(function(result) {
+                if (result == 1) pred._can_edit_or_delete = true;
+            });
+        });
+        egSerialsCoreSvc.fetch_spt().then(function() {
+            $scope.pattern_templates = egCore.idl.toTypedHash(egSerialsCoreSvc.sptList);
+            $scope.active_pattern_template = { id : null };
+            if ($scope.pattern_templates.length > 0) {
+                $scope.active_pattern_template.id = $scope.pattern_templates[0].id;
+            }
+        });
+    }
+
+    $scope.createScap = function(pred) {
+        var scap = egCore.idl.fromTypedHash(pred);
+        egCore.pcrud.create(scap).then(function() {
+            // completely reset the model in order to reset the
+            // forms; causes a blink, alas
+            $scope.predictions = [];
+            $scope.new_prediction = null;
+            egSerialsCoreSvc.fetch($scope.bibId).then(function() {
+                reload($scope.ssubId);
+            });
+        });
+    }
+    $scope.updateScap = function(pred) {
+        var scap = egCore.idl.fromTypedHash(pred);
+        egCore.pcrud.update(scap).then(function() {
+            // completely reset the model in order to reset the
+            // forms; causes a blink, alas
+            $scope.predictions = [];
+            egSerialsCoreSvc.fetch($scope.bibId).then(function() {
+                reload($scope.ssubId);
+            });
+        });
+    }
+    $scope.deleteScap = function(pred) {
+        var scap = egCore.idl.fromTypedHash(pred);
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_SCAP,
+            egCore.strings.CONFIRM_DELETE_SCAP_MESSAGE,
+            {}
+        ).result.then(function () {
+            egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.caption_and_pattern.safe_delete',
+                egCore.auth.token(),
+                scap.id()
+            ).then(function(resp){
+                var evt = egCore.evt.parse(resp);
+                if (evt) {
+                    ngToast.danger(egCore.strings.SERIALS_SCAP_FAIL_DELETE + ' : ' + evt.desc);
+                } else {
+                    ngToast.success(egCore.strings.SERIALS_SCAP_SUCCESS_DELETE);
+                }
+                $scope.predictions = [];
+                egSerialsCoreSvc.fetch($scope.bibId).then(function() {
+                    reload($scope.ssubId);
+                });
+            })
+        });
+    }
+    $scope.cancelNewScap = function() {
+        $scope.new_prediction = null;
+    }
+    $scope.startNewScap = function() {
+        $scope.new_prediction = egCore.idl.toTypedHash(new egCore.idl.scap());
+        $scope.new_prediction.type = 'basic';
+        $scope.new_prediction.active = true;
+        $scope.new_prediction.create_date = new Date();
+        $scope.new_prediction.subscription = $scope.ssubId;
+        $scope.new_prediction.pattern_code = null;
+    }
+
+    $scope.importScapFromBibRecord = function() {
+        $uibModal.open({
+            templateUrl: './serials/t_select_pattern_dialog',
+            size: 'md',
+            backdrop: 'static',
+            controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.focusMe = true;
+                $scope.potentials = egSerialsCoreSvc.potentialPatternList.slice();
+                $scope.ok = function(patternCode) { $uibModalInstance.close($scope.potentials) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }]
+        }).result.then(function (potentials) {
+            var marc = [];
+            angular.forEach(potentials, function(pot) {
+                if (pot.selected) {
+                    marc.push(pot.marc);
+                }
+            });
+            if (marc.length == 0) return;
+            egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.caption_and_pattern.create_from_records',
+                egCore.auth.token(),
+                $scope.ssubId,
+                marc
+            ).then(function() {
+                egSerialsCoreSvc.fetch($scope.bibId).then(function() {
+                    reload($scope.ssubId);
+                });
+            });
+        });
+    }
+    
+    $scope.importScapFromSpt = function() {
+        $scope.new_prediction = egCore.idl.toTypedHash(new egCore.idl.scap());
+        $scope.new_prediction.type = 'basic';
+        $scope.new_prediction.active = true;
+        $scope.new_prediction.create_date = new Date();
+        $scope.new_prediction.subscription = $scope.ssubId;
+        for (var i = 0; i < $scope.pattern_templates.length; i++) {
+            if ($scope.pattern_templates[i].id == $scope.active_pattern_template.id) {
+                $scope.new_prediction.pattern_code = $scope.pattern_templates[i].pattern_code;
+                break;
+            }
+        }
+        // Mark form dirty because, when it's created from a template,
+        // it can be immediately saved if the user so chooses. The
+        // $watch() allows this to happen after the form is bound
+        // is bound to the scope.
+        $scope.$watch('forms.newpredform', function(form) {
+            if (form) form.$setDirty();
+        });
+    }
+
+    $scope.openPatternEditorDialog = function(pred, form, viewOnly) {
+        $uibModal.open({
+            templateUrl: './serials/t_pattern_editor_dialog',
+            size: 'lg',
+            windowClass: 'eg-wide-modal',
+            backdrop: 'static',
+            controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.viewOnly = viewOnly;
+                $scope.focusMe = true;
+                $scope.patternCode = pred.pattern_code;
+                $scope.ok = function(patternCode) { $uibModalInstance.close(patternCode) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }]
+        }).result.then(function (patternCode) {
+            if (pred.pattern_code !== patternCode) {
+                pred.pattern_code = patternCode;
+                form.$setDirty();        
+            }
+        });
+    }
+
+    $scope.add_issuances = function() {
+        return egSerialsCoreSvc.fetchItemsForSub($scope.ssubId).then(function() {
+            egSerialsCoreSvc.add_issuances($scope.ssubId).then(function() {
+                $location.path('/serials/' + $scope.bibId + '/issues/' +
+                                $scope.ssubId);
+            });
+        });
+    }
+
+}]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_wizard.js b/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_wizard.js
new file mode 100644 (file)
index 0000000..d9beaff
--- /dev/null
@@ -0,0 +1,711 @@
+angular.module('egSerialsAppDep')
+
+.directive('egPredictionWizard', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            patternCode : '=',
+            onSave      : '=',
+            showShare   : '=',
+            viewOnly    : '='
+        },
+        templateUrl: './serials/t_prediction_wizard',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider) {
+
+    $scope.tab = { active : 0 };
+    if (angular.isUndefined($scope.showShare)) {
+        $scope.showShare = true;
+    }
+    if (angular.isUndefined($scope.viewOnly)) {
+        $scope.viewOnly = false;
+    }
+
+    // for use by ng-value
+    $scope.True = true;
+    $scope.False = false;
+
+    // class for MARC21 serial prediction pattern
+    // TODO move elsewhere
+    function PredictionPattern(patternCode) {
+        var self = this;
+        this.use_enum = false;
+        this.use_alt_enum = false;
+        this.use_chron = false;
+        this.use_alt_chron = false;
+        this.use_calendar_changes = false;
+        this.calendar_change = [];
+        this.compress_expand = '3';
+        this.caption_evaluation = '0';        
+        this.enum_levels = [];
+        this.alt_enum_levels = [];
+        this.chron_levels = [];
+        this.alt_chron_levels = [{ caption : null, display_caption: false }];
+        this.frequency_type = 'preset';
+        this.use_regularity = false;
+        this.regularity = [];
+
+        var nr_sf_map = {
+            '8' : 'link',
+            'n' : 'note',
+            'p' : 'pieces_per_issuance',
+            'w' : 'frequency',
+            't' : 'copy_caption'
+        }
+        var enum_level_map = {
+            'a' : 0,
+            'b' : 1,
+            'c' : 2,
+            'd' : 3,
+            'e' : 4,
+            'f' : 5
+        }
+        var alt_enum_level_map = {
+            'g' : 0,
+            'h' : 1
+        }
+        var chron_level_map = {
+            'i' : 0,
+            'j' : 1,
+            'k' : 2,
+            'l' : 3
+        }
+        var alt_chron_level_map = {
+            'm' : 0
+        }
+
+        var curr_enum_level = -1;
+        var curr_alt_enum_level = -1;
+        var curr_chron_level = -1;
+        var curr_alt_chron_level = -1;
+        if (patternCode && patternCode.length > 2 && (patternCode.length % 2 == 0)) {
+            // set indicator values
+            this.compress_expand = patternCode[0];
+            this.caption_evaluation = patternCode[1];
+            for (var i = 2; i < patternCode.length; i += 2) {
+                var sf = patternCode[i];
+                var value = patternCode[i + 1]; 
+                if (sf in nr_sf_map) {
+                    this[nr_sf_map[sf]] = value;
+                    continue;
+                }
+                if (sf in enum_level_map) {
+                    this.use_enum = true;
+                    curr_enum_level = enum_level_map[sf];
+                    this.enum_levels[curr_enum_level] = {
+                        caption : value,
+                        restart : false
+                    }
+                    continue;
+                }
+                if (sf in alt_enum_level_map) {
+                    this.use_enum = true;
+                    this.use_alt_enum = true;
+                    curr_enum_level = -1;
+                    curr_alt_enum_level = alt_enum_level_map[sf];
+                    this.alt_enum_levels[curr_alt_enum_level] = {
+                        caption : value,
+                        restart : false
+                    }
+                    continue;
+                }
+                if (sf in chron_level_map) {
+                    this.use_chron = true;
+                    curr_chron_level = chron_level_map[sf];
+                    var chron = {};
+                    if (value.match(/^\(.*\)$/)) {
+                        chron.display_caption = false;
+                        chron.caption = value.replace(/^\(/, '').replace(/\)$/, '');
+                    } else {
+                        chron.display_caption = true;
+                        chron.caption = value;
+                    }
+                    this.chron_levels[curr_chron_level] = chron;
+                    continue;
+                }
+                if (sf in alt_chron_level_map) {
+                    this.use_alt_chron = true;
+                    curr_chron_level = -1;
+                    curr_alt_chron_level = alt_chron_level_map[sf];
+                    var chron = {};
+                    if (value.match(/^\(.*\)$/)) {
+                        chron.display_caption = false;
+                        chron.caption = value.replace(/^\(/, '').replace(/\)$/, '');
+                    } else {
+                        chron.display_caption = true;
+                        chron.caption = value;
+                    }
+                    this.alt_chron_levels[curr_alt_chron_level] = chron;
+                    continue;
+                }
+
+                if (sf == 'u') {
+                    var units = {
+                        type : 'number'
+                    };
+                    if (value == 'und' || value == 'var') {
+                        units.type = value;
+                    } else if (!isNaN(parseInt(value))) {
+                        units.value = parseInt(value);
+                    } else {
+                        continue; // escape garbage
+                    }
+                    if (curr_enum_level > 0) {
+                        this.enum_levels[curr_enum_level].units_per_next_higher = units;
+                    } else if (curr_alt_enum_level > 0) {
+                        this.alt_enum_levels[curr_alt_enum_level].units_per_next_higher = units;
+                    }
+                }
+                if (sf == 'v' && value == 'r') {
+                    if (curr_enum_level > 0) {
+                        this.enum_levels[curr_enum_level].restart = true;
+                    } else if (curr_alt_enum_level > 0) {
+                        this.alt_enum_levels[curr_alt_enum_level].restart = true;
+                    }
+                }
+                if (sf == 'z') {
+                    if (curr_enum_level > -1) {
+                        this.enum_levels[curr_enum_level].numbering_scheme = value;
+                    } else if (curr_alt_enum_level > -1) {
+                        this.alt_enum_levels[curr_alt_enum_level].numbering_scheme = value;
+                    }
+                }
+                if (sf == 'x') {
+                    this.use_calendar_change = true;
+                    value.split(',').forEach(function(chg) {
+                        var calendar_change = {
+                            type   : null,
+                            season : null,
+                            month  : null,
+                            day    : null
+                        }
+                        if (chg.length == 2) {
+                            if (chg >= '21') {
+                                calendar_change.type = 'season';
+                                calendar_change.season = chg;
+                            } else {
+                                calendar_change.type = 'month';
+                                calendar_change.month = chg;
+                            }
+                        } else if (chg.length == 4) {
+                            calendar_change.type = 'date';
+                            calendar_change.month = chg.substring(0, 2);
+                            calendar_change.day   = chg.substring(2, 4);
+                        }
+                        self.calendar_change.push(calendar_change);
+                    });
+                }
+                if (sf == 'y') {
+                    this.use_regularity = true;
+                    var regularity_type = value.substring(0, 1);
+                    var parts = [];
+                    var chron_type = value.substring(1, 2);
+                    value.substring(2).split(/,/).forEach(function(value) {
+                        var piece = {};
+                        if (regularity_type == 'c') {
+                            piece.combined_code = value;
+                        } else if (chron_type == 'd') {
+                            if (value.match(/^\d\d$/)) {
+                                piece.sub_type = 'day_of_month';
+                                piece.day_of_month = value;
+                            } else if (value.match(/^\d\d\d\d$/)) {
+                                piece.sub_type = 'specific_date';
+                                piece.specific_date = value;
+                            } else {
+                                piece.sub_type = 'day_of_week';
+                                piece.day_of_week = value;
+                            }
+                        } else if (chron_type == 'm') {
+                            piece.sub_type = 'month';
+                            piece.month = value;
+                        } else if (chron_type == 's') {
+                            piece.sub_type = 'season';
+                            piece.season = value;
+                        } else if (chron_type == 'w') {
+                            if (value.match(/^\d\d\d\d$/)) {
+                                piece.sub_type = 'week_in_month';
+                                piece.week   = value.substring(0, 2);
+                                piece.month  = value.substring(2, 4);
+                            } else if (value.match(/^\d\d[a-z][a-z]$/)) {
+                                piece.sub_type = 'week_day';
+                                piece.week = value.substring(0, 2);
+                                piece.day  = value.substring(2, 4);
+                            } else if (value.length == 6) {
+                                piece.sub_type = 'week_day_in_month';
+                                piece.month = value.substring(0, 2);
+                                piece.week  = value.substring(2, 4);
+                                piece.day   = value.substring(4, 6);
+                            }
+                        } else if (chron_type == 'y') {
+                            piece.sub_type = 'year';
+                            piece.year = value;
+                        }
+                        parts.push(piece);
+                    });
+                    self.regularity.push({
+                        regularity_type  : regularity_type,
+                        chron_type       : chron_type,
+                        parts            : parts
+                    });
+                }
+            }
+        }
+
+        if (self.frequency) {
+            if (self.frequency.match(/^\d+$/)) {
+                self.frequency_type = 'numeric';
+                self.frequency_numeric = self.frequency;
+            } else {
+                self.frequency_type = 'preset';
+                self.frequency_preset = self.frequency;
+            }
+        }
+
+        // return current pattern compiled to subfield list
+        this.compile = function() {
+            var patternCode = [];
+            patternCode.push(self.compress_expand);
+            patternCode.push(self.caption_evaluation);
+            patternCode.push('8');
+            patternCode.push(self.link);
+            if (self.use_enum) {
+                for (var i = 0; i < self.enum_levels.length; i++) {
+                    patternCode.push(['a', 'b', 'c', 'd', 'e', 'f'][i]);
+                    patternCode.push(self.enum_levels[i].caption);
+                    if (i > 0 && self.enum_levels[i].units_per_next_higher) {
+                        patternCode.push('u');
+                        if (self.enum_levels[i].units_per_next_higher.type == 'number') {
+                            patternCode.push(self.enum_levels[i].units_per_next_higher.value.toString());
+                        } else {
+                            patternCode.push(self.enum_levels[i].units_per_next_higher.type);
+                        }
+                    }
+                    if (i > 0 && self.enum_levels[i].restart != null) {
+                        patternCode.push('v');
+                        patternCode.push(self.enum_levels[i].restart ? 'r' : 'c');
+                    }
+                }
+            }
+            if (self.use_enum && self.use_alt_enum) {
+                for (var i = 0; i < self.alt_enum_levels.length; i++) {
+                    patternCode.push(['g','h'][i]);
+                    patternCode.push(self.alt_enum_levels[i].caption);
+                    if (i > 0 && self.alt_enum_levels[i].units_per_next_higher) {
+                        patternCode.push('u');
+                        if (self.alt_enum_levels[i].units_per_next_higher.type == 'number') {
+                            patternCode.push(self.alt_enum_levels[i].units_per_next_higher.value);
+                        } else {
+                            patternCode.push(self.alt_enum_levels[i].units_per_next_higher.type);
+                        }
+                    }
+                    if (i > 0 && self.alt_enum_levels[i].restart != null) {
+                        patternCode.push('v');
+                        patternCode.push(self.alt_enum_levels[i].restart ? 'r' : 'c');
+                    }
+                }
+            }
+            var chron_sfs = (self.use_enum) ? ['i', 'j', 'k', 'l'] : ['a', 'b', 'c', 'd'];
+            if (self.use_chron) {
+                for (var i = 0; i < self.chron_levels.length; i++) {
+                    patternCode.push(chron_sfs[i],
+                        self.chron_levels[i].display_caption ?
+                           self.chron_levels[i].caption :
+                           '(' + self.chron_levels[i].caption + ')'
+                    );
+                }
+            }
+            var alt_chron_sf = (self.use_enum) ? 'm' : 'g';
+            if (self.use_alt_chron) {
+                patternCode.push(alt_chron_sf,
+                    self.alt_chron_levels[0].display_caption ?
+                       self.alt_chron_levels[0].caption :
+                       '(' + self.alt_chron_levels[0].caption + ')'
+                );
+            }
+            // frequency
+            patternCode.push('w',
+                self.frequency_type == 'numeric' ?
+                    self.frequency_numeric :
+                    self.frequency_preset
+            );
+            // calendar change
+            if (self.use_enum && self.use_calendar_change) {
+                patternCode.push('x');
+                patternCode.push(self.calendar_change.map(function(chg) {
+                    if (chg.type == 'season') {
+                        return chg.season;
+                    } else if (chg.type == 'month') {
+                        return chg.month;
+                    } else if (chg.type == 'date') {
+                        return chg.month + chg.day;
+                    }
+                }).join(','));
+            }
+            // regularity
+            if (self.use_regularity) {
+                self.regularity.forEach(function(reg) {
+                    patternCode.push('y');
+                    var val = reg.regularity_type + reg.chron_type;
+                    val += reg.parts.map(function(part) {
+                        if (reg.regularity_type == 'c') {
+                            return part.combined_code;
+                        } else if (reg.chron_type == 'd') {
+                            return part[part.sub_type];
+                        } else if (reg.chron_type == 'm') {
+                            return part.month;
+                        } else if (reg.chron_type == 'w') {
+                            if (part.sub_type == 'week_in_month') {
+                                return part.week + part.month;
+                            } else if (part.sub_type == 'week_day') {
+                                return part.week + part.day;
+                            } else if (part.sub_type == 'week_day_in_month') {
+                                return part.month + part.week + part.day;
+                            }
+                        } else if (reg.chron_type == 's') {
+                            return part.season;
+                        } else if (reg.chron_type == 'y') {
+                            return part.year;
+                        }
+                    }).join(',');
+                    patternCode.push(val);
+                });
+            }
+            return patternCode;
+        }
+
+        this.compile_stringify = function() {
+            return JSON.stringify(this.compile(), null, 2);
+        }
+
+        this.add_enum_level = function() {
+            if (self.enum_levels.length < 6) {
+                self.enum_levels.push({
+                    caption : null,
+                    units_per_next_higher : { type : 'und' },
+                    restart : false
+                });
+            }
+        }
+        this.drop_enum_level = function() {
+            if (self.enum_levels.length > 1) {
+                self.enum_levels.pop();
+            }
+        }
+
+        this.add_alt_enum_level = function() {
+            if (self.alt_enum_levels.length < 2) {
+                self.alt_enum_levels.push({
+                    caption : null,
+                    units_per_next_higher : { type : 'und' },
+                    restart : false
+                });
+            }
+        }
+        this.drop_alt_enum_level = function() {
+            if (self.alt_enum_levels.length > 1) {
+                self.alt_enum_levels.pop();
+            }
+        }
+        this.remove_calendar_change = function(idx) {
+            if (self.calendar_change.length > idx) {
+                self.calendar_change.splice(idx, 1);
+            }
+        }
+        this.add_calendar_change = function() {
+            self.calendar_change.push({
+                type   : null,
+                season : null,
+                month  : null,
+                day    : null
+            });
+        }
+
+        this.add_chron_level = function() {
+            if (self.chron_levels.length < 4) {
+                self.chron_levels.push({
+                    caption : null,
+                    display_caption : false
+                });
+            }
+        }
+        this.drop_chron_level = function() {
+            if (self.chron_levels.length > 1) {
+                self.chron_levels.pop();
+            }
+        }
+        this.add_regularity = function() {
+            self.regularity.push({
+                regularity_type : null,
+                chron_type : null,
+                parts : [{ sub_type : null }]
+            });
+        }
+        this.remove_regularity = function(idx) {
+            if (self.regularity.length > idx) {
+                self.regularity.splice(idx, 1);
+            }
+            // and add a blank entry back if need be
+            if (self.regularity.length == 0) {
+                self.add_regularity();
+            }
+        }
+        this.add_regularity_part = function(reg) {
+            reg.parts.push({
+                sub_type : null
+            });
+        }
+        this.remove_regularity_part = function(reg, idx) {
+            if (reg.parts.length > idx) {
+                reg.parts.splice(idx, 1);
+            }
+            // and add a blank entry back if need be
+            if (reg.parts.length == 0) {
+                self.add_regularity_part(reg);
+            }
+        }
+
+        this.display_enum_captions = function() {
+            return self.enum_levels.map(function(lvl) {
+                return lvl.caption;
+            }).join(', ');
+        }
+        this.display_alt_enum_captions = function() {
+            return self.alt_enum_levels.map(function(lvl) {
+                return lvl.caption;
+            }).join(', ');
+        }
+        this.display_chron_captions = function() {
+            return self.chron_levels.map(function(lvl) {
+                return lvl.caption;
+            }).join(', ');
+        }
+        this.display_alt_chron_captions = function() {
+            return self.alt_chron_levels.map(function(lvl) {
+                return lvl.caption;
+            }).join(', ');
+        }
+
+        if (!patternCode) {
+            // starting from scratch, ensure there's
+            // enough so that the input wizard can be used
+            this.use_enum = true;
+            this.use_chron = true;
+            this.link = 0;
+            self.add_enum_level();
+            self.add_alt_enum_level();
+            self.add_chron_level();
+            self.add_calendar_change();
+            self.add_regularity();
+        } else {
+            // fill in potential missing bits
+            if (!self.use_enum && self.enum_levels.length == 0) self.add_enum_level();
+            if (!self.use_alt_enum && self.alt_enum_levels.length == 0) self.add_alt_enum_level();
+            if (!self.use_chron && self.chron_levels.length == 0) self.add_chron_level();
+            if (!self.use_calendar_change) self.add_calendar_change();
+            if (!self.use_regularity) self.add_regularity();
+        }
+    }
+    // TODO chron only
+
+    if ($scope.patternCode) {
+        $scope.pattern = new PredictionPattern(JSON.parse($scope.patternCode));
+    } else {
+        $scope.pattern = new PredictionPattern();
+    }
+
+    // possible sharing
+    $scope.share = {
+        pattern_name : null,
+        depth        : 0
+    };
+
+    $scope.chron_captions = [];
+    $scope.alt_chron_captions = [];
+
+    $scope.handle_save = function() {
+        $scope.patternCode = JSON.stringify($scope.pattern.compile());
+        if ($scope.share.pattern_name !== null) {
+            var spt = new egCore.idl.spt();
+            spt.name($scope.share.pattern_name);
+            spt.pattern_code($scope.patternCode);
+            spt.share_depth($scope.share.depth);
+            spt.owning_lib(egCore.auth.user().ws_ou());
+            egCore.pcrud.create(spt).then(function() {
+                if (angular.isFunction($scope.onSave)) {
+                    $scope.onSave($scope.patternCode);
+                }
+            });
+        } else {
+            if (angular.isFunction($scope.onSave)) {
+                $scope.onSave($scope.patternCode);
+            }
+        }
+    }
+
+}]
+    }
+})
+
+.directive('egChronSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            ngModel        : '=',
+            chronLevel     : '=',
+            linkedSelector : '=',
+        },
+        templateUrl: './serials/t_chron_selector',
+        controller:
+       ['$scope','$q','egCore',
+function($scope , $q , egCore) {
+        $scope.options = [
+            { value : 'year',   label : egCore.strings.CHRON_LABEL_YEAR,   disabled: false },
+            { value : 'season', label : egCore.strings.CHRON_LABEL_SEASON, disabled: false },
+            { value : 'month',  label : egCore.strings.CHRON_LABEL_MONTH,  disabled: false },
+            { value : 'week',   label : egCore.strings.CHRON_LABEL_WEEK,   disabled: false },
+            { value : 'day',    label : egCore.strings.CHRON_LABEL_DAY,    disabled: false },
+            { value : 'hour',   label : egCore.strings.CHRON_LABEL_HOUR,   disabled: false }
+        ];
+        var levels = {
+            'year'   : 0,
+            'season' : 1,
+            'month'  : 1,
+            'week'   : 2,
+            'day'    : 3,
+            'hour'   : 4
+        };
+        $scope.$watch('ngModel', function(newVal, oldVal) {
+            $scope.linkedSelector[$scope.chronLevel] = $scope.ngModel;
+        });
+        $scope.$watch('linkedSelector', function(newVal, oldVal) {
+            if ($scope.chronLevel > 0 && $scope.linkedSelector[$scope.chronLevel - 1]) {
+                var level_to_disable = levels[ $scope.linkedSelector[$scope.chronLevel - 1] ];
+                for (var i = 0; i < $scope.options.length; i++) {
+                    $scope.options[i].disabled =
+                        (levels[ $scope.options[i].value ] <= level_to_disable);
+                }
+            }
+        }, true);
+}]
+    }
+})
+
+.directive('egMonthSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            ngModel : '='
+        },
+        templateUrl: './serials/t_month_selector',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+}]
+    }
+})
+
+.directive('egSeasonSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            ngModel : '='
+        },
+        templateUrl: './serials/t_season_selector',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+}]
+    }
+})
+
+.directive('egWeekInMonthSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            ngModel : '='
+        },
+        templateUrl: './serials/t_week_in_month_selector',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+}]
+    }
+})
+
+.directive('egDayOfWeekSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            ngModel : '='
+        },
+        templateUrl: './serials/t_day_of_week_selector',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+}]
+    }
+})
+
+.directive('egMonthDaySelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            month : '=',
+            day   : '='
+        },
+        templateUrl: './serials/t_month_day_selector',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+    if ($scope.month == null) $scope.month = '01';
+    if ($scope.day   == null) $scope.day   = '01';
+    $scope.dt = new Date(2012, parseInt($scope.month) - 1, parseInt($scope.day), 1);
+    $scope.options = {
+        minMode : 'day',
+        maxMode : 'day',
+        datepickerMode : 'day',
+        showWeeks : false,
+        // use a leap year, though any publisher who uses 29 February as a
+        // calendar change is simply trolling
+        // also note that when https://github.com/angular-ui/bootstrap/issues/1993
+        // is fixed, setting minDate and maxDate would make sense, as
+        // user wouldn't be able to keeping hit the left or right arrows
+        // past the end of the range
+        // minDate : new Date('2012-01-01 00:00:01'),
+        // maxDate : new Date('2012-12-31 23:59:59'),
+        formatDayTitle : 'MMMM',
+    }
+    $scope.datePickerIsOpen = false;
+    $scope.$watch('dt', function(newVal, oldVal) {
+        if (newVal != oldVal) {
+            $scope.day   = ('00' + $scope.dt.getDate() ).slice(-2);
+            $scope.month = ('00' + ($scope.dt.getMonth() + 1)).slice(-2);
+        }
+    });
+}]
+    }
+})
+
+.directive('egPredictionPatternSummary', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            pattern : '<'
+        },
+        templateUrl: './serials/t_pattern_summary',
+        controller:
+       ['$scope','$q',
+function($scope , $q) {
+}]
+    }
+})
+
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/sub_selector.js b/Open-ILS/web/js/ui/default/staff/serials/directives/sub_selector.js
new file mode 100644 (file)
index 0000000..7556046
--- /dev/null
@@ -0,0 +1,31 @@
+angular.module('egSerialsAppDep')
+
+.directive('egSubSelector', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+            ssubId : '='
+        },
+        templateUrl: './serials/t_sub_selector',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+        '$uibModal',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
+                     $uibModal) {
+    if ($scope.ssubId) {
+        $scope.owning_ou = egCore.org.root();
+    }
+    $scope.owning_ou_changed = function(org) {
+        $scope.selected_owning_ou = org.id();
+        reload();
+    }
+    function reload() {
+        egSerialsCoreSvc.fetch($scope.bibId, $scope.selected_owning_ou).then(function() {
+            $scope.subscriptions = egCore.idl.toTypedHash(egSerialsCoreSvc.subTree);
+        });
+    }
+}]
+    }
+})
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/subscription_manager.js b/Open-ILS/web/js/ui/default/staff/serials/directives/subscription_manager.js
new file mode 100644 (file)
index 0000000..d7edbb8
--- /dev/null
@@ -0,0 +1,943 @@
+angular.module('egSerialsAppDep')
+
+.directive('egSubscriptionManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId : '='
+        },
+        templateUrl: './serials/t_subscription_manager',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','ngToast','egConfirmDialog',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , ngToast , egConfirmDialog ) {
+
+    $scope.selected_owning_ou = null;
+    $scope.owning_ou_changed = function(org) {
+        $scope.selected_owning_ou = org.id();
+        reload();
+    }
+
+    function reload() {
+        egSerialsCoreSvc.fetch($scope.bibId, $scope.selected_owning_ou).then(function() {
+            $scope.subscriptions = egCore.idl.toTypedHash(egSerialsCoreSvc.subTree);
+            // un-flesh receive unit template so that we can use
+            // it as a model of a select
+            angular.forEach($scope.subscriptions, function(ssub) {
+                angular.forEach(ssub.distributions, function(sdist) {
+                    if (angular.isObject(sdist.receive_unit_template)) {
+                        sdist.receive_unit_template = sdist.receive_unit_template.id;
+                    }
+                });
+            });
+            $scope.distStreamGridDataProvider.refresh();
+        });
+    }
+    reload();
+
+    $scope.localStreamNames = [];
+    egCore.hatch.getItem('eg.serials.stream_names')
+    .then(function(list) {
+        if (list) $scope.localStreamNames = list;
+    });
+
+    $scope.distStreamGridControls = {
+        activateItem : function (item) { } // TODO
+    };
+    $scope.distStreamGridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier(egSerialsCoreSvc.subList, offset, count);
+        }
+    });
+
+    $scope.need_one_selected = function() {
+        var items = $scope.distStreamGridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.receiving_templates = {};
+    angular.forEach(egCore.org.list(), function(org) {
+        egSerialsCoreSvc.fetch_templates(org.id()).then(function(list){
+            $scope.receiving_templates[org.id()] = egCore.idl.toTypedHash(list);
+        });
+    });
+
+    $scope.add_subscription = function() {
+        var new_ssub = egCore.idl.toTypedHash(new egCore.idl.ssub());
+        new_ssub._isnew = true;
+        new_ssub.record_entry = $scope.bibId;
+        new_ssub._focus_me = true;
+        $scope.subscriptions.push(new_ssub);
+        $scope.add_distribution(new_ssub); // since we know we want at least one distribution
+    }
+    $scope.add_distribution = function(ssub, grab_focus) {
+        egCore.org.settings([
+            'serial.default_display_grouping'
+        ]).then(function(set) {
+            var new_sdist = egCore.idl.toTypedHash(new egCore.idl.sdist());
+            new_sdist._isnew = true;
+            new_sdist.subscription = ssub.id;
+            new_sdist.display_grouping = set['serial.default_display_grouping'] || 'chron';
+            if (!angular.isArray(ssub.distributions)){
+                ssub.distributions = [];
+            }
+            if (grab_focus) {
+                new_sdist._focus_me = true;
+                ssub._focus_me = false;
+            }
+            ssub.distributions.push(new_sdist);
+            $scope.add_stream(new_sdist); // since we know we want at least one stream
+        });
+    }
+    $scope.remove_pending_distribution = function(ssub, sdist) {
+        var to_remove = -1;
+        for (var i = 0; i < ssub.distributions.length; i++) {
+            if (ssub.distributions[i] === sdist) {
+                to_remove = i;
+                break;
+            }
+        }
+        if (to_remove > -1) {
+            ssub.distributions.splice(to_remove, 1);
+        }
+    }
+    $scope.add_stream = function(sdist, grab_focus) {
+        var new_sstr = egCore.idl.toTypedHash(new egCore.idl.sstr());
+        new_sstr.distribution = sdist.id;
+        new_sstr._isnew = true;
+        if (grab_focus) {
+            new_sstr._focus_me = true;
+            sdist._has_focus = false; // and take focus away from a newly created sdist
+        }
+        if (!angular.isArray(sdist.streams)){
+            sdist.streams = [];
+        }
+        sdist.streams.push(new_sstr);
+        $scope.dirtyForm();
+    }
+    $scope.remove_pending_stream = function(sdist, sstr) {
+        var to_remove = -1;
+        for (var i = 0; i < sdist.streams.length; i++) {
+            if (sdist.streams[i] === sstr) {
+                to_remove = i;
+                break;
+            }
+        }
+        if (to_remove > -1) {
+            sdist.streams.splice(to_remove, 1);
+        }
+    }
+
+    $scope.abort_changes = function(form) {
+        reload();
+        form.$setPristine();
+    }
+    function updateLocalStreamNames (new_name) {
+        if (new_name && $scope.localStreamNames.filter(function(x){ return x == new_name}).length == 0) {
+            $scope.localStreamNames.push(new_name);
+            egCore.hatch.setItem('eg.serials.stream_names', $scope.localStreamNames)
+        }
+    }
+
+    $scope.dirtyForm = function () {
+        $scope.ssubform.$dirty = true;
+    }
+
+    $scope.save_subscriptions = function(form) {
+        // traverse through structure and set _ischanged
+        // TODO add more granular dirty input detection
+        angular.forEach($scope.subscriptions, function(ssub) {
+            if (!ssub._isnew) ssub._ischanged = true;
+            angular.forEach(ssub.distributions, function(sdist) {
+                if (!sdist._isnew) sdist._ischanged = true;
+                angular.forEach(sdist.streams, function(sstr) {
+                    if (!sstr._isnew) sstr._ischanged = true;
+                    updateLocalStreamNames(sstr.routing_label);
+                });
+            });
+        });
+
+        var obj = egCore.idl.fromTypedHash($scope.subscriptions);
+
+        // create a bunch of promises that each get resolved upon each
+        // CUD update; that way, we can know when the entire save
+        // operation is completed
+        var promises = [];
+        angular.forEach(obj, function(ssub) {
+            ssub._cud_done = $q.defer();
+            promises.push(ssub._cud_done.promise);
+            angular.forEach(ssub.distributions(), function(sdist) {
+                sdist._cud_done = $q.defer();
+                promises.push(sdist._cud_done.promise);
+                angular.forEach(sdist.streams(), function(sstr) {
+                    sstr._cud_done = $q.defer();
+                    promises.push(sstr._cud_done.promise);
+                });
+            });
+        });
+
+        angular.forEach(obj, function(ssub) {
+            ssub.owning_lib(ssub.owning_lib().id()); // deflesh
+            egCore.pcrud.apply(ssub).then(function(res) {
+                var ssub_id = (ssub.isnew() && angular.isObject(res)) ? res.id() : ssub.id();
+                angular.forEach(ssub.distributions(), function(sdist) {
+                    // set subscription ID just in case it's new
+                    sdist.holding_lib(sdist.holding_lib().id()); // deflesh
+                    sdist.subscription(ssub_id);
+                    egCore.pcrud.apply(sdist).then(function(res) {
+                        var sdist_id = (sdist.isnew() && angular.isObject(res)) ? res.id() : sdist.id();
+                        angular.forEach(sdist.streams(), function(sstr) {
+                            // set distribution ID just in case it's new
+                            sstr.distribution(sdist_id);
+                            egCore.pcrud.apply(sstr).then(function(res) {
+                                sstr._cud_done.resolve();
+                            });
+                        });
+                    });
+                    sdist._cud_done.resolve();
+                });
+                ssub._cud_done.resolve();
+            });
+        });
+        $q.all(promises).then(function(resolutions) {
+            reload();
+            form.$setPristine();
+        });
+    }
+    $scope.delete_subscription = function(rows) {
+        if (rows.length == 0) { return; }
+        var s_rows = rows.filter(function(el) {
+            return typeof el['id'] != 'undefined';
+        });
+        if (s_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_SUBSCRIPTION,
+            egCore.strings.CONFIRM_DELETE_SUBSCRIPTION_MESSAGE,
+            {count : s_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(s_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.subscription.safe_delete',
+                        egCore.auth.token(),
+                        el['id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_SUBSCRIPTION_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.delete_distribution = function(rows) {
+        if (rows.length == 0) { return; }
+        var d_rows = rows.filter(function(el) {
+            return typeof el['sdist.id'] != 'undefined';
+        });
+        if (d_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_DISTRIBUTION,
+            egCore.strings.CONFIRM_DELETE_DISTRIBUTION_MESSAGE,
+            {count : d_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(d_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.distribution.safe_delete',
+                        egCore.auth.token(),
+                        el['sdist.id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.delete_stream = function(rows) {
+        if (rows.length == 0) { return; }
+        var s_rows = rows.filter(function(el) {
+            return typeof el['sstr.id'] != 'undefined';
+        });
+        if (s_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_STREAM,
+            egCore.strings.CONFIRM_DELETE_STREAM_MESSAGE,
+            {count : s_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(s_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.stream.safe_delete',
+                        egCore.auth.token(),
+                        el['sstr.id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_STREAM_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_STREAM_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.additional_routing = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        if (!row) { row = $scope.distStreamGridControls.selectedItems()[0]; }
+        if (row && row['sstr.id']) {
+            egCore.pcrud.search('srlu', {
+                    stream : row['sstr.id']
+                }, {
+                    flesh : 2,
+                    flesh_fields : {
+                        'srlu' : ['reader'],
+                        'au'  : ['mailing_address','billing_address','home_ou']
+                    },
+                    order_by : { srlu : 'pos' }
+                },
+                { atomic : true }
+            ).then(function(list) {
+                $uibModal.open({
+                    templateUrl: './serials/t_routing_list',
+                    controller: 'RoutingCtrl',
+                    resolve : {
+                        rowInfo : function() {
+                            return row;
+                        },
+                        routes : function() {
+                            return egCore.idl.toHash(list);
+                        }
+                    }
+                }).result.then(function(routes) {
+                    // delete all of the routes first;
+                    // it's easiest given the constraints
+                    var deletions = [];
+                    var creations = [];
+                    angular.forEach(routes, function(r) {
+                        var srlu = new egCore.idl.srlu();
+                        srlu.stream(r.stream);
+                        srlu.pos(r.pos);
+                        if (r.reader) {
+                            srlu.reader(r.reader.id);
+                        }
+                        srlu.department(r.department);
+                        srlu.note(r.note);
+                        if (r.id) {
+                            srlu.id(r.id);
+                            var srlu_copy = angular.copy(srlu);
+                            srlu_copy.isdeleted(true);
+                            deletions.push(srlu_copy);
+                        }
+                        if (!r.delete_me) {
+                            srlu.isnew(true);
+                            creations.push(srlu);
+                        }
+                    });
+                    egCore.pcrud.apply(deletions.concat(creations)).then(function(){
+                        reload();
+                    });
+                });
+            });
+        }
+    }
+    $scope.clone_subscription = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        $uibModal.open({
+            templateUrl: './serials/t_clone_subscription',
+            controller: 'CloneCtrl',
+            resolve : {
+                subs : function() {
+                    return rows;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            var promises = [];
+            var some_failure = false;
+            var some_success = false;
+            var seen = {};
+            angular.forEach(rows, function(row) { 
+                //console.log(row);
+                if (!seen[row.id]) {
+                    seen[row.id] = 1;
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.serial',
+                            'open-ils.serial.subscription.clone',
+                            egCore.auth.token(),
+                            row.id,
+                            args.bib_id
+                        ).then(
+                            function(resp) {
+                                var evt = egCore.evt.parse(resp);
+                                if (evt) { // any way to just throw or return this to the error handler?
+                                    console.log('failure',resp);
+                                    some_failure = true;
+                                    ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_CLONE);
+                                } else {
+                                    console.log('success',resp);
+                                    some_success = true;
+                                    ngToast.success(egCore.strings.SERIALS_SUBSCRIPTION_SUCCESS_CLONE);
+                                }
+                            },
+                            function(resp) {
+                                console.log('failure',resp);
+                                some_failure = true;
+                                ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_CLONE);
+                            }
+                        )
+                    );
+                }
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.link_mfhd = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        if (!row['sdist.id']) { return; }
+        $uibModal.open({
+            templateUrl: './serials/t_link_mfhd',
+            controller: 'LinkMFHDCtrl',
+            resolve : {
+                row : function() {
+                    return rows[0];
+                },
+                bibId : function() {
+                    return $scope.bibId;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            console.log('modal done', args);
+            egCore.pcrud.search('sdist', {
+                    id: rows[0]['sdist.id']
+                }, {}, { atomic : true }
+            ).then(function(resp){
+                var evt = egCore.evt.parse(resp);
+                if (evt) { // any way to just throw or return this to the error handler?
+                    console.log('failure',resp);
+                    ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                }
+                var sdist = resp[0];
+                sdist.ischanged(true);
+                sdist.summary_method( args.summary_method );
+                sdist.record_entry( args.which_mfhd );
+                egCore.pcrud.apply(sdist).then(
+                    function(resp) { // maybe success
+                        console.log('apply',resp);
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) { // any way to just throw or return this to the error handler?
+                            console.log('failure',resp);
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                        } else {
+                            console.log('success',resp);
+                            ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_LINK_MFHD);
+                            reload();
+                        }
+                    },
+                    function(resp) {
+                        console.log('failure',resp);
+                        ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                    }
+                );
+            });
+        });
+    }
+    $scope.apply_binding_template = function(rows) {
+        if (rows.length == 0) { return; }
+        var d_rows = rows.filter(function(el) {
+            return typeof el['sdist.id'] != 'undefined';
+        });
+        if (d_rows.length == 0) { return; }
+        var libs = []; var seen_lib = {};
+        angular.forEach(d_rows, function(el) {
+            if (el['sdist.holding_lib.id'] && !seen_lib[el['sdist.holding_lib.id']]) {
+                seen_lib[el['sdist.holding_lib.id']] = 1;
+                libs.push({
+                      id: el['sdist.holding_lib.id'],
+                    name: el['sdist.holding_lib.name'],
+                });
+            }
+        });
+        $uibModal.open({
+            templateUrl: './serials/t_apply_binding_template',
+            controller: 'ApplyBindingTemplateCtrl',
+            resolve : {
+                rows : function() {
+                    return d_rows;
+                },
+                libs : function() {
+                    return libs;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            console.log(args);
+            egCore.pcrud.search('sdist', {
+                    id: d_rows.map(function(el) { return el['sdist.id']; })
+                }, {}, { atomic : true }
+            ).then(function(resp){
+                var evt = egCore.evt.parse(resp);
+                if (evt) { // any way to just throw or return this to the error handler?
+                    console.log('failure',resp);
+                    ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                }
+                var promises = [];
+                angular.forEach(resp,function(sdist) {
+                    var promise = $q.defer();
+                    promises.push(promise.promise);
+                    sdist.ischanged(true);
+                    sdist.bind_unit_template(
+                        typeof args.bind_unit_template[sdist.holding_lib()] == 'undefined'
+                        ? null
+                        : args.bind_unit_template[sdist.holding_lib()]
+                    );
+                    egCore.pcrud.apply(sdist).then(
+                        function(resp2) { // maybe success
+                            console.log('apply',resp2);
+                            var evt = egCore.evt.parse(resp2);
+                            if (evt) { // any way to just throw or return this to the error handler?
+                                console.log('failure',resp2);
+                                ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                            } else {
+                                console.log('success',resp2);
+                                ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_BINDING_TEMPLATE);
+                            }
+                            promise.resolve();
+                        },
+                        function(resp2) {
+                            console.log('failure',resp2);
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                            promise.resolve();
+                        }
+                    );
+                });
+                $q.all(promises).then(function() {
+                    reload();
+                });
+            });
+        });
+    }
+    $scope.subscription_notes = function(rows) {
+        return $scope.notes('subscription',rows);
+    }
+    $scope.distribution_notes = function(rows) {
+        return $scope.notes('distribution',rows);
+    }
+    $scope.notes = function(note_type,rows) {
+        if (!rows) { return; }
+
+        function modal(existing_notes) {
+            $uibModal.open({
+                templateUrl: './serials/t_notes',
+                animation: true,
+                controller: 'NotesCtrl',
+                resolve : {
+                    note_type : function() { return note_type; },
+                    rows : function() {
+                        return rows;
+                    },
+                    notes : function() {
+                        return existing_notes;
+                    }
+                },
+                windowClass: 'app-modal-window',
+                backdrop: 'static',
+                keyboard: false
+            }).result.then(function(notes) {
+                console.log('results',notes);
+                egCore.pcrud.apply(notes).then(
+                    function(a) { console.log('toast here 1',a); },
+                    function(a) { console.log('toast here 2',a); }
+                );
+            });
+        }
+
+        if (rows.length == 1) {
+            var fm_hint;
+            var search_hash = {};
+            var search_opt = {};
+            switch(note_type) {
+                case 'subscription':
+                    fm_hint = 'ssubn';
+                    search_hash.subscription = rows[0]['id'];
+                    search_opt.order_by = { ssubn : 'create_date' };
+                break;
+                case 'distribution':
+                    fm_hint = 'sdistn';
+                    search_hash.distribution = rows[0]['sdist.id'];
+                    search_opt.order_by = { sdistn : 'create_date' };
+                break;
+                case 'item': default:
+                    fm_hint = 'sin';
+                    search_hash.item = rows[0]['si.id'];
+                    search_opt.order_by = { sin : 'create_date' };
+                break;
+            }
+            egCore.pcrud.search(fm_hint, search_hash, search_opt,
+                { atomic : true }
+            ).then(function(list) {
+                modal(list);
+            });
+        } else {
+                // support batch creation of notes across selections,
+                // but not editing
+                modal([]);
+        }
+    }
+
+}]
+    }
+})
+
+.controller('ApplyBindingTemplateCtrl',
+       ['$scope','$q','$uibModalInstance','egCore','egSerialsCoreSvc',
+        'rows','libs',
+function($scope , $q , $uibModalInstance , egCore , egSerialsCoreSvc ,
+         rows , libs ) {
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.libs = libs;
+    $scope.rows = rows;
+    $scope.args = { bind_unit_template : {} };
+    $scope.templates = {};
+    angular.forEach(libs, function(org) {
+        egSerialsCoreSvc.fetch_templates(org.id).then(function(list){
+            $scope.templates[org.id] = egCore.idl.toTypedHash(list);
+        });
+    });
+}])
+
+.controller('LinkMFHDCtrl',
+       ['$scope','$q','$uibModalInstance','egCore','row','bibId',
+function($scope , $q , $uibModalInstance , egCore , row , bibId ) {
+    console.log('row',row);
+    console.log('bibId',bibId);
+    $scope.args = {
+        summary_method: row['sdist.summary_method'] || 'add_to_sre',
+    };
+    if (row['sdist.record_entry']) {
+        $scope.args.which_mfhd = row['sdist.record_entry'].id;
+    }
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.legacies = {};
+    egCore.pcrud.search('sre', {
+            record: bibId, owning_lib : row['sdist.holding_lib.id'], active: 't', deleted: 'f'
+        }, {}, { atomic : true }
+    ).then(
+        function(resp) { // maybe success
+            var evt; if (evt = egCore.evt.parse(resp)) { console.error(evt.toString()); return; }
+            if (!resp) { return; }
+
+            var promises = [];
+            var seen = {};
+
+            angular.forEach(resp, function(sre) {
+                console.log('sre',sre);
+                if (!seen[sre.record()]) {
+                    seen[sre.record()] = 1;
+                    $scope.legacies[sre.record()] = { mvr: null, svrs: [] };
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.search',
+                            'open-ils.search.biblio.record.mods_slim.retrieve.authoritative',
+                            sre.record()
+                        ).then(function(resp2) {
+                            var evt; if (evt = egCore.evt.parse(resp2)) { console.error(evt.toString()); return; }
+                            if (!resp2) { return; }
+                            $scope.legacies[sre.record()].mvr = egCore.idl.toHash(resp2);
+                        })
+                    );
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.search',
+                            'open-ils.search.serial.record.bib.retrieve',
+                            sre.record(),
+                            row['owning_lib.id']
+                        ).then(function(resp2) {
+                            angular.forEach(resp2,function(r) {
+                                if (r.sre_id() > 0) {
+                                    console.log('svr',egCore.idl.toHash(r));
+                                    $scope.legacies[sre.record()].svrs.push( egCore.idl.toHash(r) );
+                                }
+                            });
+                        })
+                    );
+                }
+                if (typeof $scope.legacies[sre.record()].sres == 'undefined') {
+                    $scope.legacies[sre.record()].sres = {};
+                }
+                $scope.legacies[sre.record()].sres[sre.id()] = egCore.idl.toHash(sre);
+            });
+
+            $q.all(promises).then(function(){
+                console.log('done',$scope.legacies);
+            });
+        },
+        function(resp) { // outright failure
+            console.error('failure',resp);
+        }
+    )
+}])
+
+.controller('CloneCtrl',
+       ['$scope','$uibModalInstance','egCore','subs',
+function($scope , $uibModalInstance , egCore , subs ) {
+    $scope.args = {};
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.subs = subs;
+    $scope.find_bib = function () {
+
+        $scope.bibNotFound = null;
+        $scope.mvr = null;
+        if (!$scope.args.bib_id) return;
+
+        return egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.record.mods_slim.retrieve.authoritative',
+            $scope.args.bib_id
+        ).then(
+            function(resp) { // maybe success 
+
+                if (evt = egCore.evt.parse(resp)) {
+                    $scope.bibNotFound = $scope.args.bib_id;
+                    console.error(evt.toString());
+                    return;
+                }
+
+                if (!resp) {
+                    $scope.bibNotFound = $scope.args.bib_id;
+                    return;
+                }
+
+                $scope.mvr = egCore.idl.toHash(resp);
+                //console.log($scope.mvr);
+            },
+            function(resp) { // outright failure
+                console.error(resp);
+                $scope.bibNotFound = $scope.args.bib_id;
+                return;
+            }
+        );
+    }
+    $scope.$watch("args.bib_id", function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.find_bib();
+        }
+    });
+}])
+
+.controller('RoutingCtrl',
+       ['$scope','$uibModalInstance','egCore','rowInfo','routes',
+function($scope , $uibModalInstance , egCore , rowInfo , routes ) {
+    $scope.args = {
+         which_radio_button: 'reader'
+        ,reader: ''
+        ,department: ''
+        ,delete_me: false
+    };
+    $scope.stream_id = rowInfo['sstr.id'];
+    $scope.stream_label = rowInfo['sstr.routing_label'];
+    $scope.routes = routes;
+    $scope.readerInFocus = true;
+    $scope.ok = function(count) { $uibModalInstance.close($scope.routes) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.model_has_changed = false;
+    $scope.find_user = function () {
+
+        $scope.readerNotFound = null;
+        $scope.reader_obj = null;
+        if (!$scope.args.reader) return;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(),
+            'actor', $scope.args.reader)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.readerNotFound = $scope.args.reader;
+                return;
+            }
+
+            egCore.pcrud.search('au', {
+                    id : resp[0].id
+                }, {
+                    flesh : 1,
+                    flesh_fields : {
+                        'au'  : ['mailing_address','billing_address','home_ou']
+                    }
+                },
+                { atomic : true }
+            ).then(function(usr) {
+                $scope.reader_obj = egCore.idl.toHash(usr[0]);
+            });
+        });
+    }
+    $scope.add_route = function () {
+        var new_route = {
+             stream: $scope.stream_id
+            ,pos: $scope.routes.length
+            ,note: $scope.args.note
+        }
+        if ($scope.args.which_radio_button == 'reader') {
+            new_route.reader = $scope.reader_obj;
+        } else {
+            new_route.department = $scope.args.department;
+        }
+        $scope.routes.push(new_route);
+        $scope.model_has_changed = true;
+    }
+    function adjust_pos_field() {
+        var idx = 0;
+        for (var i = 0; i < $scope.routes.length; i++) {
+            $scope.routes[i].pos = $scope.routes[i].delete_me ? idx : idx++;
+        }
+        $scope.model_has_changed = true;
+    }
+    $scope.move_route_up = function(r) {
+        var pos = r.pos;
+        if (pos > 0) {
+            var temp = $scope.routes[ pos - 1 ];
+            $scope.routes[ pos - 1 ] = $scope.routes[ pos ];
+            $scope.routes[ pos ] = temp;
+            adjust_pos_field();
+        }
+    }
+    $scope.move_route_down = function(r) {
+        var pos = r.pos;
+        if (pos < $scope.routes.length - 1) {
+            var temp = $scope.routes[ pos + 1 ];
+            $scope.routes[ pos + 1 ] = $scope.routes[ pos ];
+            $scope.routes[ pos ] = temp;
+            adjust_pos_field();
+        }
+    }
+    $scope.toggle_delete = function(r) {
+        r.delete_me = ! r.delete_me;
+        adjust_pos_field();
+    }
+    $scope.$watch("args.reader", function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.find_user();
+        }
+    });
+}])
+
+.controller('NotesCtrl',
+       ['$scope','$uibModalInstance','egCore','note_type','rows','notes',
+function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
+    $scope.note_type = note_type;
+    $scope.focusNote = true;
+    $scope.note = {
+        creator : egCore.auth.user().id(),
+        title   : '',
+        value   : '',
+        pub     : false,
+        'alert' : false,
+    };
+
+    $scope.require_initials = false;
+    egCore.org.settings([
+        'ui.staff.require_initials.copy_notes'
+    ]).then(function(set) {
+        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
+    });
+
+    $scope.note_list = notes;
+
+    $scope.ok = function(note) {
+
+        var return_notes = [];
+        if (note.initials) note.value += ' [' + note.initials + ']';
+        if (   (typeof note.title != 'undefined' && note.title != '')
+            || (typeof note.value != 'undefined' && note.value != '')) {
+            angular.forEach(rows, function (r) {
+                console.log('r',r);
+                window.my_r = r;
+                var n;
+                switch(note_type) {
+                    case 'subscription':
+                        n = new egCore.idl.ssubn();
+                        n.subscription(r['id']);
+                        break;
+                    case 'distribution':
+                        n = new egCore.idl.sdistn();
+                        n.distribution(r['sdist.id']);
+                        break;
+                    case 'item':
+                    default:
+                        n = new egCore.idl.sin();
+                        n.item(r['si.id']);
+                }
+                n.isnew(true);
+                n.creator(note.creator);
+                n.pub(note.pub);
+                n['alert'](note['alert']);
+                n.title(note.title);
+                n.value(note.value);
+                return_notes.push( n );
+            });
+        }
+        angular.forEach(notes, function(n) {
+            if (n.ischanged() || n.isdeleted()) {
+                return_notes.push( n );
+            }
+        });
+        window.return_notes = return_notes;
+        $uibModalInstance.close(return_notes);
+    }
+
+    $scope.cancel = function($event) {
+        $uibModalInstance.dismiss();
+        $event.preventDefault();
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js b/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
new file mode 100644 (file)
index 0000000..f1b1b9c
--- /dev/null
@@ -0,0 +1,545 @@
+angular.module('egSerialsAppDep')
+
+.directive('egItemGrid', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+            ssubId : '='
+        },
+        templateUrl: './serials/t_view_items_grid',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider','orderByFilter',
+        '$uibModal','ngToast','egConfirmDialog','egPromptDialog','$timeout',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider , orderByFilter ,
+         $uibModal , ngToast , egConfirmDialog , egPromptDialog , $timeout) {
+
+    $scope.svc = egSerialsCoreSvc;
+
+    var _paging_filter;
+    function reload(ssubId,filter) {
+        _paging_filter = filter;
+        return egSerialsCoreSvc.fetchItemsForSub(ssubId,filter).then(function() {
+            $scope.itemGridProvider.refresh();
+        });
+    }
+
+    $scope.filter_items_all = function () { return reload($scope.ssubId) }
+    $scope.filter_items_have = function () { return reload($scope.ssubId,{status:['Received','Bindery','Bound']}) }
+    $scope.filter_items_dont_have = function () { return reload($scope.ssubId,{'-not':{status:['Received','Bindery','Bound']}}) }
+    $scope.filter_items_by_status = function (item,status) { return reload($scope.ssubId,{status:status.name}) }
+
+    $scope.$watch('ssubId', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) return reload(newVal);
+    });
+
+    $scope.itemGridControls = {
+        activateItem : function (item) { } // TODO
+    };
+
+    function compileSort(sort) {
+        if (sort && angular.isArray(sort) && sort.length == 1) {
+            if (angular.isObject(sort[0])) {
+                for (key in sort[0]) {
+                    return {
+                        'class'   : 'sitem',
+                        field     :  key,
+                        direction : sort[0][key]
+                    };
+                }
+            } else {
+                return { 'class': 'sitem', field: sort[0] };
+            }
+        }
+    }
+    var current_sort = [];
+    $scope.itemGridProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            var self = this;
+            if (angular.equals(current_sort, self.sort) && egSerialsCoreSvc.itemList.length >= offset + count) { // if there's anything on the requested page, notify
+                return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+            } else { // else try to fetch another page
+                if (angular.equals(current_sort, self.sort)) {
+                    return egSerialsCoreSvc.fetchItemsForSubPaged(
+                        $scope.ssubId,
+                        _paging_filter,
+                        egSerialsCoreSvc.itemList.length,
+                        count + offset - egSerialsCoreSvc.itemList.length,
+                        compileSort(self.sort)
+                    ).then(function() {
+                        return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+                    });
+                } else {
+                    current_sort = self.sort;
+                    return egSerialsCoreSvc.fetchItemsForSub(
+                        $scope.ssubId,
+                        _paging_filter,
+                        null,
+                        compileSort(self.sort)
+                    ).then(function() {
+                        return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+                    });
+                }
+            }
+        }
+    });
+
+    $scope.delete_items = function (items) {
+        var list = [];
+
+        angular.forEach(items, function (i) {
+            var obj = egCore.idl.fromHash('sitem',i);
+            obj.isdeleted(1);
+            obj.stream(obj.stream().id); // API wants scalar or FM object
+            obj.issuance(obj.issuance().id);
+            list.push(obj);
+        });
+
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_CHANGE_ITEMS.delete,
+            egCore.strings.CONFIRM_CHANGE_ITEMS_MESSAGE.delete,
+            {items : list.length}
+        ).result.then(function () {
+            return egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.item.fleshed.batch.update',
+                egCore.auth.token(),
+                list
+            ).then( function(resp) {
+                var evt = egCore.evt.parse(resp);
+                if (evt) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                } else {
+                    ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                    return reload($scope.ssubId,_paging_filter);
+                }
+            });
+        });
+    }
+
+    $scope.edit_issuance_holding_code = function (items) {
+        var promises = [];
+        var edits = [];
+        angular.forEach(items.reverse(), function (item) {
+            promises.push( egSerialsCoreSvc.new_holding_code({
+                    title    : egCore.strings.SERIALS_EDIT_SISS_HC,
+                    curr_iss : egCore.idl.fromHash('siss',item.issuance),
+                    label    : item.issuance.label,
+                    type     : item.issuance.type ? item.issuance.type : 'basic',
+                    can_change_adhoc : true
+                }).then(function(result) {
+                    if (!result.adhoc) {
+                        item.issuance.holding_code = JSON.stringify(result.holding_code);
+                        item.issuance.holding_type = result.type;
+                    } else {
+                        item.issuance.label = result.label;
+                        item.issuance.holding_type = result.type;
+                    }
+
+                    item.issuance.date_published = result.date.toISOString();
+                    item.issuance.editor = egCore.auth.user();
+                    item.issuance.edit_date = 'now';
+
+                    var iss = egCore.idl.fromHash('siss',item.issuance);
+                    if (!result.adhoc) { // not an ad hoc issuance, get predicted label
+                        return egCore.net.request(
+                            'open-ils.serial',
+                            'open-ils.serial.make_prediction_values',
+                            egCore.auth.token(),
+                            { ssub_id : $scope.ssubId,
+                              num_to_predict : 0,
+                              include_base_issuance : 1,
+                              base_issuance : iss
+                            }
+                        ).then( function(resp) {
+                            var evt = egCore.evt.parse(resp);
+                            if (evt) {
+                                ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                            } else {
+                                iss.label(resp[0].label);
+                                edits.push(iss);
+                            }
+                        });
+                    }
+
+                    return $q.when(edits.push(iss));
+                })
+            );
+        });
+        return $q.all(promises)
+            .finally(function() {
+                if (edits.length) return update_issuances(edits);
+            });
+    }
+
+
+    function update_issuances (list) {
+        if (!angular.isArray(list)) list = [list];
+
+        return egCore.net.request(
+            'open-ils.serial',
+                'open-ils.serial.issuance.fleshed.batch.update',
+                egCore.auth.token(),
+                list
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+    }
+
+
+    $scope.following_issuance = function (items) {
+        return egSerialsCoreSvc.new_holding_code({
+            title : egCore.strings.SERIALS_ISSUANCE_ADD,
+            prev_iss : egCore.idl.fromHash('siss',items[0].issuance),
+            can_change_adhoc : true
+        }).then(function(hc) {
+            if (hc.adhoc) {
+                var new_iss = new egCore.idl.siss();
+                new_iss.creator( egCore.auth.user().id() );
+                new_iss.editor( egCore.auth.user().id() );
+                new_iss.date_published( hc.date.toISOString() );
+                new_iss.subscription( $scope.ssubId );
+                new_iss.label( hc.label );
+                new_iss.holding_type( hc.type );
+
+                return egCore.pcrud.create(new_iss).then(function(issuance) {
+                    var new_item = new egCore.idl.sitem();
+                    new_item.creator( egCore.auth.user().id() );
+                    new_item.editor( egCore.auth.user().id() );
+                    new_item.issuance( issuance.id() );
+                    new_item.stream( items[0].stream.id );
+                    new_item.date_expected( hc.date.toISOString() ); // XXX do we have interval math?
+
+                    return egCore.pcrud.create(new_item).then(function() {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    },function (error) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    });
+                },function (error) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                });
+            }
+
+            return egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.make_predictions',
+                egCore.auth.token(),
+                { ssub_id : $scope.ssubId,
+                  num_to_predict : 1,
+                  base_issuance : egCore.idl.fromHash('siss',items[0].issuance)
+                }
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+        });
+    }
+
+    $scope.add_special_issuance = function() {
+        return egSerialsCoreSvc.new_holding_code({
+            title : egCore.strings.SERIALS_SPECIAL_ISSUANCE_ADD,
+            can_change_adhoc : false,
+            adhoc       : true
+        }).then(function(hc) {
+            // perforce add hoc
+            var new_iss = new egCore.idl.siss();
+            new_iss.creator( egCore.auth.user().id() );
+            new_iss.editor( egCore.auth.user().id() );
+            new_iss.date_published( hc.date.toISOString() );
+            new_iss.subscription( $scope.ssubId );
+            new_iss.label( hc.label );
+            new_iss.holding_type( hc.type );
+
+            return egCore.pcrud.create(new_iss).then(function(issuance) {
+                var new_items = [];
+                var sub = egSerialsCoreSvc.get_ssub($scope.ssubId);
+                angular.forEach(sub.distributions(), function(dist) {
+                    angular.forEach(dist.streams(), function(stream) {
+                        var new_item = new egCore.idl.sitem();
+                        new_item.creator( egCore.auth.user().id() );
+                        new_item.editor( egCore.auth.user().id() );
+                        new_item.issuance( issuance.id() );
+                        new_item.stream( stream.id() );
+                        new_item.date_expected( hc.date.toISOString() ); // XXX do we have interval math?
+                        new_items.push(new_item);
+                    });
+                });
+                var promises = [];
+                angular.forEach(new_items, function(item) {
+                    promises.push(egCore.pcrud.create(item));
+                });
+
+                $q.all(promises).then(function() {
+                    ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                    return reload($scope.ssubId,_paging_filter);
+                },function (error) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                });
+            });
+        });
+    }
+
+    $scope.do_print_routing_lists = false;
+    egCore.hatch.getItem('eg.serials.items.do_print_routing_lists').then(function(val) {
+        $scope.do_print_routing_lists = val;
+    });
+
+    $scope.receive_and_barcode = false;
+    egCore.hatch.getItem('eg.serials.items.receive_and_barcode').then(function(val) {
+        $scope.receive_and_barcode = val;
+    });
+
+    $scope.checkbox_handler = function(item) {
+        $scope[item.checkbox] = item.checked;
+        egCore.hatch.setItem('eg.serials.items.'+item.checkbox, item.checked);
+    }
+
+    $scope.receive_next = function () {
+        var list = [];
+        var next_per_stream = {};
+        angular.forEach(egSerialsCoreSvc.itemTree, function (item) {
+            if (next_per_stream[item.stream().id()]) return;
+            if (item.status() == 'Expected') {
+                next_per_stream[item.stream().id()] = item;
+                list.push(egCore.idl.Clone(item));
+            }
+        });
+
+        return egSerialsCoreSvc.process_items('receive', $scope.bibId, list, $scope.receive_and_barcode, false, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.receive_selected = function (list) {
+        var items = list.filter(function(i){
+            return i.status == 'Expected';
+        });
+        return egSerialsCoreSvc.process_items('receive', $scope.bibId, items.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), $scope.receive_and_barcode, false, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.reset_selected = function (list) {
+        return egSerialsCoreSvc.process_items('reset', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), false, false, false, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.bind_selected = function (list) {
+        return egSerialsCoreSvc.process_items('bind', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), true, true, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.set_selected_as_claimed = function(list) {
+        return egSerialsCoreSvc.set_item_status('Claimed', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_discarded = function(list) {
+        return egSerialsCoreSvc.set_item_status('Discarded', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_not_published = function(list) {
+        return egSerialsCoreSvc.set_item_status('Not Published', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_not_held = function(list) {
+        return egSerialsCoreSvc.set_item_status('Not Held', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.menu_print_routing_lists = function (items) {
+        items = items.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        });
+        return egSerialsCoreSvc.print_routing_lists($scope.bibId, items, false, true, $scope.do_print_routing_lists);
+    }
+
+    $scope.add_issuances = function () {
+        egSerialsCoreSvc.add_issuances($scope.ssubId).then(function() {
+            return reload($scope.ssubId,_paging_filter);
+        });
+    }
+
+    $scope.need_one_selected = function() {
+        var items = $scope.itemGridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.need_many_selected = function() {
+        var items = $scope.itemGridControls.selectedItems();
+        if (items.length > 1) return false;
+        return true;
+    };
+
+    $scope.need_expected = function() {
+        var items = $scope.itemGridControls.selectedItems().filter(function(i){
+            return i.status == 'Expected';
+        });
+        if (items.length) return false;
+        return true;
+    };
+
+    $scope.item_notes = function(rows) {
+        return $scope.notes('item',rows);
+    }
+    // TODO - refactor this, it's duplicated in subscription_manager.js
+    $scope.notes = function(note_type,rows) {
+        if (!rows) { return; }
+
+        function modal(existing_notes) {
+            $uibModal.open({
+                templateUrl: './serials/t_notes',
+                animation: true,
+                controller: 'NotesCtrl',
+                resolve : {
+                    note_type : function() { return note_type; },
+                    rows : function() {
+                        return rows;
+                    },
+                    notes : function() {
+                        return existing_notes;
+                    }
+                },
+                windowClass: 'app-modal-window',
+                backdrop: 'static',
+                keyboard: false
+            }).result.then(function(notes) {
+                egCore.pcrud.apply(notes).then(
+                    function(a) { ngToast.success(egCore.strings.SERIALS_ITEM_NOTE_SUCCESS_SAVE) },
+                    function(a) { ngToast.danger(egCore.strings.SERIALS_ITEM_NOTE_FAIL_SAVE) }
+                );
+            });
+        }
+
+        if (rows.length == 1) {
+            var fm_hint;
+            var search_hash = {};
+            var search_opt = {};
+            switch(note_type) {
+                case 'subscription':
+                    fm_hint = 'ssubn';
+                    search_hash.subscription = rows[0]['id'];
+                    search_opt.order_by = { ssubn : 'create_date' };
+                break;
+                case 'distribution':
+                    fm_hint = 'sdistn';
+                    search_hash.distribution = rows[0]['sdist.id'];
+                    search_opt.order_by = { sdistn : 'create_date' };
+                break;
+                case 'item': default:
+                    fm_hint = 'sin';
+                    search_hash.item = rows[0]['id'];
+                    search_opt.order_by = { sin : 'create_date' };
+                break;
+            }
+            egCore.pcrud.search(fm_hint, search_hash, search_opt,
+                { atomic : true }
+            ).then(function(list) {
+                modal(list);
+            });
+        } else {
+                // support batch creation of notes across selections,
+                // but not editing
+                modal([]);
+        }
+    }
+
+}]
+
+    }
+})
+
+// TODO - refactor this; it's duplicated in subscription_manager.js
+.controller('NotesCtrl',
+       ['$scope','$uibModalInstance','egCore','note_type','rows','notes',
+function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
+    $scope.note_type = note_type;
+    $scope.focusNote = true;
+    $scope.note = {
+        creator : egCore.auth.user().id(),
+        title   : '',
+        value   : '',
+        pub     : false,
+        'alert' : false,
+    };
+
+    $scope.require_initials = false;
+    egCore.org.settings([
+        'ui.staff.require_initials.copy_notes'
+    ]).then(function(set) {
+        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
+    });
+
+    $scope.note_list = notes;
+
+    $scope.ok = function(note) {
+
+        var return_notes = [];
+        if (note.initials) note.value += ' [' + note.initials + ']';
+        if (   (typeof note.title != 'undefined' && note.title != '')
+            || (typeof note.value != 'undefined' && note.value != '')) {
+            angular.forEach(rows, function (r) {
+                var n;
+                switch(note_type) {
+                    case 'subscription':
+                        n = new egCore.idl.ssubn();
+                        n.subscription(r['id']);
+                        break;
+                    case 'distribution':
+                        n = new egCore.idl.sdistn();
+                        n.distribution(r['sdist.id']);
+                        break;
+                    case 'item':
+                    default:
+                        n = new egCore.idl.sin();
+                        n.item(r['id']);
+                }
+                n.isnew(true);
+                n.creator(note.creator);
+                n.pub(note.pub);
+                n['alert'](note['alert']);
+                n.title(note.title);
+                n.value(note.value);
+                return_notes.push( n );
+            });
+        }
+        angular.forEach(notes, function(n) {
+            if (n.ischanged() || n.isdeleted()) {
+                return_notes.push( n );
+            }
+        });
+        $uibModalInstance.close(return_notes);
+    }
+
+    $scope.cancel = function($event) {
+        $uibModalInstance.dismiss();
+        $event.preventDefault();
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/serials/services/core.js b/Open-ILS/web/js/ui/default/staff/serials/services/core.js
new file mode 100644 (file)
index 0000000..5fe4756
--- /dev/null
@@ -0,0 +1,1217 @@
+angular.module('egSerialsMod', ['egCoreMod'])
+.factory('egSerialsCoreSvc',
+       ['egCore','orderByFilter','$q','$filter','$uibModal','ngToast','egConfirmDialog',
+function(egCore , orderByFilter , $q , $filter , $uibModal , ngToast , egConfirmDialog) {
+    var DAY = 86400000;
+    var service = {
+        bibId : null,
+        subId : null,
+        subTree : [],
+        subList : [],
+        sptList : [],
+        mfhdList : [],
+        potentialPatternList : [],
+        flatMfhdList : [],
+        itemMap : {},
+        itemTree : [],
+        itemList : [],
+        freq_offset : {
+            a : 365 * DAY,
+            b : 62 * DAY,
+            c : 4 * DAY,
+            d : DAY,
+            e : 14 * DAY,
+            f : 186 * DAY,
+            g : 2 * 365 * DAY,
+            h : 3 * 365 * DAY,
+            i : 2 * DAY,
+            j : 10 * DAY,
+            k : DAY,
+            m : 31 * DAY,
+            q : 93 * DAY,
+            s : 14 * DAY,
+            t : 124 * DAY,
+            w : 7 * DAY,
+            x : 0
+        },
+        freq_chrons : {
+            a : ['year'],
+            b : ['year','month'],
+            c : ['year','month'],
+            d : ['year','month','day'],
+            e : ['year','month','day'],
+            f : ['year','month'],
+            g : ['year'],
+            h : ['year','month'],
+            i : ['year','month','day'],
+            j : ['year','month','day'],
+            k : ['year','month','day'],
+            m : ['year','month'],
+            q : ['year','season'],
+            s : ['year','month'],
+            t : ['year','month','day'],
+            w : ['year','month','day'],
+            x : ['year','month','day']
+        },
+        get_chron_part : {
+            year  : function(d) { return d.getFullYear() },
+            season: function(d) { return _loose_season(d) },
+            month : function(d) { return ('00' + (d.getMonth() + 1)).slice(-2) },
+            week  : function(d) { return $filter('date')(d, 'ww') },
+            day   : function(d) { return ('00'+d.getDate()).slice(-2) },
+            hour  : function(d) { return ('00'+d.getHours()).slice(-2) }
+        },
+        item_status_list : [
+            'Expected',
+            'Received',
+            'Claimed',
+            'Bindery',
+            'Bound',
+            'Discarded',
+            'Not Held',
+            'Not Published'
+        ],
+        item_status_i18n : []
+    };
+
+    angular.forEach(service.item_status_list, function(status) {
+        service.item_status_i18n.push({
+            name  : status,
+            label : egCore.strings.SERIALS_ITEM_STATUS[status]
+        });
+    });
+
+    function _loose_season(D) {
+        var m = D.getMonth() + 1;
+        var d = D.getDate();
+
+        if (
+            (m == 1 || m == 2) || (m == 12 && d >= 21) || (m == 3 && d < 20)
+        ) {
+            return 24;  /* MFHD winter */
+        } else if (
+            (m == 4 || m == 5) || (m == 3 && d >= 20) || (m == 6 && d < 21)
+        ) {
+            return 21;  /* spring */
+        } else if (
+            (m == 7 || m == 8) || (m == 6 && d >= 21) || (m == 9 && d < 22)
+        ) {
+            return 22;  /* summer */
+        } else {
+            return 23;  /* autumn */
+        }
+    }
+
+    service.fetch_mfhds = function(bibId, contextOrg) {
+        // TODO filter by contextOrg
+        return egCore.pcrud.search('sre', {
+                record       : bibId,
+                deleted      : 'f',
+                active       : 't'
+            }, {
+                flesh : 3,
+                flesh_fields : {
+                    'sre' : ['owning_lib']
+                }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            service.bibId = bibId;
+            service.mfhdList = list;
+            update_flat_mfhd_list();
+        });
+    }
+
+    service.fetch_patterns_from_bibs_mfhds = function(bibId) {
+        return egCore.net.request(
+            'open-ils.serial',
+            'open-ils.serial.caption_and_pattern.find_legacy_by_bib_record.atomic',
+            egCore.auth.token(),
+            bibId
+        ).then(function(list) {
+            service.potentialPatternList = egCore.idl.toTypedHash(list);
+            angular.forEach(service.potentialPatternList, function(pot) {
+                var rec = new MARC21.Record({ marcxml : pot.marc });
+                var pattern_fields = rec.fields.filter(function(f) {
+                    return (f.tag == '853' || f.tag == '854' || f.tag == '855');
+                });
+                pot.desc = '';
+                if (pattern_fields.length > 0) {
+                    // just take the first one
+                    var fld = pattern_fields[0];
+                    pot.desc = fld.tag + ' ' + fld.ind1 + fld.ind2 +
+                               fld.subfields.map(function(sf) { 
+                                 return '$' + sf[0] + sf[1]
+                               }).join('');
+                }
+            });
+        })
+    }
+
+    // fetch subscription, distributions, streams, captions,
+    // and notes associated with the indicated bib
+    service.fetch = function(bibId, contextOrg) {
+
+        var filter = { record_entry : bibId };
+        if (contextOrg) filter.owning_lib = egCore.org.descendants(contextOrg, true);
+        return egCore.pcrud.search('ssub', filter,
+            {
+                flesh : 5,
+                flesh_fields : {
+                    'ssub'  : ['owning_lib','distributions', 'scaps', 'notes'],
+                    'sdist' : [ 'record_entry','holding_lib',
+                                'receive_call_number',
+                                'receive_unit_template',
+                                'bind_call_number',
+                                'bind_unit_template',
+                                'streams','notes'],
+                    'sstr'  : ['routing_list_users'],
+                    'srlu'  : ['reader'],
+                    'au'    : ['card','home_ou','mailing_address','billing_address']
+                }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            service.bibId = bibId;
+            service.subTree = list;
+            update_flat_sdist_sstr_list();
+            return $q.when(list);
+        });
+    }
+
+    // fetch subscription, distributions, streams, captions,
+    // and notes associated with the indicated bib
+    service.fetchLastCallnumber = function(contextOrg) {
+        return egCore.pcrud.search('acn', {
+                record : service.bibId,
+                owning_lib : contextOrg,
+                deleted : 'f'
+            }, { flesh : 1,
+                 flesh_fields : {acn : ['prefix','suffix']},
+                 order_by : [{class:'acn',field:'create_date',direction:'desc'}],
+                 limit : 1
+            }, { atomic : true }
+        ).then(function(list) {
+            return $q.when(list[0]);
+        });
+    }
+
+    service.fetchItemsForSubPaged = function(subId,filter,offset,limit,sort) {
+        return service.fetchItemsForSub(
+            subId,
+            filter,
+            { limit : limit, offset : offset, paging : true },
+            sort
+        );
+    }
+
+    // Creates an inverted tree from item to sub
+    service.fetchItemsForSub = function(subId,filter,options,sort) {
+        var deferred = $q.defer(); // side-effects only, otherwise the grid is wonky
+
+        if (!filter) filter = {};
+        if (!options) options = { limit : 100 }; // only used during full refresh
+
+        if (!subId && service.subId) subId = service.subId;
+        if (!subId) return $q.reject('fetchItemsForSub: no subscription id');
+
+        var sub = service.get_ssub(subId);
+        if (!sub) return $q.reject('fetchItemsForSub: unknown subscription id');
+
+        var streams = [];
+        angular.forEach(sub.distributions(), function(dist) {
+            angular.forEach(
+                dist.streams().map(
+                    function (stream) { return stream.id() }
+                ),
+                function (sid) { streams.push(sid) }
+            );
+        });
+
+        angular.extend(filter, {stream:streams});
+        angular.extend(options, { 
+            order_by : [{class:'sitem',field:'date_expected'}], // best aprox of pub date
+            flesh : 1,
+            flesh_fields : {
+                sitem : ['notes','issuance','editor','creator','unit','url']
+            }
+        });
+        if (sort) {
+            angular.extend(options, {
+                order_by : [sort]
+            });
+        }
+
+        egCore.pcrud.search(
+            'sitem', filter, options,
+            { atomic : true }
+        ).then(function(list) {
+            service.subId = subId;
+            if (!options.paging) { // not paged
+                service.itemTree = list;
+                service.itemMap = {};
+            } else { // paged
+                angular.forEach(list, function (item) {
+                    var exists = service.itemTree.filter(function (i) {
+                        return i.id() == item.id()
+                    }).length;
+                    if (!exists) service.itemTree.push(item);
+                });
+            }
+
+            // map items by stream for faster lookup
+            var tmp = {};
+            angular.forEach(list, function(item) {
+                if (!tmp[item.stream()]) tmp[item.stream()] = [];
+                tmp[item.stream()].push(item);
+                service.itemMap[item.id()] = item;
+            });
+
+            angular.forEach(sub.distributions(), function(dist) {
+                angular.forEach(dist.streams(), function(stream) {
+                    angular.forEach(tmp[stream.id()], function (item) {
+                        var routing_list = egCore.idl.Clone(stream.routing_list_users());
+                        var st = egCore.idl.Clone(stream,1);
+                        st.routing_list_users(routing_list);
+                        var d = egCore.idl.Clone(dist,1);
+                        var ss = egCore.idl.Clone(sub,1);
+                        ss.distributions([]);
+                        d.subscription(ss);
+                        d.streams([]);
+                        st.distribution(d);
+                        item.stream(st);
+                    });
+                });
+            });
+
+            var hashList = egCore.idl.toHash(service.itemTree);
+            angular.forEach(hashList, function (item) {
+                item['issuance.date_published'] = item.issuance.date_published;
+                item['stream.distribution.holding_lib.name'] = item.stream.distribution.holding_lib.name;
+            });
+
+            // ... then sort it
+            if (sort) {
+                service.itemList = hashList;
+            } else {
+                service.itemList = orderByFilter(hashList, ['"issuance.date_published"', '"stream.distribution.holding_lib.name"', '"id"']);
+            }
+            deferred.resolve();
+        });
+
+        return deferred.promise;
+    }
+
+    service.prep_new_holding_code = function (args) {
+
+        var type = args.type;
+        var date = args.date;
+        var prev_iss = args.prev_iss;
+        var curr_iss = args.curr_iss;
+        var adhoc = false;
+        var link = '1.1';
+        var current_values = {};
+
+        var sub = service.get_ssub(service.subId);
+        if (!sub) return args;
+
+        var scap;
+        if (prev_iss && prev_iss.holding_code()) { // we're predicting
+            var old_link_parts = JSON.parse(prev_iss.holding_code())[3].split('.');
+            var olink = old_link_parts[0];
+            var oseq = parseInt(old_link_parts[1]) + 1;
+            link = [olink,oseq].join('.');
+
+            if (prev_iss.holding_type())
+                type = prev_iss.holding_type();
+
+            if (prev_iss.caption_and_pattern()) {
+                var tmp = sub.scaps().filter(function (s) {
+                    return (s.id() == prev_iss.caption_and_pattern());
+                });
+                if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+            }
+
+            date = new Date(prev_iss.date_published());
+        } else if (curr_iss) { // we're editing
+            if (curr_iss.holding_type())
+                type = curr_iss.holding_type();
+
+            if (curr_iss.caption_and_pattern()) {
+                var tmp = sub.scaps().filter(function (s) {
+                    return (s.id() == curr_iss.caption_and_pattern());
+                });
+                if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+            }
+            if (!curr_iss.holding_code()) {
+                adhoc = true;
+            } else {
+                var tmp = JSON.parse(curr_iss.holding_code());
+                for (var i = 2; i < tmp.length; i += 2) {
+                    // we're intentionally being a bit sloppy here, as
+                    // the only subfields we are about in this context
+                    // are the ones that are not repeatable
+                    current_values[tmp[i]] = tmp[i + 1];
+                }
+            }
+
+            date = new Date(curr_iss.date_published());
+        } else {
+            // starting from scratch, so default the
+            // first publication date to the subscription start date
+            if (!date) date = new Date(sub.start_date());
+        }
+
+        args.date = date;
+
+        if (!scap) {
+            var tmp = sub.scaps().filter(function (s) {
+                return (s.type() == type && s.active() == 't');
+            });
+            if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+        }
+
+        if (!scap) return args;
+
+        var others = [], enums = [], chrons = [], freq = '';
+        var pat = JSON.parse(scap.pattern_code()).slice(4); // just the part we care about
+
+        var freq_index = pat.indexOf('w');
+        if (freq_index > -1) {
+            freq = pat[freq_index + 1];
+            if (prev_iss) {
+                date = new Date(
+                    date.getTime() + service.freq_offset[freq]
+                );
+            }
+        }
+       
+        if (!date) date = new Date();
+
+        for (var i = 0; i < pat.length; i++) {
+            sf = pat[i]; i++;
+            val = pat[i];
+
+            if (sf != 'w') {
+                var pat_part = {
+                    subfield : sf,
+                    pattern  : val
+                };
+
+                var chron_part = String(val).replace(/[)(]+/g,'');
+                if (sf in current_values) {
+                    pat_part.value = current_values[sf];
+                } else {
+                    try {
+                        pat_part.value = service.get_chron_part[chron_part](date);
+                    } catch (e) {
+                        // not a chron part
+                        pat_part.value = '';
+                    }
+                }
+
+                if (sf.match(/[a-f]/)) {
+                    enums.push(pat_part);
+                } else if (sf.match(/[i-l]/)) {
+                    chrons.push(pat_part);
+                } else {
+                    others.push(pat_part);
+                }
+            }
+        }
+
+        if (enums.length == 0 && chrons.length == 0) {
+            var parts = service.freq_chrons[freq];
+            if (parts.length) {
+                angular.forEach(parts, function(p, ind) {
+                    var sf = !ind ? 'i' : !--ind ? 'j' : 'k';
+                    chrons.push({
+                        subfield : sf,
+                        value    : service.get_chron_part.year(date)
+                    });
+                });
+            } else { 
+                chrons = [
+                    { subfield : 'i', value : service.get_chron_part.year(date)  },
+                    { subfield : 'j', value : service.get_chron_part.month(date) },
+                    { subfield : 'k', value : service.get_chron_part.day(date)  }
+                ];
+            }
+        }
+
+        return {
+            holding_code : ["4","1","8",link],
+            scap         : scap.id(),
+            type         : type,
+            date         : date,
+            enums        : enums,
+            chrons       : chrons,
+            others       : others,
+            freq         : freq,
+            adhoc        : adhoc
+        };
+    }
+
+    service.new_holding_code = function (options) {
+        if (options === undefined) options = {};
+        options.count = options.count || 1;
+        options.label = options.label || '';
+
+        return $uibModal.open({
+            templateUrl: './serials/t_holding_code_dialog',
+            //size: 'lg',
+            //windowClass: 'eg-wide-modal',
+            backdrop: 'static',
+            controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.focusMe = true;
+                $scope.title = options.title;
+                $scope.request_count = options.request_count;
+                $scope.count = options.count;
+                $scope.label = options.label;
+                $scope.save_label = options.save_label;
+                $scope.pubdate = options.date;
+                $scope.type = options.type || 'basic';
+                $scope.args = { adhoc : false };
+                if (options.adhoc) $scope.args.adhoc = true;
+                $scope.can_change_adhoc = options.can_change_adhoc;
+
+                function refresh (n,o) {
+                    if (n && o && n !== o) {
+                        $scope.args = service.prep_new_holding_code({
+                            type : $scope.type,
+                            date : $scope.pubdate,
+                            prev_iss : options.prev_iss,
+                            curr_iss : options.curr_iss,
+                        });
+                        if (!options.can_change_adhoc && options.adhoc) $scope.args.adhoc = true;
+
+                        if ($scope.args.type && $scope.type != $scope.args.type)
+                            $scope.type = $scope.args.type;
+                        if ($scope.args.date)
+                            $scope.pubdate = $scope.args.date;
+
+                        delete options.prev_iss; // only use this once
+                        delete options.curr_iss; // only use this once
+                    }
+                }
+
+                $scope.$watch('count',function (n) {options.count = n});
+                $scope.$watch('label',function (n) {options.label = n});
+                $scope.$watch('type',refresh);
+                $scope.$watch('pubdate',refresh);
+
+                $scope.ok = function(args) { $uibModalInstance.close(args) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+
+                refresh(1,2); // force data loading
+            }]
+        }).result.then(function (args) {
+            if (args.enums && args.chrons) {
+                angular.forEach(
+                    args.enums.concat(args.chrons),
+                    function (e) {
+                        args.holding_code.push(e.subfield);
+                        args.holding_code.push(e.value);
+                    }
+                );
+            }
+            args.count = options.count;
+            args.label = options.label;
+            return $q.when(args);
+        });
+    }
+
+    function update_flat_mfhd_list() {
+        var list = [];
+        angular.forEach(service.mfhdList, function(sre) {
+            var mfhdHash = egCore.idl.toHash(sre);
+            var rec = new MARC21.Record({ marcxml : mfhdHash.marc });
+            var _mfhd = {
+                'id'                   : mfhdHash.id,
+                'owning_lib.name'      : mfhdHash.owning_lib.name,
+                'owning_lib.id'        : mfhdHash.owning_lib.id,
+                'marc'                 : rec.toBreaker(),
+                'marc_xml'             : mfhdHash.marc,
+                'svr'                  : null,
+                'basic_holdings'       : null,
+                'index_holdings'       : null,
+                'supplement_holdings'  : null
+            }
+            list.push(_mfhd);
+            egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.serial.record.mfhd.retrieve',
+                mfhdHash.id
+            ).then(function(svr) {
+                _mfhd.svr = egCore.idl.toTypedHash(svr);
+                _mfhd.basic_holdings = _mfhd.svr.basic_holdings.join("; ");
+                _mfhd.index_holdings = _mfhd.svr.index_holdings.join("; ");
+                _mfhd.supplement_holdings = _mfhd.svr.supplement_holdings.join("; ");
+            })
+        });
+        service.flatMfhdList.length = 0;
+        angular.extend(service.flatMfhdList, list);
+    }
+
+    // create/update a flat version of the subscription/distribution/stream
+    // tree for feeding to the distribution and stream grid
+    function update_flat_sdist_sstr_list() {
+
+        // flatten the structure...
+        var list = [];
+        angular.forEach(service.subTree, function(ssub) {
+            var ssubHash = egCore.idl.toHash(ssub);
+
+            var _ssub = {
+                'id'                   : ssubHash.id,
+                'owning_lib.name'      : ssubHash.owning_lib.name,
+                'owning_lib.id'        : ssubHash.owning_lib.id,
+                'start_date'           : ssubHash.start_date,
+                'end_date'             : ssubHash.end_date,
+                'expected_date_offset' : ssubHash.expected_date_offset
+            };
+            // insert and escape if we have no distributions
+            if (ssubHash.distributions.length == 0) {
+                list.push(_ssub);
+                return;
+            }
+
+            angular.forEach(ssubHash.distributions, function(sdist) {
+                var _sdist = {};
+                angular.forEach([
+                    'id',
+                    'summary_method',
+                    'record_entry',
+                    'label',
+                    'display_grouping',
+                    'unit_label_prefix',
+                    'unit_label_suffix',
+                ], function(fld) {
+                    _sdist['sdist.' + fld] = sdist[fld];
+                });
+                _sdist['sdist.holding_lib.name'] = sdist.holding_lib.name;
+                _sdist['sdist.holding_lib.id'] = sdist.holding_lib.id;
+                _sdist['sdist.receive_call_number.label'] = 
+                    sdist.receive_call_number ? sdist.receive_call_number.label : null;
+                _sdist['sdist.receive_unit_template.name'] =
+                    sdist.receive_unit_template ? sdist.receive_unit_template.name : null;
+                _sdist['sdist.bind_call_number.label'] =
+                    sdist.bind_call_number ? sdist.bind_call_number.label : null;
+                _sdist['sdist.bind_unit_template.name'] =
+                    sdist.bind_unit_template ? sdist.bind_unit_template.name : null;
+                // if we have no streams, add to the list and escape
+                if (sdist.streams.length == 0) {
+                    var row = {};
+                    angular.extend(row, _ssub, _sdist);
+                    list.push(row);
+                    return;
+                }
+
+                angular.forEach(sdist.streams, function(sstr) {
+                    var _sstr = {
+                        'sstr.id'                 : sstr.id,
+                        'sstr.routing_label'      : sstr.routing_label,
+                        'sstr.additional_routing' : ((sstr.routing_list_users.length > 0) ? true : false)
+                    };
+                    var row = {};
+                    angular.extend(row, _ssub, _sdist, _sstr);
+                    list.push(row);
+                });
+            });
+        });
+
+        // ... then sort it
+        service.subList.length = 0;
+        angular.extend(service.subList,
+            orderByFilter(list, ['"owning_lib.name"', '"start_date"', '"end_date"',
+                                 '"holding_lib.name"', '"sdist.id"', '"sstr.id"'])
+        );
+
+        // ... then remove duplication of owning library, distribution library,
+        // and distribution labels
+        var sub_lib = null;
+        var dist_lib = null;
+        var dist_label = null;
+        var index = 0;
+        angular.forEach(service.subList, function(row) {
+            row['index'] = index++;
+            if (sub_lib == row['owning_lib.name']) {
+                row['owning_lib.name'] = null;
+            } else {
+                sub_lib = row['owning_lib.name'];
+                dist_lib = row['sdist.holding_lib.name'];
+                dist_label = row['sdist.label'];
+                return;
+            }
+            if (dist_lib == row['sdist.holding_lib.name']) {
+                row['sdist.holding_lib.name'] = null;
+            } else {
+                dist_lib = row['sdist.holding_lib.name'];
+            }
+            if (dist_label == row['sdist.label']) {
+                row['sdist.label'] = null;
+            } else {
+                dist_label = row['sdist.label'];
+            }
+        });
+    }
+
+    // verify that a subscription ID and bib ID are actually
+    // associated with each other
+    service.verify_subscription_id = function(bibId, ssubId) {
+        var deferred = $q.defer();
+        egCore.pcrud.search('ssub', {
+                record_entry : bibId,
+                id           : ssubId
+        }, {}, { atomic : true, idlist : true }
+        ).then(function(list) {
+            if (list.length == 1) {
+                deferred.resolve(true);
+            } else {
+                deferred.resolve(false);
+            }
+        });
+        return deferred.promise;
+    }
+
+    service.get_ssub = function(ssubId) {
+        if (!ssubId) return;
+        for (var i = 0; i <= service.subTree.length; i++) {
+            if (service.subTree[i].id() == ssubId) {
+                return service.subTree[i];
+            }
+        }
+    }
+
+    service.fetch_spt = function() {
+        return egCore.net.request(
+            'open-ils.serial',
+            'open-ils.serial.pattern_template.retrieve.at.atomic',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou()
+        ).then(function(list) {
+            service.sptList.length = 0;
+            angular.extend(service.sptList, list);
+        });
+    }
+
+    service.fetch_templates = function(org) {
+        return egCore.pcrud.search('act',
+            {owning_lib : egCore.org.fullPath(org, true)},
+            {order_by : { act : 'name' }}, {atomic : true}
+        );
+    };
+
+    service.print_routing_lists = function (bibId, items, check, force, print_rl) {
+        if (!check && !print_rl && !force) return $q.when();
+
+        return egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.record.mods_slim.retrieve',
+            bibId
+        ).then(function(mvr) {
+
+            var by_issuance = {};
+            angular.forEach(items, function (i) {
+                if (check && !i._print_routing_list) return;
+                if (!by_issuance[i.issuance().id()])
+                    by_issuance[i.issuance().id()] = [];
+                by_issuance[i.issuance().id()].push(i);
+            });
+
+            var issuance_matrix = [];
+            angular.forEach(by_issuance, function (list) {
+                issuance_matrix.push(list);
+            });
+
+            var deferred = $q.defer();
+            var promise = deferred.promise;
+
+            angular.forEach(issuance_matrix, function(item_list, index) {
+
+                promise = promise.then(function(){
+                    return $uibModal.open({
+                        templateUrl: './serials/t_print_routing_list',
+                        size: 'lg',
+                        windowClass: 'eg-wide-modal',
+                        backdrop: 'static',
+                        controller:
+                        ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                            var all_users = [];
+                            var all_streams = [];
+
+                            angular.forEach(item_list, function(i){
+                                all_streams.push(i.stream());
+                                all_users = all_users.concat(i.stream().routing_list_users());
+                            });
+
+                            $scope.xulg = {
+                                show_print_button: true,
+                                routing_list_data: {
+                                    streams : all_streams,
+                                    mvr     : mvr,
+                                    issuance: item_list[0].issuance(),
+                                    users   : orderByFilter(all_users, 'pos')
+                                }
+                            };
+
+                            $scope.url = '/eg/serial/print_routing_list_users?ses=' + egCore.auth.token();
+                            $scope.last = index == issuance_matrix.length - 1 ? true : false; 
+                            $scope.ok = function() { $uibModalInstance.close() }
+                        }]
+                    }).result;
+                });
+
+            });
+
+            return deferred.resolve();
+        });
+
+    }
+
+    service.set_item_status = function(newStatus, bibId, list, callback) {
+        if (!callback) callback = function () { return $q.when() }
+        if (!list.length) return $q.reject();
+
+        return egConfirmDialog.open(
+            egCore.strings.CONFIRM_CHANGE_ITEMS.status,
+            egCore.strings.CONFIRM_CHANGE_ITEMS_MESSAGE.status,
+            {items : list.length}
+        ).result.then(function () {
+            var promises = [$q.when()];
+            angular.forEach(list, function(item) {
+                item.status(newStatus);
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.item.update',
+                        egCore.auth.token(),
+                        item
+                    ).then(function(res) {
+                        return $q.when();
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                callback();
+            });
+        });
+    }
+    
+    service.process_items = function (mode, bibId, list, do_barcode, bind, print_rl, callback) {
+        if (!callback) callback = function () { return $q.when() }
+        if (!list.length) return $q.reject();
+
+        // deal with locations and circ mods for *NEW* units
+        var copy_locations = {};
+        var circ_mods = {};
+
+        // deal with barcodes and call numbers for *NEW* units
+        var barcodes = {};
+        var call_numbers = {};
+        var call_numbers_by_siss_and_sdist = {};
+
+        var deferred = $q.defer();
+        var current_promise = deferred.promise;
+        var last_promise;
+
+        var sitem_alerts = [];
+        var sdist_alerts = [];
+        var ssub_alerts = list[0].stream().distribution().subscription().notes().filter(function(n){
+            return n.alert() == 't';
+        })
+
+        var dist_seen = {};
+        angular.forEach(list, function(i) {
+            sitem_alerts = sitem_alerts.concat(
+                i.notes().filter(function(n){
+                    return n.alert() == 't';
+                })
+            );
+            var sdist = '_'+i.stream().distribution().id();
+            if (!dist_seen[sdist]) {
+                dist_seen[sdist] = 1;
+                sdist_alerts = sdist_alerts.concat(
+                    i.stream().distribution().notes().filter(function(n){
+                        return n.alert() == 't';
+                    })
+                );
+            }
+        });
+
+        if (do_barcode || bind) {
+
+            last_promise = current_promise.then(function(){ return $uibModal.open({
+                templateUrl: './serials/t_batch_receive',
+                size: 'lg',
+                windowClass: 'eg-wide-modal',
+                backdrop: 'static',
+                controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+
+                    $scope.print_routing_lists = print_rl;
+                    $scope.barcode_items = do_barcode;
+                    $scope.force_bind = bind;
+                    $scope.bind = bind;
+                    $scope.items = list;
+                    $scope.ssub_alerts = ssub_alerts;
+                    $scope.sdist_alerts = sdist_alerts;
+                    $scope.sitem_alerts = sitem_alerts;
+                    $scope.acn_list = [];
+                    $scope.acnp_labels = [];
+                    $scope.acns_labels = [];
+                    $scope.acpl_list = [];
+
+                    $scope.cannot_print = function (index) {
+                        return $scope.items[index].stream().routing_list_users().length == 0 || ($scope.bind && index > 0);
+                    }
+
+                    $scope.bind_or_none = function (index) {
+                        return !$scope.barcode_items || ($scope.bind && index > 0);
+                    }
+
+                    $scope.focus_next_barcode = function (index) {
+                        index++;
+                        $('#item_barcode_'+index).focus().select();
+                    }
+
+                    $scope.fullCNLabel = function (cn) {
+                        var label = [cn.prefix.label,cn.label,cn.suffix.label].join(' ');
+                        return label;
+                    }
+
+                    $scope.apply_template_overrides = function (e) {
+                        if ($scope.selected_call_number) {
+                            angular.forEach($scope.items, function (i) {
+                                i._call_number = $scope.selected_call_number.label;
+                                i._cn_prefix = $scope.selected_call_number.prefix.label;
+                                i._cn_suffix = $scope.selected_call_number.suffix.label;
+                            });
+                        }
+                        if ($scope.selected_circ_mod) {
+                            angular.forEach($scope.items, function (i) {
+                                i._circ_mod = $scope.selected_circ_mod;
+                            });
+                        }
+                        if ($scope.selected_copy_location) {
+                            angular.forEach($scope.items, function (i) {
+                                i._copy_location = $scope.selected_copy_location;
+                            });
+                        }
+                    }
+
+                    $scope.ok = function(items) { $uibModalInstance.close(items) }
+                    $scope.cancel = function () { $uibModalInstance.dismiss() }
+
+                    var dist_libs = {};
+                    var pile_o_promises = [$q.when()];
+
+                    // let's gather what we need...
+                    angular.forEach(list, function (i, index) {
+                        var dlib = i.stream().distribution().holding_lib().id();
+                        dist_libs[dlib] = egCore.org.fullPath(dlib, true);
+                        if (i.unit()) {
+                            i._barcode = i.unit().barcode();
+                            pile_o_promises.push(
+                                egCore.pcrud.retrieve(
+                                    'acn', i.unit().call_number(),
+                                    {flesh : 1, flesh_fields : {acn : ['prefix','suffix']}}
+                                ).then(function(cn){
+                                    if (cn.deleted() == 'f') {
+                                        i._call_number = cn.label();
+                                        i._cn_prefix = cn.prefix().label();
+                                        i._cn_suffix = cn.suffix().label();
+                                    }
+                                })
+                            );
+                        } else {
+                            if (i.stream().distribution()[mode + '_call_number']() && 
+                                i.stream().distribution()[mode + '_call_number']().deleted() == 'f'
+                            ) {
+                                i._call_number = i.stream().distribution()[mode + '_call_number']().label();
+                            } else {
+                                pile_o_promises.push(
+                                    service.fetchLastCallnumber(
+                                        i.stream().distribution().holding_lib().id()
+                                    ).then(function(cn){
+                                        if (cn) {
+                                            i._call_number = cn.label();
+                                            i._cn_prefix = cn.prefix().label();
+                                            i._cn_suffix = cn.suffix().label();
+                                        }
+                                    })
+                                );
+                            }
+                        }
+
+                        if (i.stream().distribution()[mode + '_unit_template']()) {
+                            i._copy_location = i.stream().distribution()[mode + '_unit_template']().location();
+                            i._circ_mod = i.stream().distribution()[mode + '_unit_template']().circ_modifier();
+                        }
+
+                        if ($scope.print_routing_lists && !$scope.cannot_print(index))
+                            i._print_routing_list = true;
+
+                        i._receive = true;
+                    });
+
+                    // build unique list of orgs from distribution.holding_lib fullPaths
+                    var dist_lib_list = [];
+                    angular.forEach(dist_libs, function (l) {
+                        dist_lib_list = dist_lib_list.concat(l);
+                    });
+                    dist_lib_list = dist_lib_list.filter(function(v,i,s){
+                        return s.indexOf(v) == i;
+                    });
+
+                    // Copy locations only come from the workstation location, same as XUL
+                    pile_o_promises.push(egCore.pcrud.search(
+                        'acpl',
+                        {owning_lib : egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+                        {},{ atomic : true }
+                    ).then(function (list) {
+                        $scope.acpl_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    // Call numbers, however, come from anywhere the distributions might live
+                    pile_o_promises.push(egCore.pcrud.search(
+                        'acn',
+                        {deleted : 'f', record : bibId, owning_lib : dist_lib_list},
+                        {flesh : 1, flesh_fields : {acn : ['prefix','suffix']}},{ atomic : true }
+                    ).then(function (list) {
+                        $scope.acn_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    // Likewise for prefix and suffix, for combo box
+                    angular.forEach(['acnp','acns'], function (cl) {
+                        pile_o_promises.push(egCore.pcrud.search(
+                            cl,
+                            {owning_lib : dist_lib_list},
+                            {},{ atomic : true }
+                        ).then(function (list) {
+                            $scope[cl+'_labels'] = list.map(function(i){return i.label()});
+                            return $q.when();
+                        }));
+                    });
+
+                    pile_o_promises.push(egCore.pcrud.retrieveAll(
+                        'ccm', {}, { atomic : true }
+                    ).then(function (list) {
+                        $scope.ccm_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    $q.all(pile_o_promises).then(function() {
+                        console.log('receive data collected');
+                    });
+
+                    $scope.$watch('barcode_items', function (n,o) {
+                        if (n === undefined || n == o) return;
+                        do_barcode = n;
+                    });
+
+                    $scope.$watch('bind', function (n,o) {
+                        if (n === undefined || n == o) return;
+                        bind = n;
+                        if (bind) {
+                            angular.forEach($scope.items, function (i,index) {
+                                if (index > 0) i._print_routing_list = false;
+                            });
+                        }
+                    });
+                        
+                    $scope.$watch('auto_barcodes', function (n,o) {
+                        if (n === undefined || n == o) return;
+
+                        var bc = '@@AUTO';
+                        if (!n) bc = '';
+
+                        angular.forEach($scope.items, function (i) {
+                            if (!i.stream().distribution().receive_unit_template()) return;
+                            var _barcode = i._barcode;
+                            i._barcode = bc || i._old_barcode;
+                            i._old_barcode = _barcode;
+                        });
+                    });
+
+                    $scope.$watch('print_routing_lists', function (n,o) {
+                        if (n === undefined || n == o) return;
+
+                        angular.forEach($scope.items, function(i, index) {
+                            if (!$scope.cannot_print(index)) {
+                                i._print_routing_list = n;
+                            } else {
+                                i._print_routing_list = false;
+                            }
+                        });
+                    });
+                }]
+            }).result});
+        } else {
+            last_promise = current_promise.then(function(){ return $uibModal.open({
+                templateUrl: './serials/t_receive_alerts',
+                controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                    $scope.title = egCore.strings.CONFIRM_CHANGE_ITEMS[mode];
+                    $scope.items = list.length;
+                    $scope.list = list;
+                    $scope.mode = mode;
+                    $scope.ssub_alerts = ssub_alerts;
+                    $scope.sdist_alerts = sdist_alerts;
+                    $scope.sitem_alerts = sitem_alerts;
+
+                    $scope.ok = function(items) { $uibModalInstance.close(items) }
+                    $scope.cancel = function () { $uibModalInstance.dismiss() }
+                }]
+            }).result.then(
+                function(items) {
+                    angular.forEach(list, function (i, index) {
+                        i._receive = true;
+                    });
+                    return $q.when(list);
+                })
+            });
+        }
+
+        last_promise.then(function (items) {
+
+            var method;
+            if (mode == 'receive') {
+                method = 'open-ils.serial.receive_items';
+                items = items.filter(function(i){return i._receive});
+            } else if ( mode == 'bind') {
+                method = 'open-ils.serial.bind_items';
+                items = items.filter(function(i){return i._receive});
+            } else if ( mode == 'reset') {
+                method = 'open-ils.serial.reset_items';
+            } 
+
+            if (!items.length) return $q.reject();
+
+            var donor_unit_ids = {};
+            angular.forEach(items, function(i, index) {
+                if (i.unit()) donor_unit_ids[i.unit().id()] = 1;
+                if (do_barcode) i.unit(-1);
+                if (bind) i.unit(-2);
+                copy_locations[i.id()] = i._copy_location;
+                circ_mods[i.id()] = i._circ_mod;
+                call_numbers[i.id()] = [i._cn_prefix, i._call_number, i._cn_suffix] || 'DEFAULT';
+                barcodes[i.id()] = i._barcode || '@@AUTO';
+                if (bind && index > 0) barcodes[i.id()] = items[0]._barcode;
+            });
+
+            return egCore.net.request(
+                'open-ils.serial', method,
+                egCore.auth.token(), items, barcodes, call_numbers, donor_unit_ids,
+                    {circ_mods:circ_mods, copy_locations : copy_locations}
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return service.print_routing_lists(bibId, items, do_barcode || bind, false, print_rl)
+                            .finally(callback);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+        });
+
+        return deferred.resolve();
+    }
+
+    service.add_issuances = function (mySsubId) {
+        if (!mySsubId && service.subId) mySsubId = service.subId;
+        if (!mySsubId) return $q.reject('fetchItemsForSub: no subscription id');
+
+        var sub = service.get_ssub(mySsubId);
+        if (!sub) return $q.reject('fetchItemsForSub: unknown subscription id');
+
+        var streams = [];
+        angular.forEach(sub.distributions(), function(dist) {
+            angular.forEach(
+                dist.streams().map(
+                    function (stream) { return stream.id() }
+                ),
+                function (sid) { streams.push(sid) }
+            );
+        });
+
+        var options = { 
+            order_by : [{class:'sitem',field:'date_expected',direction:'desc'}], // best aprox of pub date
+            limit : 1,
+            flesh : 1,
+            flesh_fields : { sitem : ['issuance'] }
+        };
+
+        return egCore.pcrud.search(
+            'sitem', {stream:streams},
+            {   order_by : [{class:'sitem',field:'date_expected',direction:'desc'}], // best aprox of pub date
+                limit : 1,
+                flesh : 1,
+                flesh_fields : { sitem : ['issuance'] }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            var lastItem = list[0];
+    
+            if (lastItem) lastItem = lastItem.issuance();
+    
+            return service.new_holding_code({
+                title : egCore.strings.SERIALS_ISSUANCE_PREDICT,
+                request_count : true,
+                prev_iss : lastItem,
+                allow_adhoc : false
+            }).then(function(hc) {
+    
+                var base_iss;
+                if (!lastItem) {
+                    base_iss = new egCore.idl.siss();
+                    base_iss.creator( egCore.auth.user().id() );
+                    base_iss.editor( egCore.auth.user().id() );
+                    base_iss.date_published( hc.date.toISOString() );
+                    base_iss.subscription( mySsubId );
+                    base_iss.caption_and_pattern( hc.scap );
+                    base_iss.holding_code( JSON.stringify(hc.holding_code) );
+                    base_iss.holding_type( hc.type );
+                }
+    
+                // if we're predicting without a preexisting holding, reduce the count
+                if (!lastItem) hc.count--;
+    
+                return egCore.net.request(
+                    'open-ils.serial',
+                    'open-ils.serial.make_predictions',
+                    egCore.auth.token(),
+                    { ssub_id : mySsubId,
+                      include_base_issuance : lastItem ? 0 : 1,
+                      num_to_predict : hc.count,
+                      base_issuance : base_iss || lastItem
+                    }
+                ).then(
+                    function(resp) {
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        }
+                    },
+                    function(resp) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    }
+                );
+            });
+        });
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/mfhd.js b/Open-ILS/web/js/ui/default/staff/services/mfhd.js
new file mode 100644 (file)
index 0000000..488b7cf
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+  * MFHD tools and directives.
+  */
+angular.module('egMfhdMod', ['egCoreMod', 'ui.bootstrap'])
+
+.factory('egMfhdCreateDialog',
+       ['$uibModal','egCore',
+function($uibModal , egCore) {
+    var service = {};
+
+    service.open = function(bibId, orgId) {
+        return $uibModal.open({
+            templateUrl: './share/t_mfhd_create_dialog',
+            controller: ['$scope', '$uibModalInstance',
+                function($scope, $uibModalInstance) {
+                    $scope.mfhd_lib = orgId ?
+                        egCore.org.get(orgId) :
+                        null;
+                    $scope.ok = function() {
+                        egCore.net.request(
+                            'open-ils.cat',
+                            'open-ils.cat.serial.record.xml.create',
+                            egCore.auth.token(),
+                            1, // source
+                            $scope.mfhd_lib.id(),
+                            bibId
+                        ).then(function() {
+                            $uibModalInstance.close()
+                        });
+                    }
+                    $scope.cancel = function() {
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}
+])
index aa95d16..51aac05 100644 (file)
@@ -592,17 +592,19 @@ function($window , egStrings) {
         scope: {
             list: "=", // list of strings
             selected: "=",
+            onSelect: "=",
             egDisabled: "=",
             allowAll: "@",
+            placeholder: "@",
             focusMe: "=?"
         },
         template:
             '<div class="input-group">'+
-                '<input type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
+                '<input placeholder="{{placeholder}}" type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
                 '<div class="input-group-btn" dropdown ng-class="{open:isopen}">'+
-                    '<button type="button" ng-click="showAll()" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
+                    '<button type="button" ng-click="showAll()" ng-disabled="egDisabled" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
                     '<ul class="dropdown-menu dropdown-menu-right">'+
-                        '<li ng-repeat="item in list|filter:selected"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
+                        '<li ng-repeat="item in list|filter:selected:compare"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
                         '<li ng-if="complete_list" class="divider"><span></span></li>'+
                         '<li ng-if="complete_list" ng-repeat="item in list"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
                     '</ul>'+
@@ -616,6 +618,12 @@ function($window , egStrings) {
                 $scope.clickedopen = false;
                 $scope.clickedclosed = null;
 
+                $scope.compare = function (ex, act) {
+                    if (act === null || act === undefined) return true;
+                    if (act.toString) act = act.toString();
+                    return new RegExp(act.toLowerCase()).test(ex)
+                }
+
                 $scope.showAll = function () {
 
                     $scope.clickedopen = !$scope.clickedopen;
@@ -628,8 +636,8 @@ function($window , egStrings) {
                         $scope.clickedclosed = !$scope.clickedopen;
                     }
 
-                    if ($scope.selected.length > 0) $scope.complete_list = true;
-                    if ($scope.selected.length == 0) $scope.complete_list = false;
+                    if ($scope.selected && $scope.selected.length > 0) $scope.complete_list = true;
+                    if (!$scope.selected || $scope.selected.length == 0) $scope.complete_list = false;
                     $scope.makeOpen();
                 }
 
@@ -638,7 +646,10 @@ function($window , egStrings) {
                         $scope.list,
                         $scope.selected
                     ).length > 0 && $scope.selected.length > 0);
-                    if ($scope.clickedclosed) $scope.isopen = false;
+                    if ($scope.clickedclosed) {
+                        $scope.isopen = false;
+                        $scope.clickedclosed = null;
+                    }
                 }
 
                 $scope.changeValue = function (newVal) {
@@ -647,6 +658,7 @@ function($window , egStrings) {
                     $scope.clickedclosed = null;
                     $scope.clickedopen = false;
                     if ($scope.selected.length == 0) $scope.complete_list = false;
+                    if ($scope.onSelect) $scope.onSelect();
                 }
 
             }