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);
+            });
+        },
+&nbs