[open-ils-commits] [GIT] Evergreen ILS branch master updated. b437f50243e90b63dd3ba6f9532448df8c601a5f

Evergreen Git git at git.evergreen-ils.org
Fri Sep 1 12:49:11 EDT 2017


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  b437f50243e90b63dd3ba6f9532448df8c601a5f (commit)
       via  1f81ff654fdc42554246a9dc8e1d715b18b5a38a (commit)
       via  55b5bb124b5c4eefb64ef5703952aec70191f139 (commit)
       via  cf77f78a83e0a3690c0dca1d206577f486441f74 (commit)
       via  9d4ce86fff2b2c1d3ace88d5379e68de06b71d1a (commit)
       via  e9e5e9a7f4d9f85da84f01a8bc3867ed83856cbb (commit)
       via  f1fe22bc80f200157fe4a2f89d957a95e570282b (commit)
       via  bad8ab7907b9f32e0c91b027da8258df49880b9f (commit)
       via  e0a0375f09ee27835faa7967364447b8695d7d77 (commit)
       via  b854319e3b3fa55204ba050ebe45c694053cde25 (commit)
       via  04f882160973f3a696201d27ca55885abc894e02 (commit)
       via  5311a1755f5d33262de1b0ae0571561050799e71 (commit)
       via  9e22667fc434193028c826e95d1ec09baa90a1cd (commit)
       via  407554e93dbe2facfaddfb641e04d927961cc9f0 (commit)
       via  2690328d76a972208b77ba3ad50c67fce9b436b0 (commit)
       via  973f032feb02c24cf201fdb26ec0ac78b18aaa42 (commit)
       via  1660b0db5004ae6032c78dcc347305e08ac23347 (commit)
      from  fc886e1d96a6bf5f78da305d94a0b4271ca2dbe4 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit b437f50243e90b63dd3ba6f9532448df8c601a5f
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue Aug 29 16:11:13 2017 -0400

    LP#1708291: tweak to subscription selector
    
    When entering the Manage Predictions or Manage Issues tab
    and only one subscription is present in the current OU
    scope, automatically select it rather than making the operator
    have to select it manually.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

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
index 7556046..2918e78 100644
--- 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
@@ -24,6 +24,9 @@ function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
     function reload() {
         egSerialsCoreSvc.fetch($scope.bibId, $scope.selected_owning_ou).then(function() {
             $scope.subscriptions = egCore.idl.toTypedHash(egSerialsCoreSvc.subTree);
+            if ($scope.subscriptions.length == 1 && !$scope.ssubId) {
+                $scope.ssubId = $scope.subscriptions[0].id;
+            }
         });
     }
 }]

commit 1f81ff654fdc42554246a9dc8e1d715b18b5a38a
Author: Jason Etheridge <jason at EquinoxInitiative.org>
Date:   Fri Aug 4 13:51:36 2017 -0400

    LP#1708291: remove initials field for serial notes
    
    This was a carry-over from the copy notes modal that doesn't
    apply to serial notes.
    
    Signed-off-by: Jason Etheridge <jason at EquinoxInitiative.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/serials/t_notes.tt2 b/Open-ILS/src/templates/staff/serials/t_notes.tt2
index 06ed074..d0fe48e 100644
--- a/Open-ILS/src/templates/staff/serials/t_notes.tt2
+++ b/Open-ILS/src/templates/staff/serials/t_notes.tt2
@@ -34,8 +34,6 @@
     <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') %]"/>
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
index d7edbb8..db0e009 100644
--- 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
@@ -885,13 +885,6 @@ function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
         'alert' : false,
     };
 
-    $scope.require_initials = false;
-    egCore.org.settings([
-        'ui.staff.require_initials.copy_notes'
-    ]).then(function(set) {
-        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
-    });
-
     $scope.note_list = notes;
 
     $scope.ok = function(note) {
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js b/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
index f1b1b9c..5d9d376 100644
--- a/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
@@ -490,13 +490,6 @@ function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
         'alert' : false,
     };
 
-    $scope.require_initials = false;
-    egCore.org.settings([
-        'ui.staff.require_initials.copy_notes'
-    ]).then(function(set) {
-        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
-    });
-
     $scope.note_list = notes;
 
     $scope.ok = function(note) {

commit 55b5bb124b5c4eefb64ef5703952aec70191f139
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Aug 4 14:02:59 2017 -0400

    LP#1708291: add release notes
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/docs/RELEASE_NOTES_NEXT/Serials/Webstaff_Serials.adoc b/docs/RELEASE_NOTES_NEXT/Serials/Webstaff_Serials.adoc
new file mode 100644
index 0000000..3c1cffc
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Serials/Webstaff_Serials.adoc
@@ -0,0 +1,26 @@
+Web Staff Client Serials Module
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The serials module has been ported over to the web staff
+client, implementing a unified serials interface that combines
+ideas from both the serial control view and alternate serials
+control view from the old staff client.
+
+In addition to carrying over functionality that was available
+in the old staff client, several new features are included:
+
+* 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.

commit cf77f78a83e0a3690c0dca1d206577f486441f74
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Apr 13 12:03:52 2017 -0400

    LP#1708291: web staff client serials module
    
    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 at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at 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 at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml
index 7764758..d075d35 100644
--- a/Open-ILS/examples/fm_IDL.xml
+++ b/Open-ILS/examples/fm_IDL.xml
@@ -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" />
diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml
index 570e19b..a4573b4 100644
--- a/Open-ILS/src/extras/ils_events.xml
+++ b/Open-ILS/src/extras/ils_events.xml
@@ -1054,6 +1054,9 @@
     <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>
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
index 071922c..04f79c0 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
@@ -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;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm
index 03975cf..bbe3661 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm
@@ -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);
diff --git a/Open-ILS/src/sql/Pg/210.schema.serials.sql b/Open-ILS/src/sql/Pg/210.schema.serials.sql
index 2e5af44..8c65f6f 100644
--- a/Open-ILS/src/sql/Pg/210.schema.serials.sql
+++ b/Open-ILS/src/sql/Pg/210.schema.serials.sql
@@ -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;
 
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index f3d5aa5..391ad5a 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -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
index 0000000..455877e
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/live_t/spt-visibility.pg
@@ -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
index 0000000..d396682
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial_pattern_templates.sql
@@ -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
index 0000000..2ceef91
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.spt_perms.sql
@@ -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
index 0000000..d27f8bc
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql
@@ -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;
+
diff --git a/Open-ILS/src/templates/staff/admin/local/t_splash.tt2 b/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
index cdcccb7..82599b3 100644
--- a/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
+++ b/Open-ILS/src/templates/staff/admin/local/t_splash.tt2
@@ -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
index 0000000..2a54931
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/index.tt2
@@ -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
index 0000000..8ff7928
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/pattern_template.tt2
@@ -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
index 0000000..a4bfecf
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/t_attr_edit.tt2
@@ -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
index 0000000..308a31d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/t_splash.tt2
@@ -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
index 0000000..14f37ce
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/t_template_list.tt2
@@ -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
index 0000000..547b39d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/admin/serials/t_templates.tt2
@@ -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>
diff --git a/Open-ILS/src/templates/staff/cat/catalog/index.tt2 b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
index b98c3f1..3d19ca2 100644
--- a/Open-ILS/src/templates/staff/cat/catalog/index.tt2
+++ b/Open-ILS/src/templates/staff/cat/catalog/index.tt2
@@ -11,6 +11,10 @@
 <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>
@@ -49,6 +53,13 @@
       "[% 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>
 
diff --git a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2 b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
index bcb52df..c1e326e 100644
--- a/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
+++ b/Open-ILS/src/templates/staff/cat/catalog/t_catalog.tt2
@@ -27,6 +27,22 @@
         [% 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>
diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2
index 16cd665..748ef4b 100644
--- a/Open-ILS/src/templates/staff/navbar.tt2
+++ b/Open-ILS/src/templates/staff/navbar.tt2
@@ -484,6 +484,12 @@
             </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
index 0000000..e00e4e7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/index.tt2
@@ -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
index 0000000..80f32ae
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/share/serials_strings.tt2
@@ -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
index 0000000..dbdb21d
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_apply_binding_template.tt2
@@ -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">×</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
index 0000000..cec2820
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_batch_receive.tt2
@@ -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">×</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
index 0000000..af5a43c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_chron_selector.tt2
@@ -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
index 0000000..038a57f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_clone_subscription.tt2
@@ -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">×</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
index 0000000..1941861
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_day_of_week_selector.tt2
@@ -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
index 0000000..8346f0c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_holding_code_dialog.tt2
@@ -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">×</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
index 0000000..8c7227a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_item_manager.tt2
@@ -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
index 0000000..03820d2
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_link_mfhd.tt2
@@ -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">×</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
index 0000000..c919d29
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_manage.tt2
@@ -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
index 0000000..6568fee
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_mfhd_manager.tt2
@@ -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
index 0000000..aa79e28
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_mfhd_tooltip.tt2
@@ -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
index 0000000..5a1a38f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_month_day_selector.tt2
@@ -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
index 0000000..a9329f0
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_month_selector.tt2
@@ -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
index 0000000..06ed074
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_notes.tt2
@@ -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">×</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 > 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
index 0000000..c19ef8c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_pattern_editor_dialog.tt2
@@ -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">×</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
index 0000000..ce98556
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_pattern_summary.tt2
@@ -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
index 0000000..f28b1b6
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_prediction_manager.tt2
@@ -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
index 0000000..cd97232
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_prediction_wizard.tt2
@@ -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
index 0000000..e5da6f1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_print_routing_list.tt2
@@ -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
index 0000000..28c9b90
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_receive_alerts.tt2
@@ -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">×</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
index 0000000..1520c5c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_routing_list.tt2
@@ -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">×</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)">↑</a>
+                    <a href ng-click="move_route_down(route)">↓</a>
+                    <a href ng-click="toggle_delete(route)">×</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
index 0000000..f939503
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_season_selector.tt2
@@ -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
index 0000000..1f900d7
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_select_pattern_dialog.tt2
@@ -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">×</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
index 0000000..7995ed1
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_sub_selector.tt2
@@ -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
index 0000000..c104e9f
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_subscription_manager.tt2
@@ -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
index 0000000..189e8ce
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_view_items_grid.tt2
@@ -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
index 0000000..56b1f55
--- /dev/null
+++ b/Open-ILS/src/templates/staff/serials/t_week_in_month_selector.tt2
@@ -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
index 0000000..633445c
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2
@@ -0,0 +1,14 @@
+<div>
+  <div class="modal-header">
+    <button type="button" class="close"
+      ng-click="cancel()" aria-hidden="true">×</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
index 0000000..c04d958
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_mfhd_create_dialog.tt2
@@ -0,0 +1,25 @@
+<!--
+  MFHD creation dialog
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">×</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
index 0000000..7981165
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_org_select_dialog.tt2
@@ -0,0 +1,22 @@
+<!--
+  Org selection interstitial
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">×</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
index 0000000..eeea5d8
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_subscription_select_dialog.tt2
@@ -0,0 +1,22 @@
+<!--
+  Org selection interstitial
+-->
+<div>
+  <div class="modal-header">
+    <button type="button" class="close" 
+      ng-click="cancel()" aria-hidden="true">×</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>
diff --git a/Open-ILS/web/js/ui/default/serial/print_routing_list_users.js b/Open-ILS/web/js/ui/default/serial/print_routing_list_users.js
index 917ff75..dabd885 100644
--- a/Open-ILS/web/js/ui/default/serial/print_routing_list_users.js
+++ b/Open-ILS/web/js/ui/default/serial/print_routing_list_users.js
@@ -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
index 0000000..81f68e4
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/serials/app.js
@@ -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
index 0000000..1585bf4
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/admin/serials/pattern_template.js
@@ -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'];
+        }
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
index 8b541f5..faa0954 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
@@ -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
index 0000000..31f925e
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/app.js
@@ -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
index 0000000..bedc656
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/item_manager.js
@@ -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
index 0000000..c754cf8
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/mfhd_manager.js
@@ -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
index 0000000..d0cd44f
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_manager.js
@@ -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
index 0000000..d9beaff
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/prediction_wizard.js
@@ -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
index 0000000..7556046
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/sub_selector.js
@@ -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
index 0000000..d7edbb8
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/subscription_manager.js
@@ -0,0 +1,943 @@
+angular.module('egSerialsAppDep')
+
+.directive('egSubscriptionManager', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId : '='
+        },
+        templateUrl: './serials/t_subscription_manager',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider',
+        '$uibModal','ngToast','egConfirmDialog',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider ,
+         $uibModal , ngToast , egConfirmDialog ) {
+
+    $scope.selected_owning_ou = null;
+    $scope.owning_ou_changed = function(org) {
+        $scope.selected_owning_ou = org.id();
+        reload();
+    }
+
+    function reload() {
+        egSerialsCoreSvc.fetch($scope.bibId, $scope.selected_owning_ou).then(function() {
+            $scope.subscriptions = egCore.idl.toTypedHash(egSerialsCoreSvc.subTree);
+            // un-flesh receive unit template so that we can use
+            // it as a model of a select
+            angular.forEach($scope.subscriptions, function(ssub) {
+                angular.forEach(ssub.distributions, function(sdist) {
+                    if (angular.isObject(sdist.receive_unit_template)) {
+                        sdist.receive_unit_template = sdist.receive_unit_template.id;
+                    }
+                });
+            });
+            $scope.distStreamGridDataProvider.refresh();
+        });
+    }
+    reload();
+
+    $scope.localStreamNames = [];
+    egCore.hatch.getItem('eg.serials.stream_names')
+    .then(function(list) {
+        if (list) $scope.localStreamNames = list;
+    });
+
+    $scope.distStreamGridControls = {
+        activateItem : function (item) { } // TODO
+    };
+    $scope.distStreamGridDataProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            return this.arrayNotifier(egSerialsCoreSvc.subList, offset, count);
+        }
+    });
+
+    $scope.need_one_selected = function() {
+        var items = $scope.distStreamGridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.receiving_templates = {};
+    angular.forEach(egCore.org.list(), function(org) {
+        egSerialsCoreSvc.fetch_templates(org.id()).then(function(list){
+            $scope.receiving_templates[org.id()] = egCore.idl.toTypedHash(list);
+        });
+    });
+
+    $scope.add_subscription = function() {
+        var new_ssub = egCore.idl.toTypedHash(new egCore.idl.ssub());
+        new_ssub._isnew = true;
+        new_ssub.record_entry = $scope.bibId;
+        new_ssub._focus_me = true;
+        $scope.subscriptions.push(new_ssub);
+        $scope.add_distribution(new_ssub); // since we know we want at least one distribution
+    }
+    $scope.add_distribution = function(ssub, grab_focus) {
+        egCore.org.settings([
+            'serial.default_display_grouping'
+        ]).then(function(set) {
+            var new_sdist = egCore.idl.toTypedHash(new egCore.idl.sdist());
+            new_sdist._isnew = true;
+            new_sdist.subscription = ssub.id;
+            new_sdist.display_grouping = set['serial.default_display_grouping'] || 'chron';
+            if (!angular.isArray(ssub.distributions)){
+                ssub.distributions = [];
+            }
+            if (grab_focus) {
+                new_sdist._focus_me = true;
+                ssub._focus_me = false;
+            }
+            ssub.distributions.push(new_sdist);
+            $scope.add_stream(new_sdist); // since we know we want at least one stream
+        });
+    }
+    $scope.remove_pending_distribution = function(ssub, sdist) {
+        var to_remove = -1;
+        for (var i = 0; i < ssub.distributions.length; i++) {
+            if (ssub.distributions[i] === sdist) {
+                to_remove = i;
+                break;
+            }
+        }
+        if (to_remove > -1) {
+            ssub.distributions.splice(to_remove, 1);
+        }
+    }
+    $scope.add_stream = function(sdist, grab_focus) {
+        var new_sstr = egCore.idl.toTypedHash(new egCore.idl.sstr());
+        new_sstr.distribution = sdist.id;
+        new_sstr._isnew = true;
+        if (grab_focus) {
+            new_sstr._focus_me = true;
+            sdist._has_focus = false; // and take focus away from a newly created sdist
+        }
+        if (!angular.isArray(sdist.streams)){
+            sdist.streams = [];
+        }
+        sdist.streams.push(new_sstr);
+        $scope.dirtyForm();
+    }
+    $scope.remove_pending_stream = function(sdist, sstr) {
+        var to_remove = -1;
+        for (var i = 0; i < sdist.streams.length; i++) {
+            if (sdist.streams[i] === sstr) {
+                to_remove = i;
+                break;
+            }
+        }
+        if (to_remove > -1) {
+            sdist.streams.splice(to_remove, 1);
+        }
+    }
+
+    $scope.abort_changes = function(form) {
+        reload();
+        form.$setPristine();
+    }
+    function updateLocalStreamNames (new_name) {
+        if (new_name && $scope.localStreamNames.filter(function(x){ return x == new_name}).length == 0) {
+            $scope.localStreamNames.push(new_name);
+            egCore.hatch.setItem('eg.serials.stream_names', $scope.localStreamNames)
+        }
+    }
+
+    $scope.dirtyForm = function () {
+        $scope.ssubform.$dirty = true;
+    }
+
+    $scope.save_subscriptions = function(form) {
+        // traverse through structure and set _ischanged
+        // TODO add more granular dirty input detection
+        angular.forEach($scope.subscriptions, function(ssub) {
+            if (!ssub._isnew) ssub._ischanged = true;
+            angular.forEach(ssub.distributions, function(sdist) {
+                if (!sdist._isnew) sdist._ischanged = true;
+                angular.forEach(sdist.streams, function(sstr) {
+                    if (!sstr._isnew) sstr._ischanged = true;
+                    updateLocalStreamNames(sstr.routing_label);
+                });
+            });
+        });
+
+        var obj = egCore.idl.fromTypedHash($scope.subscriptions);
+
+        // create a bunch of promises that each get resolved upon each
+        // CUD update; that way, we can know when the entire save
+        // operation is completed
+        var promises = [];
+        angular.forEach(obj, function(ssub) {
+            ssub._cud_done = $q.defer();
+            promises.push(ssub._cud_done.promise);
+            angular.forEach(ssub.distributions(), function(sdist) {
+                sdist._cud_done = $q.defer();
+                promises.push(sdist._cud_done.promise);
+                angular.forEach(sdist.streams(), function(sstr) {
+                    sstr._cud_done = $q.defer();
+                    promises.push(sstr._cud_done.promise);
+                });
+            });
+        });
+
+        angular.forEach(obj, function(ssub) {
+            ssub.owning_lib(ssub.owning_lib().id()); // deflesh
+            egCore.pcrud.apply(ssub).then(function(res) {
+                var ssub_id = (ssub.isnew() && angular.isObject(res)) ? res.id() : ssub.id();
+                angular.forEach(ssub.distributions(), function(sdist) {
+                    // set subscription ID just in case it's new
+                    sdist.holding_lib(sdist.holding_lib().id()); // deflesh
+                    sdist.subscription(ssub_id);
+                    egCore.pcrud.apply(sdist).then(function(res) {
+                        var sdist_id = (sdist.isnew() && angular.isObject(res)) ? res.id() : sdist.id();
+                        angular.forEach(sdist.streams(), function(sstr) {
+                            // set distribution ID just in case it's new
+                            sstr.distribution(sdist_id);
+                            egCore.pcrud.apply(sstr).then(function(res) {
+                                sstr._cud_done.resolve();
+                            });
+                        });
+                    });
+                    sdist._cud_done.resolve();
+                });
+                ssub._cud_done.resolve();
+            });
+        });
+        $q.all(promises).then(function(resolutions) {
+            reload();
+            form.$setPristine();
+        });
+    }
+    $scope.delete_subscription = function(rows) {
+        if (rows.length == 0) { return; }
+        var s_rows = rows.filter(function(el) {
+            return typeof el['id'] != 'undefined';
+        });
+        if (s_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_SUBSCRIPTION,
+            egCore.strings.CONFIRM_DELETE_SUBSCRIPTION_MESSAGE,
+            {count : s_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(s_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.subscription.safe_delete',
+                        egCore.auth.token(),
+                        el['id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_SUBSCRIPTION_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.delete_distribution = function(rows) {
+        if (rows.length == 0) { return; }
+        var d_rows = rows.filter(function(el) {
+            return typeof el['sdist.id'] != 'undefined';
+        });
+        if (d_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_DISTRIBUTION,
+            egCore.strings.CONFIRM_DELETE_DISTRIBUTION_MESSAGE,
+            {count : d_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(d_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.distribution.safe_delete',
+                        egCore.auth.token(),
+                        el['sdist.id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.delete_stream = function(rows) {
+        if (rows.length == 0) { return; }
+        var s_rows = rows.filter(function(el) {
+            return typeof el['sstr.id'] != 'undefined';
+        });
+        if (s_rows.length == 0) { return; }
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_DELETE_STREAM,
+            egCore.strings.CONFIRM_DELETE_STREAM_MESSAGE,
+            {count : s_rows.length}
+        ).result.then(function () {
+            var promises = [];
+            angular.forEach(s_rows, function(el) {
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.stream.safe_delete',
+                        egCore.auth.token(),
+                        el['sstr.id']
+                    ).then(function(resp){
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_STREAM_FAIL_DELETE + ' : ' + evt.desc);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_STREAM_SUCCESS_DELETE);
+                        }
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.additional_routing = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        if (!row) { row = $scope.distStreamGridControls.selectedItems()[0]; }
+        if (row && row['sstr.id']) {
+            egCore.pcrud.search('srlu', {
+                    stream : row['sstr.id']
+                }, {
+                    flesh : 2,
+                    flesh_fields : {
+                        'srlu' : ['reader'],
+                        'au'  : ['mailing_address','billing_address','home_ou']
+                    },
+                    order_by : { srlu : 'pos' }
+                },
+                { atomic : true }
+            ).then(function(list) {
+                $uibModal.open({
+                    templateUrl: './serials/t_routing_list',
+                    controller: 'RoutingCtrl',
+                    resolve : {
+                        rowInfo : function() {
+                            return row;
+                        },
+                        routes : function() {
+                            return egCore.idl.toHash(list);
+                        }
+                    }
+                }).result.then(function(routes) {
+                    // delete all of the routes first;
+                    // it's easiest given the constraints
+                    var deletions = [];
+                    var creations = [];
+                    angular.forEach(routes, function(r) {
+                        var srlu = new egCore.idl.srlu();
+                        srlu.stream(r.stream);
+                        srlu.pos(r.pos);
+                        if (r.reader) {
+                            srlu.reader(r.reader.id);
+                        }
+                        srlu.department(r.department);
+                        srlu.note(r.note);
+                        if (r.id) {
+                            srlu.id(r.id);
+                            var srlu_copy = angular.copy(srlu);
+                            srlu_copy.isdeleted(true);
+                            deletions.push(srlu_copy);
+                        }
+                        if (!r.delete_me) {
+                            srlu.isnew(true);
+                            creations.push(srlu);
+                        }
+                    });
+                    egCore.pcrud.apply(deletions.concat(creations)).then(function(){
+                        reload();
+                    });
+                });
+            });
+        }
+    }
+    $scope.clone_subscription = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        $uibModal.open({
+            templateUrl: './serials/t_clone_subscription',
+            controller: 'CloneCtrl',
+            resolve : {
+                subs : function() {
+                    return rows;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            var promises = [];
+            var some_failure = false;
+            var some_success = false;
+            var seen = {};
+            angular.forEach(rows, function(row) { 
+                //console.log(row);
+                if (!seen[row.id]) {
+                    seen[row.id] = 1;
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.serial',
+                            'open-ils.serial.subscription.clone',
+                            egCore.auth.token(),
+                            row.id,
+                            args.bib_id
+                        ).then(
+                            function(resp) {
+                                var evt = egCore.evt.parse(resp);
+                                if (evt) { // any way to just throw or return this to the error handler?
+                                    console.log('failure',resp);
+                                    some_failure = true;
+                                    ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_CLONE);
+                                } else {
+                                    console.log('success',resp);
+                                    some_success = true;
+                                    ngToast.success(egCore.strings.SERIALS_SUBSCRIPTION_SUCCESS_CLONE);
+                                }
+                            },
+                            function(resp) {
+                                console.log('failure',resp);
+                                some_failure = true;
+                                ngToast.danger(egCore.strings.SERIALS_SUBSCRIPTION_FAIL_CLONE);
+                            }
+                        )
+                    );
+                }
+            });
+            $q.all(promises).then(function() {
+                reload();
+            });
+        });
+    }
+    $scope.link_mfhd = function(rows) {
+        if (!rows) { return; }
+        var row = rows[0];
+        if (!row['sdist.id']) { return; }
+        $uibModal.open({
+            templateUrl: './serials/t_link_mfhd',
+            controller: 'LinkMFHDCtrl',
+            resolve : {
+                row : function() {
+                    return rows[0];
+                },
+                bibId : function() {
+                    return $scope.bibId;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            console.log('modal done', args);
+            egCore.pcrud.search('sdist', {
+                    id: rows[0]['sdist.id']
+                }, {}, { atomic : true }
+            ).then(function(resp){
+                var evt = egCore.evt.parse(resp);
+                if (evt) { // any way to just throw or return this to the error handler?
+                    console.log('failure',resp);
+                    ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                }
+                var sdist = resp[0];
+                sdist.ischanged(true);
+                sdist.summary_method( args.summary_method );
+                sdist.record_entry( args.which_mfhd );
+                egCore.pcrud.apply(sdist).then(
+                    function(resp) { // maybe success
+                        console.log('apply',resp);
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) { // any way to just throw or return this to the error handler?
+                            console.log('failure',resp);
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                        } else {
+                            console.log('success',resp);
+                            ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_LINK_MFHD);
+                            reload();
+                        }
+                    },
+                    function(resp) {
+                        console.log('failure',resp);
+                        ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_LINK_MFHD);
+                    }
+                );
+            });
+        });
+    }
+    $scope.apply_binding_template = function(rows) {
+        if (rows.length == 0) { return; }
+        var d_rows = rows.filter(function(el) {
+            return typeof el['sdist.id'] != 'undefined';
+        });
+        if (d_rows.length == 0) { return; }
+        var libs = []; var seen_lib = {};
+        angular.forEach(d_rows, function(el) {
+            if (el['sdist.holding_lib.id'] && !seen_lib[el['sdist.holding_lib.id']]) {
+                seen_lib[el['sdist.holding_lib.id']] = 1;
+                libs.push({
+                      id: el['sdist.holding_lib.id'],
+                    name: el['sdist.holding_lib.name'],
+                });
+            }
+        });
+        $uibModal.open({
+            templateUrl: './serials/t_apply_binding_template',
+            controller: 'ApplyBindingTemplateCtrl',
+            resolve : {
+                rows : function() {
+                    return d_rows;
+                },
+                libs : function() {
+                    return libs;
+                }
+            },
+            windowClass: 'app-modal-window',
+            backdrop: 'static',
+            keyboard: false
+        }).result.then(function(args) {
+            console.log(args);
+            egCore.pcrud.search('sdist', {
+                    id: d_rows.map(function(el) { return el['sdist.id']; })
+                }, {}, { atomic : true }
+            ).then(function(resp){
+                var evt = egCore.evt.parse(resp);
+                if (evt) { // any way to just throw or return this to the error handler?
+                    console.log('failure',resp);
+                    ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                }
+                var promises = [];
+                angular.forEach(resp,function(sdist) {
+                    var promise = $q.defer();
+                    promises.push(promise.promise);
+                    sdist.ischanged(true);
+                    sdist.bind_unit_template(
+                        typeof args.bind_unit_template[sdist.holding_lib()] == 'undefined'
+                        ? null
+                        : args.bind_unit_template[sdist.holding_lib()]
+                    );
+                    egCore.pcrud.apply(sdist).then(
+                        function(resp2) { // maybe success
+                            console.log('apply',resp2);
+                            var evt = egCore.evt.parse(resp2);
+                            if (evt) { // any way to just throw or return this to the error handler?
+                                console.log('failure',resp2);
+                                ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                            } else {
+                                console.log('success',resp2);
+                                ngToast.success(egCore.strings.SERIALS_DISTRIBUTION_SUCCESS_BINDING_TEMPLATE);
+                            }
+                            promise.resolve();
+                        },
+                        function(resp2) {
+                            console.log('failure',resp2);
+                            ngToast.danger(egCore.strings.SERIALS_DISTRIBUTION_FAIL_BINDING_TEMPLATE);
+                            promise.resolve();
+                        }
+                    );
+                });
+                $q.all(promises).then(function() {
+                    reload();
+                });
+            });
+        });
+    }
+    $scope.subscription_notes = function(rows) {
+        return $scope.notes('subscription',rows);
+    }
+    $scope.distribution_notes = function(rows) {
+        return $scope.notes('distribution',rows);
+    }
+    $scope.notes = function(note_type,rows) {
+        if (!rows) { return; }
+
+        function modal(existing_notes) {
+            $uibModal.open({
+                templateUrl: './serials/t_notes',
+                animation: true,
+                controller: 'NotesCtrl',
+                resolve : {
+                    note_type : function() { return note_type; },
+                    rows : function() {
+                        return rows;
+                    },
+                    notes : function() {
+                        return existing_notes;
+                    }
+                },
+                windowClass: 'app-modal-window',
+                backdrop: 'static',
+                keyboard: false
+            }).result.then(function(notes) {
+                console.log('results',notes);
+                egCore.pcrud.apply(notes).then(
+                    function(a) { console.log('toast here 1',a); },
+                    function(a) { console.log('toast here 2',a); }
+                );
+            });
+        }
+
+        if (rows.length == 1) {
+            var fm_hint;
+            var search_hash = {};
+            var search_opt = {};
+            switch(note_type) {
+                case 'subscription':
+                    fm_hint = 'ssubn';
+                    search_hash.subscription = rows[0]['id'];
+                    search_opt.order_by = { ssubn : 'create_date' };
+                break;
+                case 'distribution':
+                    fm_hint = 'sdistn';
+                    search_hash.distribution = rows[0]['sdist.id'];
+                    search_opt.order_by = { sdistn : 'create_date' };
+                break;
+                case 'item': default:
+                    fm_hint = 'sin';
+                    search_hash.item = rows[0]['si.id'];
+                    search_opt.order_by = { sin : 'create_date' };
+                break;
+            }
+            egCore.pcrud.search(fm_hint, search_hash, search_opt,
+                { atomic : true }
+            ).then(function(list) {
+                modal(list);
+            });
+        } else {
+                // support batch creation of notes across selections,
+                // but not editing
+                modal([]);
+        }
+    }
+
+}]
+    }
+})
+
+.controller('ApplyBindingTemplateCtrl',
+       ['$scope','$q','$uibModalInstance','egCore','egSerialsCoreSvc',
+        'rows','libs',
+function($scope , $q , $uibModalInstance , egCore , egSerialsCoreSvc ,
+         rows , libs ) {
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.libs = libs;
+    $scope.rows = rows;
+    $scope.args = { bind_unit_template : {} };
+    $scope.templates = {};
+    angular.forEach(libs, function(org) {
+        egSerialsCoreSvc.fetch_templates(org.id).then(function(list){
+            $scope.templates[org.id] = egCore.idl.toTypedHash(list);
+        });
+    });
+}])
+
+.controller('LinkMFHDCtrl',
+       ['$scope','$q','$uibModalInstance','egCore','row','bibId',
+function($scope , $q , $uibModalInstance , egCore , row , bibId ) {
+    console.log('row',row);
+    console.log('bibId',bibId);
+    $scope.args = {
+        summary_method: row['sdist.summary_method'] || 'add_to_sre',
+    };
+    if (row['sdist.record_entry']) {
+        $scope.args.which_mfhd = row['sdist.record_entry'].id;
+    }
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.legacies = {};
+    egCore.pcrud.search('sre', {
+            record: bibId, owning_lib : row['sdist.holding_lib.id'], active: 't', deleted: 'f'
+        }, {}, { atomic : true }
+    ).then(
+        function(resp) { // maybe success
+            var evt; if (evt = egCore.evt.parse(resp)) { console.error(evt.toString()); return; }
+            if (!resp) { return; }
+
+            var promises = [];
+            var seen = {};
+
+            angular.forEach(resp, function(sre) {
+                console.log('sre',sre);
+                if (!seen[sre.record()]) {
+                    seen[sre.record()] = 1;
+                    $scope.legacies[sre.record()] = { mvr: null, svrs: [] };
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.search',
+                            'open-ils.search.biblio.record.mods_slim.retrieve.authoritative',
+                            sre.record()
+                        ).then(function(resp2) {
+                            var evt; if (evt = egCore.evt.parse(resp2)) { console.error(evt.toString()); return; }
+                            if (!resp2) { return; }
+                            $scope.legacies[sre.record()].mvr = egCore.idl.toHash(resp2);
+                        })
+                    );
+                    promises.push(
+                        egCore.net.request(
+                            'open-ils.search',
+                            'open-ils.search.serial.record.bib.retrieve',
+                            sre.record(),
+                            row['owning_lib.id']
+                        ).then(function(resp2) {
+                            angular.forEach(resp2,function(r) {
+                                if (r.sre_id() > 0) {
+                                    console.log('svr',egCore.idl.toHash(r));
+                                    $scope.legacies[sre.record()].svrs.push( egCore.idl.toHash(r) );
+                                }
+                            });
+                        })
+                    );
+                }
+                if (typeof $scope.legacies[sre.record()].sres == 'undefined') {
+                    $scope.legacies[sre.record()].sres = {};
+                }
+                $scope.legacies[sre.record()].sres[sre.id()] = egCore.idl.toHash(sre);
+            });
+
+            $q.all(promises).then(function(){
+                console.log('done',$scope.legacies);
+            });
+        },
+        function(resp) { // outright failure
+            console.error('failure',resp);
+        }
+    )
+}])
+
+.controller('CloneCtrl',
+       ['$scope','$uibModalInstance','egCore','subs',
+function($scope , $uibModalInstance , egCore , subs ) {
+    $scope.args = {};
+    $scope.ok = function(count) { $uibModalInstance.close($scope.args) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.subs = subs;
+    $scope.find_bib = function () {
+
+        $scope.bibNotFound = null;
+        $scope.mvr = null;
+        if (!$scope.args.bib_id) return;
+
+        return egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.record.mods_slim.retrieve.authoritative',
+            $scope.args.bib_id
+        ).then(
+            function(resp) { // maybe success 
+
+                if (evt = egCore.evt.parse(resp)) {
+                    $scope.bibNotFound = $scope.args.bib_id;
+                    console.error(evt.toString());
+                    return;
+                }
+
+                if (!resp) {
+                    $scope.bibNotFound = $scope.args.bib_id;
+                    return;
+                }
+
+                $scope.mvr = egCore.idl.toHash(resp);
+                //console.log($scope.mvr);
+            },
+            function(resp) { // outright failure
+                console.error(resp);
+                $scope.bibNotFound = $scope.args.bib_id;
+                return;
+            }
+        );
+    }
+    $scope.$watch("args.bib_id", function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.find_bib();
+        }
+    });
+}])
+
+.controller('RoutingCtrl',
+       ['$scope','$uibModalInstance','egCore','rowInfo','routes',
+function($scope , $uibModalInstance , egCore , rowInfo , routes ) {
+    $scope.args = {
+         which_radio_button: 'reader'
+        ,reader: ''
+        ,department: ''
+        ,delete_me: false
+    };
+    $scope.stream_id = rowInfo['sstr.id'];
+    $scope.stream_label = rowInfo['sstr.routing_label'];
+    $scope.routes = routes;
+    $scope.readerInFocus = true;
+    $scope.ok = function(count) { $uibModalInstance.close($scope.routes) }
+    $scope.cancel = function () { $uibModalInstance.dismiss() }
+    $scope.model_has_changed = false;
+    $scope.find_user = function () {
+
+        $scope.readerNotFound = null;
+        $scope.reader_obj = null;
+        if (!$scope.args.reader) return;
+
+        egCore.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_barcodes',
+            egCore.auth.token(), egCore.auth.user().ws_ou(),
+            'actor', $scope.args.reader)
+
+        .then(function(resp) { // get_barcodes
+
+            if (evt = egCore.evt.parse(resp)) {
+                console.error(evt.toString());
+                return;
+            }
+
+            if (!resp || !resp[0]) {
+                $scope.readerNotFound = $scope.args.reader;
+                return;
+            }
+
+            egCore.pcrud.search('au', {
+                    id : resp[0].id
+                }, {
+                    flesh : 1,
+                    flesh_fields : {
+                        'au'  : ['mailing_address','billing_address','home_ou']
+                    }
+                },
+                { atomic : true }
+            ).then(function(usr) {
+                $scope.reader_obj = egCore.idl.toHash(usr[0]);
+            });
+        });
+    }
+    $scope.add_route = function () {
+        var new_route = {
+             stream: $scope.stream_id
+            ,pos: $scope.routes.length
+            ,note: $scope.args.note
+        }
+        if ($scope.args.which_radio_button == 'reader') {
+            new_route.reader = $scope.reader_obj;
+        } else {
+            new_route.department = $scope.args.department;
+        }
+        $scope.routes.push(new_route);
+        $scope.model_has_changed = true;
+    }
+    function adjust_pos_field() {
+        var idx = 0;
+        for (var i = 0; i < $scope.routes.length; i++) {
+            $scope.routes[i].pos = $scope.routes[i].delete_me ? idx : idx++;
+        }
+        $scope.model_has_changed = true;
+    }
+    $scope.move_route_up = function(r) {
+        var pos = r.pos;
+        if (pos > 0) {
+            var temp = $scope.routes[ pos - 1 ];
+            $scope.routes[ pos - 1 ] = $scope.routes[ pos ];
+            $scope.routes[ pos ] = temp;
+            adjust_pos_field();
+        }
+    }
+    $scope.move_route_down = function(r) {
+        var pos = r.pos;
+        if (pos < $scope.routes.length - 1) {
+            var temp = $scope.routes[ pos + 1 ];
+            $scope.routes[ pos + 1 ] = $scope.routes[ pos ];
+            $scope.routes[ pos ] = temp;
+            adjust_pos_field();
+        }
+    }
+    $scope.toggle_delete = function(r) {
+        r.delete_me = ! r.delete_me;
+        adjust_pos_field();
+    }
+    $scope.$watch("args.reader", function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) {
+            $scope.find_user();
+        }
+    });
+}])
+
+.controller('NotesCtrl',
+       ['$scope','$uibModalInstance','egCore','note_type','rows','notes',
+function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
+    $scope.note_type = note_type;
+    $scope.focusNote = true;
+    $scope.note = {
+        creator : egCore.auth.user().id(),
+        title   : '',
+        value   : '',
+        pub     : false,
+        'alert' : false,
+    };
+
+    $scope.require_initials = false;
+    egCore.org.settings([
+        'ui.staff.require_initials.copy_notes'
+    ]).then(function(set) {
+        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
+    });
+
+    $scope.note_list = notes;
+
+    $scope.ok = function(note) {
+
+        var return_notes = [];
+        if (note.initials) note.value += ' [' + note.initials + ']';
+        if (   (typeof note.title != 'undefined' && note.title != '')
+            || (typeof note.value != 'undefined' && note.value != '')) {
+            angular.forEach(rows, function (r) {
+                console.log('r',r);
+                window.my_r = r;
+                var n;
+                switch(note_type) {
+                    case 'subscription':
+                        n = new egCore.idl.ssubn();
+                        n.subscription(r['id']);
+                        break;
+                    case 'distribution':
+                        n = new egCore.idl.sdistn();
+                        n.distribution(r['sdist.id']);
+                        break;
+                    case 'item':
+                    default:
+                        n = new egCore.idl.sin();
+                        n.item(r['si.id']);
+                }
+                n.isnew(true);
+                n.creator(note.creator);
+                n.pub(note.pub);
+                n['alert'](note['alert']);
+                n.title(note.title);
+                n.value(note.value);
+                return_notes.push( n );
+            });
+        }
+        angular.forEach(notes, function(n) {
+            if (n.ischanged() || n.isdeleted()) {
+                return_notes.push( n );
+            }
+        });
+        window.return_notes = return_notes;
+        $uibModalInstance.close(return_notes);
+    }
+
+    $scope.cancel = function($event) {
+        $uibModalInstance.dismiss();
+        $event.preventDefault();
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js b/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
new file mode 100644
index 0000000..f1b1b9c
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
@@ -0,0 +1,545 @@
+angular.module('egSerialsAppDep')
+
+.directive('egItemGrid', function() {
+    return {
+        transclude: true,
+        restrict:   'E',
+        scope: {
+            bibId  : '=',
+            ssubId : '='
+        },
+        templateUrl: './serials/t_view_items_grid',
+        controller:
+       ['$scope','$q','egSerialsCoreSvc','egCore','egGridDataProvider','orderByFilter',
+        '$uibModal','ngToast','egConfirmDialog','egPromptDialog','$timeout',
+function($scope , $q , egSerialsCoreSvc , egCore , egGridDataProvider , orderByFilter ,
+         $uibModal , ngToast , egConfirmDialog , egPromptDialog , $timeout) {
+
+    $scope.svc = egSerialsCoreSvc;
+
+    var _paging_filter;
+    function reload(ssubId,filter) {
+        _paging_filter = filter;
+        return egSerialsCoreSvc.fetchItemsForSub(ssubId,filter).then(function() {
+            $scope.itemGridProvider.refresh();
+        });
+    }
+
+    $scope.filter_items_all = function () { return reload($scope.ssubId) }
+    $scope.filter_items_have = function () { return reload($scope.ssubId,{status:['Received','Bindery','Bound']}) }
+    $scope.filter_items_dont_have = function () { return reload($scope.ssubId,{'-not':{status:['Received','Bindery','Bound']}}) }
+    $scope.filter_items_by_status = function (item,status) { return reload($scope.ssubId,{status:status.name}) }
+
+    $scope.$watch('ssubId', function(newVal, oldVal) {
+        if (newVal && newVal != oldVal) return reload(newVal);
+    });
+
+    $scope.itemGridControls = {
+        activateItem : function (item) { } // TODO
+    };
+
+    function compileSort(sort) {
+        if (sort && angular.isArray(sort) && sort.length == 1) {
+            if (angular.isObject(sort[0])) {
+                for (key in sort[0]) {
+                    return {
+                        'class'   : 'sitem',
+                        field     :  key,
+                        direction : sort[0][key]
+                    };
+                }
+            } else {
+                return { 'class': 'sitem', field: sort[0] };
+            }
+        }
+    }
+    var current_sort = [];
+    $scope.itemGridProvider = egGridDataProvider.instance({
+        get : function(offset, count) {
+            var self = this;
+            if (angular.equals(current_sort, self.sort) && egSerialsCoreSvc.itemList.length >= offset + count) { // if there's anything on the requested page, notify
+                return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+            } else { // else try to fetch another page
+                if (angular.equals(current_sort, self.sort)) {
+                    return egSerialsCoreSvc.fetchItemsForSubPaged(
+                        $scope.ssubId,
+                        _paging_filter,
+                        egSerialsCoreSvc.itemList.length,
+                        count + offset - egSerialsCoreSvc.itemList.length,
+                        compileSort(self.sort)
+                    ).then(function() {
+                        return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+                    });
+                } else {
+                    current_sort = self.sort;
+                    return egSerialsCoreSvc.fetchItemsForSub(
+                        $scope.ssubId,
+                        _paging_filter,
+                        null,
+                        compileSort(self.sort)
+                    ).then(function() {
+                        return self.arrayNotifier(egSerialsCoreSvc.itemList, offset, count);
+                    });
+                }
+            }
+        }
+    });
+
+    $scope.delete_items = function (items) {
+        var list = [];
+
+        angular.forEach(items, function (i) {
+            var obj = egCore.idl.fromHash('sitem',i);
+            obj.isdeleted(1);
+            obj.stream(obj.stream().id); // API wants scalar or FM object
+            obj.issuance(obj.issuance().id);
+            list.push(obj);
+        });
+
+        egConfirmDialog.open(
+            egCore.strings.CONFIRM_CHANGE_ITEMS.delete,
+            egCore.strings.CONFIRM_CHANGE_ITEMS_MESSAGE.delete,
+            {items : list.length}
+        ).result.then(function () {
+            return egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.item.fleshed.batch.update',
+                egCore.auth.token(),
+                list
+            ).then( function(resp) {
+                var evt = egCore.evt.parse(resp);
+                if (evt) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                } else {
+                    ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                    return reload($scope.ssubId,_paging_filter);
+                }
+            });
+        });
+    }
+
+    $scope.edit_issuance_holding_code = function (items) {
+        var promises = [];
+        var edits = [];
+        angular.forEach(items.reverse(), function (item) {
+            promises.push( egSerialsCoreSvc.new_holding_code({
+                    title    : egCore.strings.SERIALS_EDIT_SISS_HC,
+                    curr_iss : egCore.idl.fromHash('siss',item.issuance),
+                    label    : item.issuance.label,
+                    type     : item.issuance.type ? item.issuance.type : 'basic',
+                    can_change_adhoc : true
+                }).then(function(result) {
+                    if (!result.adhoc) {
+                        item.issuance.holding_code = JSON.stringify(result.holding_code);
+                        item.issuance.holding_type = result.type;
+                    } else {
+                        item.issuance.label = result.label;
+                        item.issuance.holding_type = result.type;
+                    }
+
+                    item.issuance.date_published = result.date.toISOString();
+                    item.issuance.editor = egCore.auth.user();
+                    item.issuance.edit_date = 'now';
+
+                    var iss = egCore.idl.fromHash('siss',item.issuance);
+                    if (!result.adhoc) { // not an ad hoc issuance, get predicted label
+                        return egCore.net.request(
+                            'open-ils.serial',
+                            'open-ils.serial.make_prediction_values',
+                            egCore.auth.token(),
+                            { ssub_id : $scope.ssubId,
+                              num_to_predict : 0,
+                              include_base_issuance : 1,
+                              base_issuance : iss
+                            }
+                        ).then( function(resp) {
+                            var evt = egCore.evt.parse(resp);
+                            if (evt) {
+                                ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                            } else {
+                                iss.label(resp[0].label);
+                                edits.push(iss);
+                            }
+                        });
+                    }
+
+                    return $q.when(edits.push(iss));
+                })
+            );
+        });
+        return $q.all(promises)
+            .finally(function() {
+                if (edits.length) return update_issuances(edits);
+            });
+    }
+
+
+    function update_issuances (list) {
+        if (!angular.isArray(list)) list = [list];
+
+        return egCore.net.request(
+            'open-ils.serial',
+                'open-ils.serial.issuance.fleshed.batch.update',
+                egCore.auth.token(),
+                list
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+    }
+
+
+    $scope.following_issuance = function (items) {
+        return egSerialsCoreSvc.new_holding_code({
+            title : egCore.strings.SERIALS_ISSUANCE_ADD,
+            prev_iss : egCore.idl.fromHash('siss',items[0].issuance),
+            can_change_adhoc : true
+        }).then(function(hc) {
+            if (hc.adhoc) {
+                var new_iss = new egCore.idl.siss();
+                new_iss.creator( egCore.auth.user().id() );
+                new_iss.editor( egCore.auth.user().id() );
+                new_iss.date_published( hc.date.toISOString() );
+                new_iss.subscription( $scope.ssubId );
+                new_iss.label( hc.label );
+                new_iss.holding_type( hc.type );
+
+                return egCore.pcrud.create(new_iss).then(function(issuance) {
+                    var new_item = new egCore.idl.sitem();
+                    new_item.creator( egCore.auth.user().id() );
+                    new_item.editor( egCore.auth.user().id() );
+                    new_item.issuance( issuance.id() );
+                    new_item.stream( items[0].stream.id );
+                    new_item.date_expected( hc.date.toISOString() ); // XXX do we have interval math?
+
+                    return egCore.pcrud.create(new_item).then(function() {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    },function (error) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    });
+                },function (error) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                });
+            }
+
+            return egCore.net.request(
+                'open-ils.serial',
+                'open-ils.serial.make_predictions',
+                egCore.auth.token(),
+                { ssub_id : $scope.ssubId,
+                  num_to_predict : 1,
+                  base_issuance : egCore.idl.fromHash('siss',items[0].issuance)
+                }
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return reload($scope.ssubId,_paging_filter);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+        });
+    }
+
+    $scope.add_special_issuance = function() {
+        return egSerialsCoreSvc.new_holding_code({
+            title : egCore.strings.SERIALS_SPECIAL_ISSUANCE_ADD,
+            can_change_adhoc : false,
+            adhoc       : true
+        }).then(function(hc) {
+            // perforce add hoc
+            var new_iss = new egCore.idl.siss();
+            new_iss.creator( egCore.auth.user().id() );
+            new_iss.editor( egCore.auth.user().id() );
+            new_iss.date_published( hc.date.toISOString() );
+            new_iss.subscription( $scope.ssubId );
+            new_iss.label( hc.label );
+            new_iss.holding_type( hc.type );
+
+            return egCore.pcrud.create(new_iss).then(function(issuance) {
+                var new_items = [];
+                var sub = egSerialsCoreSvc.get_ssub($scope.ssubId);
+                angular.forEach(sub.distributions(), function(dist) {
+                    angular.forEach(dist.streams(), function(stream) {
+                        var new_item = new egCore.idl.sitem();
+                        new_item.creator( egCore.auth.user().id() );
+                        new_item.editor( egCore.auth.user().id() );
+                        new_item.issuance( issuance.id() );
+                        new_item.stream( stream.id() );
+                        new_item.date_expected( hc.date.toISOString() ); // XXX do we have interval math?
+                        new_items.push(new_item);
+                    });
+                });
+                var promises = [];
+                angular.forEach(new_items, function(item) {
+                    promises.push(egCore.pcrud.create(item));
+                });
+
+                $q.all(promises).then(function() {
+                    ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                    return reload($scope.ssubId,_paging_filter);
+                },function (error) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                });
+            });
+        });
+    }
+
+    $scope.do_print_routing_lists = false;
+    egCore.hatch.getItem('eg.serials.items.do_print_routing_lists').then(function(val) {
+        $scope.do_print_routing_lists = val;
+    });
+
+    $scope.receive_and_barcode = false;
+    egCore.hatch.getItem('eg.serials.items.receive_and_barcode').then(function(val) {
+        $scope.receive_and_barcode = val;
+    });
+
+    $scope.checkbox_handler = function(item) {
+        $scope[item.checkbox] = item.checked;
+        egCore.hatch.setItem('eg.serials.items.'+item.checkbox, item.checked);
+    }
+
+    $scope.receive_next = function () {
+        var list = [];
+        var next_per_stream = {};
+        angular.forEach(egSerialsCoreSvc.itemTree, function (item) {
+            if (next_per_stream[item.stream().id()]) return;
+            if (item.status() == 'Expected') {
+                next_per_stream[item.stream().id()] = item;
+                list.push(egCore.idl.Clone(item));
+            }
+        });
+
+        return egSerialsCoreSvc.process_items('receive', $scope.bibId, list, $scope.receive_and_barcode, false, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.receive_selected = function (list) {
+        var items = list.filter(function(i){
+            return i.status == 'Expected';
+        });
+        return egSerialsCoreSvc.process_items('receive', $scope.bibId, items.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), $scope.receive_and_barcode, false, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.reset_selected = function (list) {
+        return egSerialsCoreSvc.process_items('reset', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), false, false, false, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.bind_selected = function (list) {
+        return egSerialsCoreSvc.process_items('bind', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), true, true, $scope.do_print_routing_lists, function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.set_selected_as_claimed = function(list) {
+        return egSerialsCoreSvc.set_item_status('Claimed', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_discarded = function(list) {
+        return egSerialsCoreSvc.set_item_status('Discarded', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_not_published = function(list) {
+        return egSerialsCoreSvc.set_item_status('Not Published', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+    $scope.set_selected_as_not_held = function(list) {
+        return egSerialsCoreSvc.set_item_status('Not Held', $scope.bibId, list.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        }), function(){reload($scope.ssubId,_paging_filter)});
+    }
+
+    $scope.menu_print_routing_lists = function (items) {
+        items = items.map(function(item) {
+            return egCore.idl.Clone(egSerialsCoreSvc.itemMap[item.id]);
+        });
+        return egSerialsCoreSvc.print_routing_lists($scope.bibId, items, false, true, $scope.do_print_routing_lists);
+    }
+
+    $scope.add_issuances = function () {
+        egSerialsCoreSvc.add_issuances($scope.ssubId).then(function() {
+            return reload($scope.ssubId,_paging_filter);
+        });
+    }
+
+    $scope.need_one_selected = function() {
+        var items = $scope.itemGridControls.selectedItems();
+        if (items.length == 1) return false;
+        return true;
+    };
+
+    $scope.need_many_selected = function() {
+        var items = $scope.itemGridControls.selectedItems();
+        if (items.length > 1) return false;
+        return true;
+    };
+
+    $scope.need_expected = function() {
+        var items = $scope.itemGridControls.selectedItems().filter(function(i){
+            return i.status == 'Expected';
+        });
+        if (items.length) return false;
+        return true;
+    };
+
+    $scope.item_notes = function(rows) {
+        return $scope.notes('item',rows);
+    }
+    // TODO - refactor this, it's duplicated in subscription_manager.js
+    $scope.notes = function(note_type,rows) {
+        if (!rows) { return; }
+
+        function modal(existing_notes) {
+            $uibModal.open({
+                templateUrl: './serials/t_notes',
+                animation: true,
+                controller: 'NotesCtrl',
+                resolve : {
+                    note_type : function() { return note_type; },
+                    rows : function() {
+                        return rows;
+                    },
+                    notes : function() {
+                        return existing_notes;
+                    }
+                },
+                windowClass: 'app-modal-window',
+                backdrop: 'static',
+                keyboard: false
+            }).result.then(function(notes) {
+                egCore.pcrud.apply(notes).then(
+                    function(a) { ngToast.success(egCore.strings.SERIALS_ITEM_NOTE_SUCCESS_SAVE) },
+                    function(a) { ngToast.danger(egCore.strings.SERIALS_ITEM_NOTE_FAIL_SAVE) }
+                );
+            });
+        }
+
+        if (rows.length == 1) {
+            var fm_hint;
+            var search_hash = {};
+            var search_opt = {};
+            switch(note_type) {
+                case 'subscription':
+                    fm_hint = 'ssubn';
+                    search_hash.subscription = rows[0]['id'];
+                    search_opt.order_by = { ssubn : 'create_date' };
+                break;
+                case 'distribution':
+                    fm_hint = 'sdistn';
+                    search_hash.distribution = rows[0]['sdist.id'];
+                    search_opt.order_by = { sdistn : 'create_date' };
+                break;
+                case 'item': default:
+                    fm_hint = 'sin';
+                    search_hash.item = rows[0]['id'];
+                    search_opt.order_by = { sin : 'create_date' };
+                break;
+            }
+            egCore.pcrud.search(fm_hint, search_hash, search_opt,
+                { atomic : true }
+            ).then(function(list) {
+                modal(list);
+            });
+        } else {
+                // support batch creation of notes across selections,
+                // but not editing
+                modal([]);
+        }
+    }
+
+}]
+
+    }
+})
+
+// TODO - refactor this; it's duplicated in subscription_manager.js
+.controller('NotesCtrl',
+       ['$scope','$uibModalInstance','egCore','note_type','rows','notes',
+function($scope , $uibModalInstance , egCore , note_type , rows , notes ) {
+    $scope.note_type = note_type;
+    $scope.focusNote = true;
+    $scope.note = {
+        creator : egCore.auth.user().id(),
+        title   : '',
+        value   : '',
+        pub     : false,
+        'alert' : false,
+    };
+
+    $scope.require_initials = false;
+    egCore.org.settings([
+        'ui.staff.require_initials.copy_notes'
+    ]).then(function(set) {
+        $scope.require_initials = Boolean(set['ui.staff.require_initials.copy_notes']);
+    });
+
+    $scope.note_list = notes;
+
+    $scope.ok = function(note) {
+
+        var return_notes = [];
+        if (note.initials) note.value += ' [' + note.initials + ']';
+        if (   (typeof note.title != 'undefined' && note.title != '')
+            || (typeof note.value != 'undefined' && note.value != '')) {
+            angular.forEach(rows, function (r) {
+                var n;
+                switch(note_type) {
+                    case 'subscription':
+                        n = new egCore.idl.ssubn();
+                        n.subscription(r['id']);
+                        break;
+                    case 'distribution':
+                        n = new egCore.idl.sdistn();
+                        n.distribution(r['sdist.id']);
+                        break;
+                    case 'item':
+                    default:
+                        n = new egCore.idl.sin();
+                        n.item(r['id']);
+                }
+                n.isnew(true);
+                n.creator(note.creator);
+                n.pub(note.pub);
+                n['alert'](note['alert']);
+                n.title(note.title);
+                n.value(note.value);
+                return_notes.push( n );
+            });
+        }
+        angular.forEach(notes, function(n) {
+            if (n.ischanged() || n.isdeleted()) {
+                return_notes.push( n );
+            }
+        });
+        $uibModalInstance.close(return_notes);
+    }
+
+    $scope.cancel = function($event) {
+        $uibModalInstance.dismiss();
+        $event.preventDefault();
+    }
+}])
diff --git a/Open-ILS/web/js/ui/default/staff/serials/services/core.js b/Open-ILS/web/js/ui/default/staff/serials/services/core.js
new file mode 100644
index 0000000..5fe4756
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/serials/services/core.js
@@ -0,0 +1,1217 @@
+angular.module('egSerialsMod', ['egCoreMod'])
+.factory('egSerialsCoreSvc',
+       ['egCore','orderByFilter','$q','$filter','$uibModal','ngToast','egConfirmDialog',
+function(egCore , orderByFilter , $q , $filter , $uibModal , ngToast , egConfirmDialog) {
+    var DAY = 86400000;
+    var service = {
+        bibId : null,
+        subId : null,
+        subTree : [],
+        subList : [],
+        sptList : [],
+        mfhdList : [],
+        potentialPatternList : [],
+        flatMfhdList : [],
+        itemMap : {},
+        itemTree : [],
+        itemList : [],
+        freq_offset : {
+            a : 365 * DAY,
+            b : 62 * DAY,
+            c : 4 * DAY,
+            d : DAY,
+            e : 14 * DAY,
+            f : 186 * DAY,
+            g : 2 * 365 * DAY,
+            h : 3 * 365 * DAY,
+            i : 2 * DAY,
+            j : 10 * DAY,
+            k : DAY,
+            m : 31 * DAY,
+            q : 93 * DAY,
+            s : 14 * DAY,
+            t : 124 * DAY,
+            w : 7 * DAY,
+            x : 0
+        },
+        freq_chrons : {
+            a : ['year'],
+            b : ['year','month'],
+            c : ['year','month'],
+            d : ['year','month','day'],
+            e : ['year','month','day'],
+            f : ['year','month'],
+            g : ['year'],
+            h : ['year','month'],
+            i : ['year','month','day'],
+            j : ['year','month','day'],
+            k : ['year','month','day'],
+            m : ['year','month'],
+            q : ['year','season'],
+            s : ['year','month'],
+            t : ['year','month','day'],
+            w : ['year','month','day'],
+            x : ['year','month','day']
+        },
+        get_chron_part : {
+            year  : function(d) { return d.getFullYear() },
+            season: function(d) { return _loose_season(d) },
+            month : function(d) { return ('00' + (d.getMonth() + 1)).slice(-2) },
+            week  : function(d) { return $filter('date')(d, 'ww') },
+            day   : function(d) { return ('00'+d.getDate()).slice(-2) },
+            hour  : function(d) { return ('00'+d.getHours()).slice(-2) }
+        },
+        item_status_list : [
+            'Expected',
+            'Received',
+            'Claimed',
+            'Bindery',
+            'Bound',
+            'Discarded',
+            'Not Held',
+            'Not Published'
+        ],
+        item_status_i18n : []
+    };
+
+    angular.forEach(service.item_status_list, function(status) {
+        service.item_status_i18n.push({
+            name  : status,
+            label : egCore.strings.SERIALS_ITEM_STATUS[status]
+        });
+    });
+
+    function _loose_season(D) {
+        var m = D.getMonth() + 1;
+        var d = D.getDate();
+
+        if (
+            (m == 1 || m == 2) || (m == 12 && d >= 21) || (m == 3 && d < 20)
+        ) {
+            return 24;  /* MFHD winter */
+        } else if (
+            (m == 4 || m == 5) || (m == 3 && d >= 20) || (m == 6 && d < 21)
+        ) {
+            return 21;  /* spring */
+        } else if (
+            (m == 7 || m == 8) || (m == 6 && d >= 21) || (m == 9 && d < 22)
+        ) {
+            return 22;  /* summer */
+        } else {
+            return 23;  /* autumn */
+        }
+    }
+
+    service.fetch_mfhds = function(bibId, contextOrg) {
+        // TODO filter by contextOrg
+        return egCore.pcrud.search('sre', {
+                record       : bibId,
+                deleted      : 'f',
+                active       : 't'
+            }, {
+                flesh : 3,
+                flesh_fields : {
+                    'sre' : ['owning_lib']
+                }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            service.bibId = bibId;
+            service.mfhdList = list;
+            update_flat_mfhd_list();
+        });
+    }
+
+    service.fetch_patterns_from_bibs_mfhds = function(bibId) {
+        return egCore.net.request(
+            'open-ils.serial',
+            'open-ils.serial.caption_and_pattern.find_legacy_by_bib_record.atomic',
+            egCore.auth.token(),
+            bibId
+        ).then(function(list) {
+            service.potentialPatternList = egCore.idl.toTypedHash(list);
+            angular.forEach(service.potentialPatternList, function(pot) {
+                var rec = new MARC21.Record({ marcxml : pot.marc });
+                var pattern_fields = rec.fields.filter(function(f) {
+                    return (f.tag == '853' || f.tag == '854' || f.tag == '855');
+                });
+                pot.desc = '';
+                if (pattern_fields.length > 0) {
+                    // just take the first one
+                    var fld = pattern_fields[0];
+                    pot.desc = fld.tag + ' ' + fld.ind1 + fld.ind2 +
+                               fld.subfields.map(function(sf) { 
+                                 return '$' + sf[0] + sf[1]
+                               }).join('');
+                }
+            });
+        })
+    }
+
+    // fetch subscription, distributions, streams, captions,
+    // and notes associated with the indicated bib
+    service.fetch = function(bibId, contextOrg) {
+
+        var filter = { record_entry : bibId };
+        if (contextOrg) filter.owning_lib = egCore.org.descendants(contextOrg, true);
+        return egCore.pcrud.search('ssub', filter,
+            {
+                flesh : 5,
+                flesh_fields : {
+                    'ssub'  : ['owning_lib','distributions', 'scaps', 'notes'],
+                    'sdist' : [ 'record_entry','holding_lib',
+                                'receive_call_number',
+                                'receive_unit_template',
+                                'bind_call_number',
+                                'bind_unit_template',
+                                'streams','notes'],
+                    'sstr'  : ['routing_list_users'],
+                    'srlu'  : ['reader'],
+                    'au'    : ['card','home_ou','mailing_address','billing_address']
+                }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            service.bibId = bibId;
+            service.subTree = list;
+            update_flat_sdist_sstr_list();
+            return $q.when(list);
+        });
+    }
+
+    // fetch subscription, distributions, streams, captions,
+    // and notes associated with the indicated bib
+    service.fetchLastCallnumber = function(contextOrg) {
+        return egCore.pcrud.search('acn', {
+                record : service.bibId,
+                owning_lib : contextOrg,
+                deleted : 'f'
+            }, { flesh : 1,
+                 flesh_fields : {acn : ['prefix','suffix']},
+                 order_by : [{class:'acn',field:'create_date',direction:'desc'}],
+                 limit : 1
+            }, { atomic : true }
+        ).then(function(list) {
+            return $q.when(list[0]);
+        });
+    }
+
+    service.fetchItemsForSubPaged = function(subId,filter,offset,limit,sort) {
+        return service.fetchItemsForSub(
+            subId,
+            filter,
+            { limit : limit, offset : offset, paging : true },
+            sort
+        );
+    }
+
+    // Creates an inverted tree from item to sub
+    service.fetchItemsForSub = function(subId,filter,options,sort) {
+        var deferred = $q.defer(); // side-effects only, otherwise the grid is wonky
+
+        if (!filter) filter = {};
+        if (!options) options = { limit : 100 }; // only used during full refresh
+
+        if (!subId && service.subId) subId = service.subId;
+        if (!subId) return $q.reject('fetchItemsForSub: no subscription id');
+
+        var sub = service.get_ssub(subId);
+        if (!sub) return $q.reject('fetchItemsForSub: unknown subscription id');
+
+        var streams = [];
+        angular.forEach(sub.distributions(), function(dist) {
+            angular.forEach(
+                dist.streams().map(
+                    function (stream) { return stream.id() }
+                ),
+                function (sid) { streams.push(sid) }
+            );
+        });
+
+        angular.extend(filter, {stream:streams});
+        angular.extend(options, { 
+            order_by : [{class:'sitem',field:'date_expected'}], // best aprox of pub date
+            flesh : 1,
+            flesh_fields : {
+                sitem : ['notes','issuance','editor','creator','unit','url']
+            }
+        });
+        if (sort) {
+            angular.extend(options, {
+                order_by : [sort]
+            });
+        }
+
+        egCore.pcrud.search(
+            'sitem', filter, options,
+            { atomic : true }
+        ).then(function(list) {
+            service.subId = subId;
+            if (!options.paging) { // not paged
+                service.itemTree = list;
+                service.itemMap = {};
+            } else { // paged
+                angular.forEach(list, function (item) {
+                    var exists = service.itemTree.filter(function (i) {
+                        return i.id() == item.id()
+                    }).length;
+                    if (!exists) service.itemTree.push(item);
+                });
+            }
+
+            // map items by stream for faster lookup
+            var tmp = {};
+            angular.forEach(list, function(item) {
+                if (!tmp[item.stream()]) tmp[item.stream()] = [];
+                tmp[item.stream()].push(item);
+                service.itemMap[item.id()] = item;
+            });
+
+            angular.forEach(sub.distributions(), function(dist) {
+                angular.forEach(dist.streams(), function(stream) {
+                    angular.forEach(tmp[stream.id()], function (item) {
+                        var routing_list = egCore.idl.Clone(stream.routing_list_users());
+                        var st = egCore.idl.Clone(stream,1);
+                        st.routing_list_users(routing_list);
+                        var d = egCore.idl.Clone(dist,1);
+                        var ss = egCore.idl.Clone(sub,1);
+                        ss.distributions([]);
+                        d.subscription(ss);
+                        d.streams([]);
+                        st.distribution(d);
+                        item.stream(st);
+                    });
+                });
+            });
+
+            var hashList = egCore.idl.toHash(service.itemTree);
+            angular.forEach(hashList, function (item) {
+                item['issuance.date_published'] = item.issuance.date_published;
+                item['stream.distribution.holding_lib.name'] = item.stream.distribution.holding_lib.name;
+            });
+
+            // ... then sort it
+            if (sort) {
+                service.itemList = hashList;
+            } else {
+                service.itemList = orderByFilter(hashList, ['"issuance.date_published"', '"stream.distribution.holding_lib.name"', '"id"']);
+            }
+            deferred.resolve();
+        });
+
+        return deferred.promise;
+    }
+
+    service.prep_new_holding_code = function (args) {
+
+        var type = args.type;
+        var date = args.date;
+        var prev_iss = args.prev_iss;
+        var curr_iss = args.curr_iss;
+        var adhoc = false;
+        var link = '1.1';
+        var current_values = {};
+
+        var sub = service.get_ssub(service.subId);
+        if (!sub) return args;
+
+        var scap;
+        if (prev_iss && prev_iss.holding_code()) { // we're predicting
+            var old_link_parts = JSON.parse(prev_iss.holding_code())[3].split('.');
+            var olink = old_link_parts[0];
+            var oseq = parseInt(old_link_parts[1]) + 1;
+            link = [olink,oseq].join('.');
+
+            if (prev_iss.holding_type())
+                type = prev_iss.holding_type();
+
+            if (prev_iss.caption_and_pattern()) {
+                var tmp = sub.scaps().filter(function (s) {
+                    return (s.id() == prev_iss.caption_and_pattern());
+                });
+                if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+            }
+
+            date = new Date(prev_iss.date_published());
+        } else if (curr_iss) { // we're editing
+            if (curr_iss.holding_type())
+                type = curr_iss.holding_type();
+
+            if (curr_iss.caption_and_pattern()) {
+                var tmp = sub.scaps().filter(function (s) {
+                    return (s.id() == curr_iss.caption_and_pattern());
+                });
+                if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+            }
+            if (!curr_iss.holding_code()) {
+                adhoc = true;
+            } else {
+                var tmp = JSON.parse(curr_iss.holding_code());
+                for (var i = 2; i < tmp.length; i += 2) {
+                    // we're intentionally being a bit sloppy here, as
+                    // the only subfields we are about in this context
+                    // are the ones that are not repeatable
+                    current_values[tmp[i]] = tmp[i + 1];
+                }
+            }
+
+            date = new Date(curr_iss.date_published());
+        } else {
+            // starting from scratch, so default the
+            // first publication date to the subscription start date
+            if (!date) date = new Date(sub.start_date());
+        }
+
+        args.date = date;
+
+        if (!scap) {
+            var tmp = sub.scaps().filter(function (s) {
+                return (s.type() == type && s.active() == 't');
+            });
+            if (angular.isArray(tmp) && tmp[0]) scap = tmp[0];
+        }
+
+        if (!scap) return args;
+
+        var others = [], enums = [], chrons = [], freq = '';
+        var pat = JSON.parse(scap.pattern_code()).slice(4); // just the part we care about
+
+        var freq_index = pat.indexOf('w');
+        if (freq_index > -1) {
+            freq = pat[freq_index + 1];
+            if (prev_iss) {
+                date = new Date(
+                    date.getTime() + service.freq_offset[freq]
+                );
+            }
+        }
+       
+        if (!date) date = new Date();
+
+        for (var i = 0; i < pat.length; i++) {
+            sf = pat[i]; i++;
+            val = pat[i];
+
+            if (sf != 'w') {
+                var pat_part = {
+                    subfield : sf,
+                    pattern  : val
+                };
+
+                var chron_part = String(val).replace(/[)(]+/g,'');
+                if (sf in current_values) {
+                    pat_part.value = current_values[sf];
+                } else {
+                    try {
+                        pat_part.value = service.get_chron_part[chron_part](date);
+                    } catch (e) {
+                        // not a chron part
+                        pat_part.value = '';
+                    }
+                }
+
+                if (sf.match(/[a-f]/)) {
+                    enums.push(pat_part);
+                } else if (sf.match(/[i-l]/)) {
+                    chrons.push(pat_part);
+                } else {
+                    others.push(pat_part);
+                }
+            }
+        }
+
+        if (enums.length == 0 && chrons.length == 0) {
+            var parts = service.freq_chrons[freq];
+            if (parts.length) {
+                angular.forEach(parts, function(p, ind) {
+                    var sf = !ind ? 'i' : !--ind ? 'j' : 'k';
+                    chrons.push({
+                        subfield : sf,
+                        value    : service.get_chron_part.year(date)
+                    });
+                });
+            } else { 
+                chrons = [
+                    { subfield : 'i', value : service.get_chron_part.year(date)  },
+                    { subfield : 'j', value : service.get_chron_part.month(date) },
+                    { subfield : 'k', value : service.get_chron_part.day(date)  }
+                ];
+            }
+        }
+
+        return {
+            holding_code : ["4","1","8",link],
+            scap         : scap.id(),
+            type         : type,
+            date         : date,
+            enums        : enums,
+            chrons       : chrons,
+            others       : others,
+            freq         : freq,
+            adhoc        : adhoc
+        };
+    }
+
+    service.new_holding_code = function (options) {
+        if (options === undefined) options = {};
+        options.count = options.count || 1;
+        options.label = options.label || '';
+
+        return $uibModal.open({
+            templateUrl: './serials/t_holding_code_dialog',
+            //size: 'lg',
+            //windowClass: 'eg-wide-modal',
+            backdrop: 'static',
+            controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.focusMe = true;
+                $scope.title = options.title;
+                $scope.request_count = options.request_count;
+                $scope.count = options.count;
+                $scope.label = options.label;
+                $scope.save_label = options.save_label;
+                $scope.pubdate = options.date;
+                $scope.type = options.type || 'basic';
+                $scope.args = { adhoc : false };
+                if (options.adhoc) $scope.args.adhoc = true;
+                $scope.can_change_adhoc = options.can_change_adhoc;
+
+                function refresh (n,o) {
+                    if (n && o && n !== o) {
+                        $scope.args = service.prep_new_holding_code({
+                            type : $scope.type,
+                            date : $scope.pubdate,
+                            prev_iss : options.prev_iss,
+                            curr_iss : options.curr_iss,
+                        });
+                        if (!options.can_change_adhoc && options.adhoc) $scope.args.adhoc = true;
+
+                        if ($scope.args.type && $scope.type != $scope.args.type)
+                            $scope.type = $scope.args.type;
+                        if ($scope.args.date)
+                            $scope.pubdate = $scope.args.date;
+
+                        delete options.prev_iss; // only use this once
+                        delete options.curr_iss; // only use this once
+                    }
+                }
+
+                $scope.$watch('count',function (n) {options.count = n});
+                $scope.$watch('label',function (n) {options.label = n});
+                $scope.$watch('type',refresh);
+                $scope.$watch('pubdate',refresh);
+
+                $scope.ok = function(args) { $uibModalInstance.close(args) }
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+
+                refresh(1,2); // force data loading
+            }]
+        }).result.then(function (args) {
+            if (args.enums && args.chrons) {
+                angular.forEach(
+                    args.enums.concat(args.chrons),
+                    function (e) {
+                        args.holding_code.push(e.subfield);
+                        args.holding_code.push(e.value);
+                    }
+                );
+            }
+            args.count = options.count;
+            args.label = options.label;
+            return $q.when(args);
+        });
+    }
+
+    function update_flat_mfhd_list() {
+        var list = [];
+        angular.forEach(service.mfhdList, function(sre) {
+            var mfhdHash = egCore.idl.toHash(sre);
+            var rec = new MARC21.Record({ marcxml : mfhdHash.marc });
+            var _mfhd = {
+                'id'                   : mfhdHash.id,
+                'owning_lib.name'      : mfhdHash.owning_lib.name,
+                'owning_lib.id'        : mfhdHash.owning_lib.id,
+                'marc'                 : rec.toBreaker(),
+                'marc_xml'             : mfhdHash.marc,
+                'svr'                  : null,
+                'basic_holdings'       : null,
+                'index_holdings'       : null,
+                'supplement_holdings'  : null
+            }
+            list.push(_mfhd);
+            egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.serial.record.mfhd.retrieve',
+                mfhdHash.id
+            ).then(function(svr) {
+                _mfhd.svr = egCore.idl.toTypedHash(svr);
+                _mfhd.basic_holdings = _mfhd.svr.basic_holdings.join("; ");
+                _mfhd.index_holdings = _mfhd.svr.index_holdings.join("; ");
+                _mfhd.supplement_holdings = _mfhd.svr.supplement_holdings.join("; ");
+            })
+        });
+        service.flatMfhdList.length = 0;
+        angular.extend(service.flatMfhdList, list);
+    }
+
+    // create/update a flat version of the subscription/distribution/stream
+    // tree for feeding to the distribution and stream grid
+    function update_flat_sdist_sstr_list() {
+
+        // flatten the structure...
+        var list = [];
+        angular.forEach(service.subTree, function(ssub) {
+            var ssubHash = egCore.idl.toHash(ssub);
+
+            var _ssub = {
+                'id'                   : ssubHash.id,
+                'owning_lib.name'      : ssubHash.owning_lib.name,
+                'owning_lib.id'        : ssubHash.owning_lib.id,
+                'start_date'           : ssubHash.start_date,
+                'end_date'             : ssubHash.end_date,
+                'expected_date_offset' : ssubHash.expected_date_offset
+            };
+            // insert and escape if we have no distributions
+            if (ssubHash.distributions.length == 0) {
+                list.push(_ssub);
+                return;
+            }
+
+            angular.forEach(ssubHash.distributions, function(sdist) {
+                var _sdist = {};
+                angular.forEach([
+                    'id',
+                    'summary_method',
+                    'record_entry',
+                    'label',
+                    'display_grouping',
+                    'unit_label_prefix',
+                    'unit_label_suffix',
+                ], function(fld) {
+                    _sdist['sdist.' + fld] = sdist[fld];
+                });
+                _sdist['sdist.holding_lib.name'] = sdist.holding_lib.name;
+                _sdist['sdist.holding_lib.id'] = sdist.holding_lib.id;
+                _sdist['sdist.receive_call_number.label'] = 
+                    sdist.receive_call_number ? sdist.receive_call_number.label : null;
+                _sdist['sdist.receive_unit_template.name'] =
+                    sdist.receive_unit_template ? sdist.receive_unit_template.name : null;
+                _sdist['sdist.bind_call_number.label'] =
+                    sdist.bind_call_number ? sdist.bind_call_number.label : null;
+                _sdist['sdist.bind_unit_template.name'] =
+                    sdist.bind_unit_template ? sdist.bind_unit_template.name : null;
+                // if we have no streams, add to the list and escape
+                if (sdist.streams.length == 0) {
+                    var row = {};
+                    angular.extend(row, _ssub, _sdist);
+                    list.push(row);
+                    return;
+                }
+
+                angular.forEach(sdist.streams, function(sstr) {
+                    var _sstr = {
+                        'sstr.id'                 : sstr.id,
+                        'sstr.routing_label'      : sstr.routing_label,
+                        'sstr.additional_routing' : ((sstr.routing_list_users.length > 0) ? true : false)
+                    };
+                    var row = {};
+                    angular.extend(row, _ssub, _sdist, _sstr);
+                    list.push(row);
+                });
+            });
+        });
+
+        // ... then sort it
+        service.subList.length = 0;
+        angular.extend(service.subList,
+            orderByFilter(list, ['"owning_lib.name"', '"start_date"', '"end_date"',
+                                 '"holding_lib.name"', '"sdist.id"', '"sstr.id"'])
+        );
+
+        // ... then remove duplication of owning library, distribution library,
+        // and distribution labels
+        var sub_lib = null;
+        var dist_lib = null;
+        var dist_label = null;
+        var index = 0;
+        angular.forEach(service.subList, function(row) {
+            row['index'] = index++;
+            if (sub_lib == row['owning_lib.name']) {
+                row['owning_lib.name'] = null;
+            } else {
+                sub_lib = row['owning_lib.name'];
+                dist_lib = row['sdist.holding_lib.name'];
+                dist_label = row['sdist.label'];
+                return;
+            }
+            if (dist_lib == row['sdist.holding_lib.name']) {
+                row['sdist.holding_lib.name'] = null;
+            } else {
+                dist_lib = row['sdist.holding_lib.name'];
+            }
+            if (dist_label == row['sdist.label']) {
+                row['sdist.label'] = null;
+            } else {
+                dist_label = row['sdist.label'];
+            }
+        });
+    }
+
+    // verify that a subscription ID and bib ID are actually
+    // associated with each other
+    service.verify_subscription_id = function(bibId, ssubId) {
+        var deferred = $q.defer();
+        egCore.pcrud.search('ssub', {
+                record_entry : bibId,
+                id           : ssubId
+        }, {}, { atomic : true, idlist : true }
+        ).then(function(list) {
+            if (list.length == 1) {
+                deferred.resolve(true);
+            } else {
+                deferred.resolve(false);
+            }
+        });
+        return deferred.promise;
+    }
+
+    service.get_ssub = function(ssubId) {
+        if (!ssubId) return;
+        for (var i = 0; i <= service.subTree.length; i++) {
+            if (service.subTree[i].id() == ssubId) {
+                return service.subTree[i];
+            }
+        }
+    }
+
+    service.fetch_spt = function() {
+        return egCore.net.request(
+            'open-ils.serial',
+            'open-ils.serial.pattern_template.retrieve.at.atomic',
+            egCore.auth.token(),
+            egCore.auth.user().ws_ou()
+        ).then(function(list) {
+            service.sptList.length = 0;
+            angular.extend(service.sptList, list);
+        });
+    }
+
+    service.fetch_templates = function(org) {
+        return egCore.pcrud.search('act',
+            {owning_lib : egCore.org.fullPath(org, true)},
+            {order_by : { act : 'name' }}, {atomic : true}
+        );
+    };
+
+    service.print_routing_lists = function (bibId, items, check, force, print_rl) {
+        if (!check && !print_rl && !force) return $q.when();
+
+        return egCore.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.record.mods_slim.retrieve',
+            bibId
+        ).then(function(mvr) {
+
+            var by_issuance = {};
+            angular.forEach(items, function (i) {
+                if (check && !i._print_routing_list) return;
+                if (!by_issuance[i.issuance().id()])
+                    by_issuance[i.issuance().id()] = [];
+                by_issuance[i.issuance().id()].push(i);
+            });
+
+            var issuance_matrix = [];
+            angular.forEach(by_issuance, function (list) {
+                issuance_matrix.push(list);
+            });
+
+            var deferred = $q.defer();
+            var promise = deferred.promise;
+
+            angular.forEach(issuance_matrix, function(item_list, index) {
+
+                promise = promise.then(function(){
+                    return $uibModal.open({
+                        templateUrl: './serials/t_print_routing_list',
+                        size: 'lg',
+                        windowClass: 'eg-wide-modal',
+                        backdrop: 'static',
+                        controller:
+                        ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                            var all_users = [];
+                            var all_streams = [];
+
+                            angular.forEach(item_list, function(i){
+                                all_streams.push(i.stream());
+                                all_users = all_users.concat(i.stream().routing_list_users());
+                            });
+
+                            $scope.xulg = {
+                                show_print_button: true,
+                                routing_list_data: {
+                                    streams : all_streams,
+                                    mvr     : mvr,
+                                    issuance: item_list[0].issuance(),
+                                    users   : orderByFilter(all_users, 'pos')
+                                }
+                            };
+
+                            $scope.url = '/eg/serial/print_routing_list_users?ses=' + egCore.auth.token();
+                            $scope.last = index == issuance_matrix.length - 1 ? true : false; 
+                            $scope.ok = function() { $uibModalInstance.close() }
+                        }]
+                    }).result;
+                });
+
+            });
+
+            return deferred.resolve();
+        });
+
+    }
+
+    service.set_item_status = function(newStatus, bibId, list, callback) {
+        if (!callback) callback = function () { return $q.when() }
+        if (!list.length) return $q.reject();
+
+        return egConfirmDialog.open(
+            egCore.strings.CONFIRM_CHANGE_ITEMS.status,
+            egCore.strings.CONFIRM_CHANGE_ITEMS_MESSAGE.status,
+            {items : list.length}
+        ).result.then(function () {
+            var promises = [$q.when()];
+            angular.forEach(list, function(item) {
+                item.status(newStatus);
+                promises.push(
+                    egCore.net.request(
+                        'open-ils.serial',
+                        'open-ils.serial.item.update',
+                        egCore.auth.token(),
+                        item
+                    ).then(function(res) {
+                        return $q.when();
+                    })
+                );
+            });
+            $q.all(promises).then(function() {
+                callback();
+            });
+        });
+    }
+    
+    service.process_items = function (mode, bibId, list, do_barcode, bind, print_rl, callback) {
+        if (!callback) callback = function () { return $q.when() }
+        if (!list.length) return $q.reject();
+
+        // deal with locations and circ mods for *NEW* units
+        var copy_locations = {};
+        var circ_mods = {};
+
+        // deal with barcodes and call numbers for *NEW* units
+        var barcodes = {};
+        var call_numbers = {};
+        var call_numbers_by_siss_and_sdist = {};
+
+        var deferred = $q.defer();
+        var current_promise = deferred.promise;
+        var last_promise;
+
+        var sitem_alerts = [];
+        var sdist_alerts = [];
+        var ssub_alerts = list[0].stream().distribution().subscription().notes().filter(function(n){
+            return n.alert() == 't';
+        })
+
+        var dist_seen = {};
+        angular.forEach(list, function(i) {
+            sitem_alerts = sitem_alerts.concat(
+                i.notes().filter(function(n){
+                    return n.alert() == 't';
+                })
+            );
+            var sdist = '_'+i.stream().distribution().id();
+            if (!dist_seen[sdist]) {
+                dist_seen[sdist] = 1;
+                sdist_alerts = sdist_alerts.concat(
+                    i.stream().distribution().notes().filter(function(n){
+                        return n.alert() == 't';
+                    })
+                );
+            }
+        });
+
+        if (do_barcode || bind) {
+
+            last_promise = current_promise.then(function(){ return $uibModal.open({
+                templateUrl: './serials/t_batch_receive',
+                size: 'lg',
+                windowClass: 'eg-wide-modal',
+                backdrop: 'static',
+                controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+
+                    $scope.print_routing_lists = print_rl;
+                    $scope.barcode_items = do_barcode;
+                    $scope.force_bind = bind;
+                    $scope.bind = bind;
+                    $scope.items = list;
+                    $scope.ssub_alerts = ssub_alerts;
+                    $scope.sdist_alerts = sdist_alerts;
+                    $scope.sitem_alerts = sitem_alerts;
+                    $scope.acn_list = [];
+                    $scope.acnp_labels = [];
+                    $scope.acns_labels = [];
+                    $scope.acpl_list = [];
+
+                    $scope.cannot_print = function (index) {
+                        return $scope.items[index].stream().routing_list_users().length == 0 || ($scope.bind && index > 0);
+                    }
+
+                    $scope.bind_or_none = function (index) {
+                        return !$scope.barcode_items || ($scope.bind && index > 0);
+                    }
+
+                    $scope.focus_next_barcode = function (index) {
+                        index++;
+                        $('#item_barcode_'+index).focus().select();
+                    }
+
+                    $scope.fullCNLabel = function (cn) {
+                        var label = [cn.prefix.label,cn.label,cn.suffix.label].join(' ');
+                        return label;
+                    }
+
+                    $scope.apply_template_overrides = function (e) {
+                        if ($scope.selected_call_number) {
+                            angular.forEach($scope.items, function (i) {
+                                i._call_number = $scope.selected_call_number.label;
+                                i._cn_prefix = $scope.selected_call_number.prefix.label;
+                                i._cn_suffix = $scope.selected_call_number.suffix.label;
+                            });
+                        }
+                        if ($scope.selected_circ_mod) {
+                            angular.forEach($scope.items, function (i) {
+                                i._circ_mod = $scope.selected_circ_mod;
+                            });
+                        }
+                        if ($scope.selected_copy_location) {
+                            angular.forEach($scope.items, function (i) {
+                                i._copy_location = $scope.selected_copy_location;
+                            });
+                        }
+                    }
+
+                    $scope.ok = function(items) { $uibModalInstance.close(items) }
+                    $scope.cancel = function () { $uibModalInstance.dismiss() }
+
+                    var dist_libs = {};
+                    var pile_o_promises = [$q.when()];
+
+                    // let's gather what we need...
+                    angular.forEach(list, function (i, index) {
+                        var dlib = i.stream().distribution().holding_lib().id();
+                        dist_libs[dlib] = egCore.org.fullPath(dlib, true);
+                        if (i.unit()) {
+                            i._barcode = i.unit().barcode();
+                            pile_o_promises.push(
+                                egCore.pcrud.retrieve(
+                                    'acn', i.unit().call_number(),
+                                    {flesh : 1, flesh_fields : {acn : ['prefix','suffix']}}
+                                ).then(function(cn){
+                                    if (cn.deleted() == 'f') {
+                                        i._call_number = cn.label();
+                                        i._cn_prefix = cn.prefix().label();
+                                        i._cn_suffix = cn.suffix().label();
+                                    }
+                                })
+                            );
+                        } else {
+                            if (i.stream().distribution()[mode + '_call_number']() && 
+                                i.stream().distribution()[mode + '_call_number']().deleted() == 'f'
+                            ) {
+                                i._call_number = i.stream().distribution()[mode + '_call_number']().label();
+                            } else {
+                                pile_o_promises.push(
+                                    service.fetchLastCallnumber(
+                                        i.stream().distribution().holding_lib().id()
+                                    ).then(function(cn){
+                                        if (cn) {
+                                            i._call_number = cn.label();
+                                            i._cn_prefix = cn.prefix().label();
+                                            i._cn_suffix = cn.suffix().label();
+                                        }
+                                    })
+                                );
+                            }
+                        }
+
+                        if (i.stream().distribution()[mode + '_unit_template']()) {
+                            i._copy_location = i.stream().distribution()[mode + '_unit_template']().location();
+                            i._circ_mod = i.stream().distribution()[mode + '_unit_template']().circ_modifier();
+                        }
+
+                        if ($scope.print_routing_lists && !$scope.cannot_print(index))
+                            i._print_routing_list = true;
+
+                        i._receive = true;
+                    });
+
+                    // build unique list of orgs from distribution.holding_lib fullPaths
+                    var dist_lib_list = [];
+                    angular.forEach(dist_libs, function (l) {
+                        dist_lib_list = dist_lib_list.concat(l);
+                    });
+                    dist_lib_list = dist_lib_list.filter(function(v,i,s){
+                        return s.indexOf(v) == i;
+                    });
+
+                    // Copy locations only come from the workstation location, same as XUL
+                    pile_o_promises.push(egCore.pcrud.search(
+                        'acpl',
+                        {owning_lib : egCore.org.fullPath(egCore.auth.user().ws_ou(), true)},
+                        {},{ atomic : true }
+                    ).then(function (list) {
+                        $scope.acpl_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    // Call numbers, however, come from anywhere the distributions might live
+                    pile_o_promises.push(egCore.pcrud.search(
+                        'acn',
+                        {deleted : 'f', record : bibId, owning_lib : dist_lib_list},
+                        {flesh : 1, flesh_fields : {acn : ['prefix','suffix']}},{ atomic : true }
+                    ).then(function (list) {
+                        $scope.acn_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    // Likewise for prefix and suffix, for combo box
+                    angular.forEach(['acnp','acns'], function (cl) {
+                        pile_o_promises.push(egCore.pcrud.search(
+                            cl,
+                            {owning_lib : dist_lib_list},
+                            {},{ atomic : true }
+                        ).then(function (list) {
+                            $scope[cl+'_labels'] = list.map(function(i){return i.label()});
+                            return $q.when();
+                        }));
+                    });
+
+                    pile_o_promises.push(egCore.pcrud.retrieveAll(
+                        'ccm', {}, { atomic : true }
+                    ).then(function (list) {
+                        $scope.ccm_list = list.map(function(i){return egCore.idl.toHash(i)});
+                        return $q.when();
+                    }));
+
+                    $q.all(pile_o_promises).then(function() {
+                        console.log('receive data collected');
+                    });
+
+                    $scope.$watch('barcode_items', function (n,o) {
+                        if (n === undefined || n == o) return;
+                        do_barcode = n;
+                    });
+
+                    $scope.$watch('bind', function (n,o) {
+                        if (n === undefined || n == o) return;
+                        bind = n;
+                        if (bind) {
+                            angular.forEach($scope.items, function (i,index) {
+                                if (index > 0) i._print_routing_list = false;
+                            });
+                        }
+                    });
+                        
+                    $scope.$watch('auto_barcodes', function (n,o) {
+                        if (n === undefined || n == o) return;
+
+                        var bc = '@@AUTO';
+                        if (!n) bc = '';
+
+                        angular.forEach($scope.items, function (i) {
+                            if (!i.stream().distribution().receive_unit_template()) return;
+                            var _barcode = i._barcode;
+                            i._barcode = bc || i._old_barcode;
+                            i._old_barcode = _barcode;
+                        });
+                    });
+
+                    $scope.$watch('print_routing_lists', function (n,o) {
+                        if (n === undefined || n == o) return;
+
+                        angular.forEach($scope.items, function(i, index) {
+                            if (!$scope.cannot_print(index)) {
+                                i._print_routing_list = n;
+                            } else {
+                                i._print_routing_list = false;
+                            }
+                        });
+                    });
+                }]
+            }).result});
+        } else {
+            last_promise = current_promise.then(function(){ return $uibModal.open({
+                templateUrl: './serials/t_receive_alerts',
+                controller:
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                    $scope.title = egCore.strings.CONFIRM_CHANGE_ITEMS[mode];
+                    $scope.items = list.length;
+                    $scope.list = list;
+                    $scope.mode = mode;
+                    $scope.ssub_alerts = ssub_alerts;
+                    $scope.sdist_alerts = sdist_alerts;
+                    $scope.sitem_alerts = sitem_alerts;
+
+                    $scope.ok = function(items) { $uibModalInstance.close(items) }
+                    $scope.cancel = function () { $uibModalInstance.dismiss() }
+                }]
+            }).result.then(
+                function(items) {
+                    angular.forEach(list, function (i, index) {
+                        i._receive = true;
+                    });
+                    return $q.when(list);
+                })
+            });
+        }
+
+        last_promise.then(function (items) {
+
+            var method;
+            if (mode == 'receive') {
+                method = 'open-ils.serial.receive_items';
+                items = items.filter(function(i){return i._receive});
+            } else if ( mode == 'bind') {
+                method = 'open-ils.serial.bind_items';
+                items = items.filter(function(i){return i._receive});
+            } else if ( mode == 'reset') {
+                method = 'open-ils.serial.reset_items';
+            } 
+
+            if (!items.length) return $q.reject();
+
+            var donor_unit_ids = {};
+            angular.forEach(items, function(i, index) {
+                if (i.unit()) donor_unit_ids[i.unit().id()] = 1;
+                if (do_barcode) i.unit(-1);
+                if (bind) i.unit(-2);
+                copy_locations[i.id()] = i._copy_location;
+                circ_mods[i.id()] = i._circ_mod;
+                call_numbers[i.id()] = [i._cn_prefix, i._call_number, i._cn_suffix] || 'DEFAULT';
+                barcodes[i.id()] = i._barcode || '@@AUTO';
+                if (bind && index > 0) barcodes[i.id()] = items[0]._barcode;
+            });
+
+            return egCore.net.request(
+                'open-ils.serial', method,
+                egCore.auth.token(), items, barcodes, call_numbers, donor_unit_ids,
+                    {circ_mods:circ_mods, copy_locations : copy_locations}
+            ).then(
+                function(resp) {
+                    var evt = egCore.evt.parse(resp);
+                    if (evt) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    } else {
+                        ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        return service.print_routing_lists(bibId, items, do_barcode || bind, false, print_rl)
+                            .finally(callback);
+                    }
+                },
+                function(resp) {
+                    ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                }
+            );
+        });
+
+        return deferred.resolve();
+    }
+
+    service.add_issuances = function (mySsubId) {
+        if (!mySsubId && service.subId) mySsubId = service.subId;
+        if (!mySsubId) return $q.reject('fetchItemsForSub: no subscription id');
+
+        var sub = service.get_ssub(mySsubId);
+        if (!sub) return $q.reject('fetchItemsForSub: unknown subscription id');
+
+        var streams = [];
+        angular.forEach(sub.distributions(), function(dist) {
+            angular.forEach(
+                dist.streams().map(
+                    function (stream) { return stream.id() }
+                ),
+                function (sid) { streams.push(sid) }
+            );
+        });
+
+        var options = { 
+            order_by : [{class:'sitem',field:'date_expected',direction:'desc'}], // best aprox of pub date
+            limit : 1,
+            flesh : 1,
+            flesh_fields : { sitem : ['issuance'] }
+        };
+
+        return egCore.pcrud.search(
+            'sitem', {stream:streams},
+            {   order_by : [{class:'sitem',field:'date_expected',direction:'desc'}], // best aprox of pub date
+                limit : 1,
+                flesh : 1,
+                flesh_fields : { sitem : ['issuance'] }
+            },
+            { atomic : true }
+        ).then(function(list) {
+            var lastItem = list[0];
+    
+            if (lastItem) lastItem = lastItem.issuance();
+    
+            return service.new_holding_code({
+                title : egCore.strings.SERIALS_ISSUANCE_PREDICT,
+                request_count : true,
+                prev_iss : lastItem,
+                allow_adhoc : false
+            }).then(function(hc) {
+    
+                var base_iss;
+                if (!lastItem) {
+                    base_iss = new egCore.idl.siss();
+                    base_iss.creator( egCore.auth.user().id() );
+                    base_iss.editor( egCore.auth.user().id() );
+                    base_iss.date_published( hc.date.toISOString() );
+                    base_iss.subscription( mySsubId );
+                    base_iss.caption_and_pattern( hc.scap );
+                    base_iss.holding_code( JSON.stringify(hc.holding_code) );
+                    base_iss.holding_type( hc.type );
+                }
+    
+                // if we're predicting without a preexisting holding, reduce the count
+                if (!lastItem) hc.count--;
+    
+                return egCore.net.request(
+                    'open-ils.serial',
+                    'open-ils.serial.make_predictions',
+                    egCore.auth.token(),
+                    { ssub_id : mySsubId,
+                      include_base_issuance : lastItem ? 0 : 1,
+                      num_to_predict : hc.count,
+                      base_issuance : base_iss || lastItem
+                    }
+                ).then(
+                    function(resp) {
+                        var evt = egCore.evt.parse(resp);
+                        if (evt) {
+                            ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                        } else {
+                            ngToast.success(egCore.strings.SERIALS_ISSUANCE_SUCCESS_SAVE);
+                        }
+                    },
+                    function(resp) {
+                        ngToast.danger(egCore.strings.SERIALS_ISSUANCE_FAIL_SAVE);
+                    }
+                );
+            });
+        });
+    }
+
+    return service;
+}]);
+
diff --git a/Open-ILS/web/js/ui/default/staff/services/mfhd.js b/Open-ILS/web/js/ui/default/staff/services/mfhd.js
new file mode 100644
index 0000000..488b7cf
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/mfhd.js
@@ -0,0 +1,41 @@
+/**
+  * MFHD tools and directives.
+  */
+angular.module('egMfhdMod', ['egCoreMod', 'ui.bootstrap'])
+
+.factory('egMfhdCreateDialog',
+       ['$uibModal','egCore',
+function($uibModal , egCore) {
+    var service = {};
+
+    service.open = function(bibId, orgId) {
+        return $uibModal.open({
+            templateUrl: './share/t_mfhd_create_dialog',
+            controller: ['$scope', '$uibModalInstance',
+                function($scope, $uibModalInstance) {
+                    $scope.mfhd_lib = orgId ?
+                        egCore.org.get(orgId) :
+                        null;
+                    $scope.ok = function() {
+                        egCore.net.request(
+                            'open-ils.cat',
+                            'open-ils.cat.serial.record.xml.create',
+                            egCore.auth.token(),
+                            1, // source
+                            $scope.mfhd_lib.id(),
+                            bibId
+                        ).then(function() {
+                            $uibModalInstance.close()
+                        });
+                    }
+                    $scope.cancel = function() {
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}
+])
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
index aa95d16..51aac05 100644
--- a/Open-ILS/web/js/ui/default/staff/services/ui.js
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -592,17 +592,19 @@ function($window , egStrings) {
         scope: {
             list: "=", // list of strings
             selected: "=",
+            onSelect: "=",
             egDisabled: "=",
             allowAll: "@",
+            placeholder: "@",
             focusMe: "=?"
         },
         template:
             '<div class="input-group">'+
-                '<input type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
+                '<input placeholder="{{placeholder}}" type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
                 '<div class="input-group-btn" dropdown ng-class="{open:isopen}">'+
-                    '<button type="button" ng-click="showAll()" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
+                    '<button type="button" ng-click="showAll()" ng-disabled="egDisabled" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
                     '<ul class="dropdown-menu dropdown-menu-right">'+
-                        '<li ng-repeat="item in list|filter:selected"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
+                        '<li ng-repeat="item in list|filter:selected:compare"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
                         '<li ng-if="complete_list" class="divider"><span></span></li>'+
                         '<li ng-if="complete_list" ng-repeat="item in list"><a href ng-click="changeValue(item)">{{item}}</a></li>'+
                     '</ul>'+
@@ -616,6 +618,12 @@ function($window , egStrings) {
                 $scope.clickedopen = false;
                 $scope.clickedclosed = null;
 
+                $scope.compare = function (ex, act) {
+                    if (act === null || act === undefined) return true;
+                    if (act.toString) act = act.toString();
+                    return new RegExp(act.toLowerCase()).test(ex)
+                }
+
                 $scope.showAll = function () {
 
                     $scope.clickedopen = !$scope.clickedopen;
@@ -628,8 +636,8 @@ function($window , egStrings) {
                         $scope.clickedclosed = !$scope.clickedopen;
                     }
 
-                    if ($scope.selected.length > 0) $scope.complete_list = true;
-                    if ($scope.selected.length == 0) $scope.complete_list = false;
+                    if ($scope.selected && $scope.selected.length > 0) $scope.complete_list = true;
+                    if (!$scope.selected || $scope.selected.length == 0) $scope.complete_list = false;
                     $scope.makeOpen();
                 }
 
@@ -638,7 +646,10 @@ function($window , egStrings) {
                         $scope.list,
                         $scope.selected
                     ).length > 0 && $scope.selected.length > 0);
-                    if ($scope.clickedclosed) $scope.isopen = false;
+                    if ($scope.clickedclosed) {
+                        $scope.isopen = false;
+                        $scope.clickedclosed = null;
+                    }
                 }
 
                 $scope.changeValue = function (newVal) {
@@ -647,6 +658,7 @@ function($window , egStrings) {
                     $scope.clickedclosed = null;
                     $scope.clickedopen = false;
                     if ($scope.selected.length == 0) $scope.complete_list = false;
+                    if ($scope.onSelect) $scope.onSelect();
                 }
 
             }

commit 9d4ce86fff2b2c1d3ace88d5379e68de06b71d1a
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Jun 29 17:09:54 2017 -0400

    LP#1708291: introduce egI18N
    
    egI18N is a module that will serve as a grab-bag of functions
    related to I18N and L10N. The initial function it provides
    takes a acpl IDL object and returns a formatted name qualified
    by the org unit, with the underlying template accessible
    to the translation subsystem.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    
    Conflicts:
    	Open-ILS/src/templates/staff/base_js.tt2
    	Open-ILS/web/js/ui/default/staff/Gruntfile.js
    
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/base_js.tt2 b/Open-ILS/src/templates/staff/base_js.tt2
index 2474245..5d93fbe 100644
--- a/Open-ILS/src/templates/staff/base_js.tt2
+++ b/Open-ILS/src/templates/staff/base_js.tt2
@@ -123,6 +123,7 @@ UpUp.start({
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/user.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/navbar.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/ui.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/i18n.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/date.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/op_change.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/lovefield.js"></script>
@@ -184,6 +185,7 @@ UpUp.start({
     s.ITEM_NOT_FOUND = "[% l('Item not found') %]";
     s.CONFIRM_CLEAR_PENDING = "[% l('Clear pending transactions') %]";
     s.CONFIRM_CLEAR_PENDING_BODY = "[% l('Are you certain you want to clear these pending offline transactions? This action is irreversible. Transactions cannot be recovered after clearing!') %]";
+    s.LOCATION_NAME_OU_QUALIFIED = "[% l('{{location_name}} ({{owning_lib_shortname}})') %]";
   }]);
 </script>
 
diff --git a/Open-ILS/web/js/ui/default/staff/Gruntfile.js b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
index d8a05e1..13ae1da 100644
--- a/Open-ILS/web/js/ui/default/staff/Gruntfile.js
+++ b/Open-ILS/web/js/ui/default/staff/Gruntfile.js
@@ -183,7 +183,8 @@ module.exports = function(grunt) {
             'services/ui.js',
             'services/date.js',
             'services/op_change.js',
-            'services/file.js'
+            'services/file.js',
+            'services/i18n.js'
         ],
         dest: 'build/js/<%= pkg.name %>.<%= pkg.version %>.min.js'
       },
diff --git a/Open-ILS/web/js/ui/default/staff/services/coresvc.js b/Open-ILS/web/js/ui/default/staff/services/coresvc.js
index f754770..c1218cc 100644
--- a/Open-ILS/web/js/ui/default/staff/services/coresvc.js
+++ b/Open-ILS/web/js/ui/default/staff/services/coresvc.js
@@ -10,10 +10,10 @@ angular.module('egCoreMod')
 .factory('egCore', 
        ['egIDL','egNet','egEnv','egOrg','egPCRUD','egEvent','egAuth',
         'egPerm','egHatch','egPrint','egStartup','egStrings','egAudio',
-        'egDate',
+        'egDate','egI18N',
 function(egIDL , egNet , egEnv , egOrg , egPCRUD , egEvent , egAuth ,
          egPerm , egHatch , egPrint , egStartup , egStrings , egAudio , 
-         egDate) {
+         egDate , egI18N) {
 
     return {
         idl     : egIDL,
@@ -29,7 +29,8 @@ function(egIDL , egNet , egEnv , egOrg , egPCRUD , egEvent , egAuth ,
         startup : egStartup,
         strings : egStrings,
         audio   : egAudio,
-        date    : egDate
+        date    : egDate,
+        i18n    : egI18N
     };
 
 }]);
diff --git a/Open-ILS/web/js/ui/default/staff/services/i18n.js b/Open-ILS/web/js/ui/default/staff/services/i18n.js
new file mode 100644
index 0000000..2c521d0
--- /dev/null
+++ b/Open-ILS/web/js/ui/default/staff/services/i18n.js
@@ -0,0 +1,22 @@
+/**
+ * egI18N : service for I18N and L10N functions
+ *
+ * This is a grab-bag of stuff related to I18N.
+ *
+ */
+
+angular.module('egCoreMod')
+.factory('egI18N', ['egStrings',
+            function(egStrings) {
+    return {
+        ou_qualified_location_name : function(loc) {
+            return egStrings.$replace(
+                egStrings.LOCATION_NAME_OU_QUALIFIED,
+                {
+                    location_name : loc.name(),
+                    owning_lib_shortname : loc.owning_lib().shortname()
+                }
+            );
+        }        
+    }
+}]);
diff --git a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
index 0b30d4a..12cc87e 100644
--- a/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
+++ b/Open-ILS/web/js/ui/default/staff/test/karma.conf.js
@@ -50,6 +50,7 @@ module.exports = function(config){
       'services/lovefield.js',
       'services/navbar.js', 'services/date.js',
       'services/user-bucket.js',
+      'services/i18n.js',
       // load app scripts
       'app.js',
       'circ/**/*.js',

commit e9e5e9a7f4d9f85da84f01a8bc3867ed83856cbb
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Jul 6 17:14:15 2017 -0400

    LP#1708291: teach egBasicComboBox and egDatePicker to accept focusMe
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    
    Conflicts:
    	Open-ILS/web/js/ui/default/staff/services/ui.js
    
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/share/t_datetime.tt2 b/Open-ILS/src/templates/staff/share/t_datetime.tt2
index 6359212..492fa1c 100644
--- a/Open-ILS/src/templates/staff/share/t_datetime.tt2
+++ b/Open-ILS/src/templates/staff/share/t_datetime.tt2
@@ -14,6 +14,7 @@
         ng-blur="ngBlur"
         ng-disabled="ngDisabled"
         ng-required="ngRequired"
+        focus-me="focusMe"
         close-text="{{closeText}}"/>
       <span class="input-group-btn">
         <button type="button" class="btn btn-default"
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
index 6513459..aa95d16 100644
--- a/Open-ILS/web/js/ui/default/staff/services/ui.js
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -594,10 +594,11 @@ function($window , egStrings) {
             selected: "=",
             egDisabled: "=",
             allowAll: "@",
+            focusMe: "=?"
         },
         template:
             '<div class="input-group">'+
-                '<input type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()">'+
+                '<input type="text" ng-disabled="egDisabled" class="form-control" ng-model="selected" ng-change="makeOpen()" focus-me="focusMe">'+
                 '<div class="input-group-btn" dropdown ng-class="{open:isopen}">'+
                     '<button type="button" ng-click="showAll()" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
                     '<ul class="dropdown-menu dropdown-menu-right">'+
@@ -852,7 +853,8 @@ function($window , egStrings) {
                 ngRequired : '=',
                 hideDatePicker : '=',
                 dateFormat : '=?',
-                outOfRange : '=?'
+                outOfRange : '=?',
+                focusMe : '=?'
             },
             require: 'ngModel',
             templateUrl: './share/t_datetime',

commit f1fe22bc80f200157fe4a2f89d957a95e570282b
Author: Mike Rylander <mrylander at gmail.com>
Date:   Mon Apr 24 12:40:37 2017 -0400

    LP#1152753: make it possible for serial units to be added to copy buckets
    
    This patch replaces the baseline asset.copy.id fkey constraints with ones
    that understand inheritance, and change all existing contstraints to do the
    same via upgrade script.
    
    To test
    -------
    Create some serial units and verify that they can be added to a copy
    bucket.
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/sql/Pg/070.schema.container.sql b/Open-ILS/src/sql/Pg/070.schema.container.sql
index 32dfc6a..b3a3056 100644
--- a/Open-ILS/src/sql/Pg/070.schema.container.sql
+++ b/Open-ILS/src/sql/Pg/070.schema.container.sql
@@ -56,17 +56,29 @@ CREATE TABLE container.copy_bucket_item (
 					ON UPDATE CASCADE
 					DEFERRABLE
 					INITIALLY DEFERRED,
-	target_copy	INT	NOT NULL
-				REFERENCES asset."copy" (id)
-					ON DELETE CASCADE
-					ON UPDATE CASCADE
-					DEFERRABLE
-					INITIALLY DEFERRED,
+	target_copy	INT	NOT NULL,
     pos         INT,
 	create_time	TIMESTAMP WITH TIME ZONE	NOT NULL DEFAULT NOW()
 );
 CREATE INDEX copy_bucket_item_bucket_idx ON container.copy_bucket_item (bucket);
 
+CREATE OR REPLACE FUNCTION evergreen.container_copy_bucket_item_target_copy_inh_fkey() RETURNS TRIGGER AS $f$
+BEGIN
+        PERFORM 1 FROM asset.copy WHERE id = NEW.target_copy;
+        IF NOT FOUND THEN
+                RAISE foreign_key_violation USING MESSAGE = FORMAT(
+                        $$Referenced asset.copy id not found, target_copy:%s$$, NEW.target_copy
+                );
+        END IF;
+        RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL VOLATILE COST 50;
+
+CREATE CONSTRAINT TRIGGER inherit_copy_bucket_item_target_copy_fkey
+        AFTER UPDATE OR INSERT OR DELETE ON container.copy_bucket_item
+        DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE evergreen.container_copy_bucket_item_target_copy_inh_fkey();
+
+
 CREATE TABLE container.copy_bucket_item_note (
     id      SERIAL      PRIMARY KEY,
     item    INT         NOT NULL REFERENCES container.copy_bucket_item (id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
diff --git a/Open-ILS/src/sql/Pg/800.fkeys.sql b/Open-ILS/src/sql/Pg/800.fkeys.sql
index 7d10125..8ae7d26 100644
--- a/Open-ILS/src/sql/Pg/800.fkeys.sql
+++ b/Open-ILS/src/sql/Pg/800.fkeys.sql
@@ -108,10 +108,40 @@ ALTER TABLE serial.unit ADD CONSTRAINT serial_unit_call_number_fkey FOREIGN KEY
 ALTER TABLE serial.unit ADD CONSTRAINT serial_unit_creator_fkey FOREIGN KEY (creator) REFERENCES actor.usr (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
 ALTER TABLE serial.unit ADD CONSTRAINT serial_unit_editor_fkey FOREIGN KEY (editor) REFERENCES actor.usr (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
 
-ALTER TABLE vandelay.import_item ADD CONSTRAINT imported_as_fkey FOREIGN KEY (imported_as) REFERENCES asset.copy (id) DEFERRABLE INITIALLY DEFERRED;
+CREATE OR REPLACE FUNCTION evergreen.vandelay_import_item_imported_as_inh_fkey() RETURNS TRIGGER AS $f$
+BEGIN
+        PERFORM 1 FROM asset.copy WHERE id = NEW.imported_as;
+        IF NOT FOUND THEN
+                RAISE foreign_key_violation USING MESSAGE = FORMAT(
+                        $$Referenced asset.copy id not found, imported_as:%s$$, NEW.imported_as
+                );
+        END IF;
+        RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL VOLATILE COST 50;
+
+CREATE CONSTRAINT TRIGGER inherit_import_item_imported_as_fkey
+        AFTER UPDATE OR INSERT OR DELETE ON vandelay.import_item
+        DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE evergreen.vandelay_import_item_imported_as_inh_fkey();
+
 ALTER TABLE vandelay.bib_queue ADD CONSTRAINT match_bucket_fkey FOREIGN KEY (match_bucket) REFERENCES container.biblio_record_entry_bucket(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
 
-ALTER TABLE asset.copy_note ADD CONSTRAINT asset_copy_note_copy_fkey FOREIGN KEY (owning_copy) REFERENCES asset.copy (id) DEFERRABLE INITIALLY DEFERRED;
+CREATE OR REPLACE FUNCTION evergreen.asset_copy_note_owning_copy_inh_fkey() RETURNS TRIGGER AS $f$
+BEGIN
+        PERFORM 1 FROM asset.copy WHERE id = NEW.owning_copy;
+        IF NOT FOUND THEN
+                RAISE foreign_key_violation USING MESSAGE = FORMAT(
+                        $$Referenced asset.copy id not found, owning_copy:%s$$, NEW.owning_copy
+                );
+        END IF;
+        RETURN NEW;
+END;
+$f$ LANGUAGE PLPGSQL VOLATILE COST 50;
+
+CREATE CONSTRAINT TRIGGER inherit_asset_copy_note_copy_fkey
+        AFTER UPDATE OR INSERT OR DELETE ON asset.copy_note
+        DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE evergreen.asset_copy_note_owning_copy_inh_fkey();
+
 ALTER TABLE asset.copy_note ADD CONSTRAINT asset_copy_note_creator_fkey FOREIGN KEY (creator) REFERENCES actor.usr (id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED;
 
 ALTER TABLE asset.call_number ADD CONSTRAINT asset_call_number_owning_lib_fkey FOREIGN KEY (owning_lib) REFERENCES actor.org_unit (id) DEFERRABLE INITIALLY DEFERRED;
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.inheritance-constraint-trigger.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.inheritance-constraint-trigger.sql
new file mode 100644
index 0000000..94f4000
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.inheritance-constraint-trigger.sql
@@ -0,0 +1,44 @@
+BEGIN;
+
+DO $temp$
+DECLARE
+	r RECORD;
+BEGIN
+
+	FOR r IN SELECT	t.table_schema AS sname,
+			t.table_name AS tname,
+			t.column_name AS colname,
+			t.constraint_name
+		  FROM	information_schema.referential_constraints ref
+			JOIN information_schema.key_column_usage t USING (constraint_schema,constraint_name)
+		  WHERE	ref.unique_constraint_schema = 'asset'
+			AND ref.unique_constraint_name = 'copy_pkey'
+	LOOP
+
+		EXECUTE 'ALTER TABLE '||r.sname||'.'||r.tname||' DROP CONSTRAINT '||r.constraint_name||';';
+
+		EXECUTE '
+			CREATE OR REPLACE FUNCTION evergreen.'||r.sname||'_'||r.tname||'_'||r.colname||'_inh_fkey() RETURNS TRIGGER AS $f$
+			BEGIN
+				PERFORM 1 FROM asset.copy WHERE id = NEW.'||r.colname||';
+				IF NOT FOUND THEN
+					RAISE foreign_key_violation USING MESSAGE = FORMAT(
+						$$Referenced asset.copy id not found, '||r.colname||':%s$$, NEW.'||r.colname||'
+					);
+				END IF;
+				RETURN NEW;
+			END;
+			$f$ LANGUAGE PLPGSQL VOLATILE COST 50;
+		';
+
+		EXECUTE '
+			CREATE CONSTRAINT TRIGGER inherit_'||r.constraint_name||'
+				AFTER UPDATE OR INSERT OR DELETE ON '||r.sname||'.'||r.tname||'
+				DEFERRABLE FOR EACH ROW EXECUTE PROCEDURE evergreen.'||r.sname||'_'||r.tname||'_'||r.colname||'_inh_fkey();
+		';
+	END LOOP;
+END
+$temp$;
+
+COMMIT;
+

commit bad8ab7907b9f32e0c91b027da8258df49880b9f
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue Jun 20 18:10:46 2017 -0400

    LP#1708291: add an egHelpPopover directive
    
    This directive allows adding help text accessible by clicking
    on a question mark icon.  Example usage:
    
    <eg-help-popover help-text="Use the Force, Leia!">
    
    to create simple popover or
    
    <eg-help-popover help-text="Learn MARC21"
                     help-link="https://www.loc.gov/marc"
    >
    
    to have the help text hyperlinked.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/share/t_help_popover.tt2 b/Open-ILS/src/templates/staff/share/t_help_popover.tt2
new file mode 100644
index 0000000..215400a
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_help_popover.tt2
@@ -0,0 +1,10 @@
+<span class="glyphicon glyphicon-question-sign"
+      uib-popover="{{helpText}}"
+      popover-trigger="click"
+      ng-if="!helpLink || helpLink.length == 0"
+></span>
+<span class="glyphicon glyphicon-question-sign"
+      uib-popover-html="helpHtml"
+      popover-trigger="click"
+      ng-if="helpLink.length > 0"
+></span>
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
index 10766ff..6513459 100644
--- a/Open-ILS/web/js/ui/default/staff/services/ui.js
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -1024,6 +1024,29 @@ function($window , egStrings) {
     }
 })
 
+/*
+ * egHelpPopover - a helpful widget
+ */
+.directive('egHelpPopover', function() {
+    return {
+        restrict : 'E',
+        transclude : true,
+        scope : {
+            helpText : '@',
+            helpLink : '@'
+        },
+        templateUrl : './share/t_help_popover',
+        controller : ['$scope','$sce', function($scope , $sce) {
+            if ($scope.helpLink) {
+                $scope.helpHtml = $sce.trustAsHtml(
+                    '<a target="_new" href="' + $scope.helpLink + '">' +
+                    $scope.helpText + '</a>'
+                );
+            }
+        }]
+    }
+})
+
 .factory('egWorkLog', ['egCore', function(egCore) {
     var service = {};
 

commit e0a0375f09ee27835faa7967364447b8695d7d77
Author: Jason Etheridge <jason at equinoxinitiative.org>
Date:   Tue May 30 11:51:51 2017 -0400

    LP#1708291: add a join filter for angular templates
    
    Signed-off-by: Jason Etheridge <jason at equinoxinitiative.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
index ff7cfd8..10766ff 100644
--- a/Open-ILS/web/js/ui/default/staff/services/ui.js
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -223,6 +223,18 @@ function($timeout , $parse) {
     return eg_context_due_date_filter;
 }])
 
+// 'join' filter
+// TODO: perhaps this should live elsewhere
+.filter('join', function() {
+    return function(arr,sep) {
+        if (typeof arr == 'object' && arr.constructor == Array) {
+            return arr.join(sep || ',');
+        } else {
+            return '';
+        }
+    };
+})
+
 /**
  * Progress Dialog. 
  *

commit b854319e3b3fa55204ba050ebe45c694053cde25
Author: Mike Rylander <mrylander at gmail.com>
Date:   Wed May 24 20:37:17 2017 -0400

    LP#1708291: teach egEmbedFrame about an afterload function
    
    The 'afterload' attribute added by this patch allows specifying
    the name of a function (to be supplied by the embedded content)
    to run after the frame has been loaded.
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/services/eframe.js b/Open-ILS/web/js/ui/default/staff/services/eframe.js
index 3beaa7c..ae26e7f 100644
--- a/Open-ILS/web/js/ui/default/staff/services/eframe.js
+++ b/Open-ILS/web/js/ui/default/staff/services/eframe.js
@@ -19,6 +19,9 @@ angular.module('egCoreMod')
             // called after onload of each new iframe page
             onchange : '=?',
 
+            // called after egFrameEmbedLoader, during link phase
+            afterload : '@',
+
             // for tweaking height
             saveSpace : '@',
             minHeight : '=?',
@@ -39,7 +42,10 @@ angular.module('egCoreMod')
             window.IEMBEDXUL = true;
             element.find('iframe').on(
                 'load',
-                function() {scope.egEmbedFrameLoader(this)}
+                function() {
+                    scope.egEmbedFrameLoader(this);
+                    if (scope.afterload) this.contentWindow[scope.afterload]();
+                }
             );
         },
 

commit 04f882160973f3a696201d27ca55885abc894e02
Author: Jason Etheridge <jason at equinoxinitiative.org>
Date:   Mon May 22 17:28:17 2017 -0400

    LP#1708291: add API for safe deleting various serial records
    
    This adds routines for safely deleting subscriptions,
    distributions and streams.
    
    open-ils.serial.subscription.safe_delete
    open-ils.serial.distribution.safe_delete
    open-ils.serial.stream.safe_delete
    open-ils.serial.subscription.safe_delete.dry_run
    open-ils.serial.distribution.safe_delete.dry_run
    open-ils.serial.stream.safe_delete.dry_run
    
    Won't delete if there are attached serial.item records with a status other
    than Expected, or if there are any attached non-deleted serial.unit
    records.
    
    The dry_run variants behave exactly the same except they don't actually
    delete anything.
    
    Signed-off-by: Jason Etheridge <jason at equinoxinitiative.org>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/extras/ils_events.xml b/Open-ILS/src/extras/ils_events.xml
index fcc2f50..570e19b 100644
--- a/Open-ILS/src/extras/ils_events.xml
+++ b/Open-ILS/src/extras/ils_events.xml
@@ -1048,6 +1048,12 @@
         <desc xml:lang="en-US">Renewal attempt failed because the "hold / available copies" ratio exceeds the configured limit</desc>
     </event>
 
+    <event code='11008' textcode='SERIAL_DISTRIBUTION_NOT_EMPTY'>
+        <desc xml:lang="en-US">The distribution still has dependent objects</desc>
+    </event>
+    <event code='11009' textcode='SERIAL_STREAM_NOT_EMPTY'>
+        <desc xml:lang="en-US">The stream still has dependent objects</desc>
+    </event>
 </ils_events>
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
index 50113eb..071922c 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
@@ -2356,6 +2356,164 @@ sub delete_note {
 ##########################################################################
 # subscription methods
 #
+
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.subscription.safe_delete',
+    signature   => q/
+        Deletes an existing subscription and related records
+        (distributions, streams, etc.), but only if there are no serial
+        items with a status other than Expected, and no non-deleted 
+        serial units.
+        @param authtoken The login session key
+        @param subid The id of the subscription to delete
+        @return 1 on success - Event otherwise.
+        /
+);
+
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.distribution.safe_delete',
+    signature   => q/
+        Deletes an existing distribution and related records
+        (streams, etc.), but only if there are no attached serial items
+        with a status other than Expected, and no non-deleted serial
+        units.
+        @param authtoken The login session key
+        @param subid The id of the distribution to delete
+        @return 1 on success - Event otherwise.
+        /
+);
+
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.stream.safe_delete',
+    signature   => q/
+        Deletes an existing stream and associated routing list, but only
+        if there are no attached serial items with a status other than
+        Expected, and no non-deleted serial units.
+        items and no issuances.
+        @param authtoken The login session key
+        @param strid The id of the stream 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(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.distribution.safe_delete.dry_run',
+);
+__PACKAGE__->register_method(
+    method      => 'safe_delete',
+    api_name        =>  'open-ils.serial.stream.safe_delete.dry_run',
+);
+
+sub safe_delete {
+    my( $self, $conn, $authtoken, $id ) = @_;
+
+    $self->api_name =~ /serial\.(\w*)\.safe_delete/;
+    my $type = $1;
+
+    my $e = new_editor(xact=>1, authtoken=>$authtoken);
+    return $e->die_event unless $e->checkauth;
+
+    my $obj;
+
+    if ($type eq 'stream') {
+        my $sstr = $e->retrieve_serial_stream([
+            $id, {
+                "flesh" => 2, "flesh_fields" => {
+                    "sstr" => ["items","distribution"],
+                    "sitem" => ["unit"]
+                }
+            }
+        ]) or return $e->die_event;
+
+        return $e->die_event unless $e->allowed(
+            "ADMIN_SERIAL_STREAM", $sstr->distribution->holding_lib
+        );
+
+        foreach my $sitem (@{$sstr->items}) {
+            if ($sitem->status ne 'Expected') {
+                return 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);
+            }
+        }
+
+        $obj = $sstr;
+
+    } elsif ($type eq 'distribution') {
+        my $sdist = $e->retrieve_serial_distribution([
+            $id, {
+                "flesh" => 3, "flesh_fields" => {
+                    "sstr" => ["items"],
+                    "sdist" => ["streams"],
+                    "sitem" => ["unit"]
+                }
+            }
+        ]) or return $e->die_event;
+
+        return $e->die_event unless
+            $e->allowed("ADMIN_SERIAL_DISTRIBUTION", $sdist->holding_lib);
+
+        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);
+                }
+                if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
+                    return OpenILS::Event->new('SERIAL_DISTRIBUTION_NOT_EMPTY', payload=>$id);
+                }
+            }
+        }
+
+        $obj = $sdist;
+
+    } else { # subscription
+        my $sub = $e->retrieve_serial_subscription([
+            $id, {
+                "flesh" => 4, "flesh_fields" => {
+                    "ssub" => [qw/distributions issuances/],
+                    "sdist" => [qw/streams/],
+                    "sstr" => ["items"],
+                    "sitem" => ["unit"]
+                }
+            }
+        ]) or return $e->die_event;
+
+        return $e->die_event unless
+            $e->allowed("ADMIN_SERIAL_SUBSCRIPTION", $sub->owning_lib);
+
+        foreach my $sdist (@{$sub->distributions}) {
+            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);
+                    }
+                    if ($sitem->unit && !$U->is_true($sitem->unit->deleted)) {
+                        return OpenILS::Event->new('SERIAL_SUBSCRIPTION_NOT_EMPTY', payload=>$id);
+                    }
+                }
+            }
+        }
+
+        $obj = $sub;
+    }
+
+    if (! ($self->api_name =~ /dry_run/)) {
+        my $method = "delete_serial_${type}";
+        $e->$method($obj) or return $e->die_event;
+        $e->commit;
+    }
+    return 1;
+}
+
 __PACKAGE__->register_method(
     method    => 'fleshed_ssub_alter',
     api_name  => 'open-ils.serial.subscription.fleshed.batch.update',

commit 5311a1755f5d33262de1b0ae0571561050799e71
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue May 23 17:59:18 2017 -0400

    LP#1708291: teach MARC editor that it can edit MFHDs/SREs
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/cat/services/marcedit.js b/Open-ILS/web/js/ui/default/staff/cat/services/marcedit.js
index fb3a05a..8717af4 100644
--- a/Open-ILS/web/js/ui/default/staff/cat/services/marcedit.js
+++ b/Open-ILS/web/js/ui/default/staff/cat/services/marcedit.js
@@ -698,6 +698,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                 $scope.$watch('stackSubfields.enabled', function (newVal, oldVal) {
                     if (newVal != oldVal) egCore.hatch.setItem('cat.marcedit.stack_subfields', newVal);
                 });
+                $scope.caretRecId = $scope.recordId;
 
                 egTagTable.loadTagTable({ marcRecordType : $scope.record_type });
 
@@ -736,7 +737,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                         new_field_index++;
                     }
 
-                    $scope.current_event_target = 'r' + $scope.recordId +
+                    $scope.current_event_target = 'r' + $scope.caretRecId +
                                                   'f' + new_field_index + 'tag';
 
                     $scope.current_event_target_cursor_pos = 0;
@@ -760,7 +761,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                     domnode.scope().$destroy();
                     domnode.remove();
 
-                    $scope.current_event_target = 'r' + $scope.recordId +
+                    $scope.current_event_target = 'r' + $scope.caretRecId +
                                                   'f' + del_field + 'tag';
 
                     $scope.current_event_target_cursor_pos = 0;
@@ -864,7 +865,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                             new_sf = index_sf;
                         }
 
-                        $scope.current_event_target = 'r' + $scope.recordId +
+                        $scope.current_event_target = 'r' + $scope.caretRecId +
                                                       'f' + event.data.scope.field.position + 
                                                       's' + new_sf + 'code';
 
@@ -915,11 +916,11 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                         );
 
                         if (!event.data.scope.field.subfields[sf]) {
-                            $scope.current_event_target = 'r' + $scope.recordId +
+                            $scope.current_event_target = 'r' + $scope.caretRecId +
                                                           'f' + event.data.scope.field.position + 
                                                           'tag';
                         } else {
-                            $scope.current_event_target = 'r' + $scope.recordId +
+                            $scope.current_event_target = 'r' + $scope.caretRecId +
                                                           'f' + event.data.scope.field.position + 
                                                           's' + sf + 'value';
                         }
@@ -961,7 +962,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                                 field_obj
                             );
 
-                            $scope.current_event_target = 'r' + $scope.recordId +
+                            $scope.current_event_target = 'r' + $scope.caretRecId +
                                                           'f' + index_field + 'tag';
 
                             $scope.current_event_target_cursor_pos = 0;
@@ -975,7 +976,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                                 $timeout(function(){
                                     $scope.current_event_target_cursor_pos = 0;
                                     $scope.current_event_target_cursor_pos_end = 0;
-                                    $scope.current_event_target = 'r' + $scope.recordId +
+                                    $scope.current_event_target = 'r' + $scope.caretRecId +
                                                                   'f' + (event.data.scope.field.position - 1) +
                                                                   'tag';
                                 }).then(setCaret);
@@ -1014,7 +1015,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                                 field_obj
                             );
 
-                            $scope.current_event_target = 'r' + $scope.recordId +
+                            $scope.current_event_target = 'r' + $scope.caretRecId +
                                                           'f' + new_field + 'tag';
 
                             $scope.current_event_target_cursor_pos = 0;
@@ -1028,7 +1029,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                                 $timeout(function(){
                                     $scope.current_event_target_cursor_pos = 0;
                                     $scope.current_event_target_cursor_pos_end = 0;
-                                    $scope.current_event_target = 'r' + $scope.recordId +
+                                    $scope.current_event_target = 'r' + $scope.caretRecId +
                                                                   'f' + (event.data.scope.field.position + 1) +
                                                                   'tag';
                                 }).then(setCaret);
@@ -1089,6 +1090,10 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                                 var are = new egCore.idl.are();
                                 are.marc($scope.marcXml);
                                 deferred.resolve(are);
+                            } else if ($scope.recordType == 'sre') {
+                                var sre = new egCore.idl.sre();
+                                sre.marc($scope.marcXml);
+                                deferred.resolve(sre);
                             }
                             $scope.brandNewRecord = true;
                         }
@@ -1097,6 +1102,12 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                         $scope.in_redo = true;
                         $scope[$scope.record_type] = rec;
                         $scope.record = new MARC21.Record({ marcxml : $scope.Record().marc() });
+                        if (!$scope.recordId) {
+                            var sf901c = $scope.record.subfield('901', 'c');
+                            if (sf901c !== null) {
+                                $scope.caretRecId = sf901c[1];
+                            }
+                        }
                         $scope.calculated_record_type = $scope.record.recordType();
                         $scope.controlfields = $scope.record.fields.filter(function(f){ return f.isControlfield() });
                         $scope.datafields = $scope.record.fields.filter(function(f){ return !f.isControlfield() });
@@ -1330,6 +1341,7 @@ angular.module('egMarcMod', ['egCoreMod', 'ui.bootstrap'])
                             $scope.Record()
                         ).then(function(bre) {
                             $scope.recordId = bre.id(); 
+                            $scope.caretRecId = $scope.recordId;
                             if ($scope.enable_fast_add) {
                                 egCore.net.request(
                                     'open-ils.actor',

commit 9e22667fc434193028c826e95d1ec09baa90a1cd
Author: Mike Rylander <mrylander at gmail.com>
Date:   Thu May 18 19:24:45 2017 -0400

    LP#1708291: teach egGrid to always show checkbox menu items
    
    This patch also tweaks some styles.
    
    Signed-off-by: Mike Rylander <mrylander at gmail.com>
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/share/t_autogrid.tt2 b/Open-ILS/src/templates/staff/share/t_autogrid.tt2
index 0942d1c..1f5500d 100644
--- a/Open-ILS/src/templates/staff/share/t_autogrid.tt2
+++ b/Open-ILS/src/templates/staff/share/t_autogrid.tt2
@@ -8,13 +8,13 @@
 
   <div class="eg-grid-primary-label">{{mainLabel}}</div>
 
-  <div class="btn-group" 
+  <div class="btn-group" style="margin-top: 4px"
     is-open="gridMenuIsOpen" ng-if="menuLabel && showMenu" uib-dropdown>
     <button type="button" class="btn btn-default eg-grid-menu-item" uib-dropdown-toggle>
       {{menuLabel}}<span class="caret"></span>
     </button>
     <ul class="scrollable-menu" uib-dropdown-menu>
-      <li ng-repeat="item in menuItems | filter : { standalone : 'false' }" ng-class="{divider: item.divider}">
+      <li ng-repeat="item in menuItems | filter : { standalone : 'false' }" ng-if="!item.checkbox && !item.hidden()" ng-class="{divider: item.divider}">
         <a ng-if="!item.divider" href a-disabled="item.disabled()"
           ng-click="item.handler()">{{item.label}}</a>
       </li>
@@ -36,6 +36,11 @@
       ng-click="item.handler(item, item.handlerData)">
         {{item.label}}
     </button>
+  </div>
+
+  <!-- Always show checkbox items as a
+       horizontal row of buttons -->
+  <div class="btn-group" ng-if="showMenu">
     <div ng-if="item.checkbox"
       class="btn btn-default eg-grid-menu-item"
       ng-repeat="item in menuItems">

commit 407554e93dbe2facfaddfb641e04d927961cc9f0
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Wed May 10 17:36:44 2017 -0400

    LP#1708291: improvements to egEditFmRecord
    
    egEditFmRecord now knows how to specify that a custom Angular template
    be used to supply the input widget for a given field; the initial use
    of this will be allowing the prediction pattern template editor to be
    used to set the pattern in a pattern template. The customFieldTemplates
    attribute is used for this purpose.
    
    This patch also teaches egEditFmRecord when to allow an org unit
    selector to default to the workstation OU. The orgDefaultAllowed
    attribute is used for this purpose.
    
    Finally, a fixes a bug that ensures that the Save button is active
    only when the entire form is valid.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2 b/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
index f2328a1..b34fb4c 100644
--- a/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
+++ b/Open-ILS/src/templates/staff/share/t_fm_record_editor.tt2
@@ -1,4 +1,4 @@
-<form role="form" class="form-validated eg-edit-fm-record">
+<form role="form" class="form-validated eg-edit-fm-record" name="fm_record_form">
 
   <div class="modal-header">
     <button type="button" class="close"
@@ -11,58 +11,67 @@
         <label for="rec-{{field.name}}">{{field.label}}</label>
       </div>
       <div class="col-md-9">
-        <span  ng-if="field.datatype == 'id' && !id_is_editable">{{rec[field.name]()}}</span>
-        <input ng-if="field.datatype == 'id' &&  id_is_editable"
-          ng-readonly="field.readonly"
-          ng-required="field.is_required()"
-          ng-model="rec[field.name]"
-          ng-model-options="{ getterSetter : true }">
-        </input>
-        <input ng-if="field.datatype == 'text'"
-          ng-readonly="field.readonly"
-          ng-required="field.is_required()"
-          ng-model="rec[field.name]"
-          ng-model-options="{ getterSetter : true }">
-        </input>
-        <input ng-if="field.datatype == 'int'"
-          type="number"
-          ng-readonly="field.readonly"
-          ng-required="field.is_required()"
-          ng-model="rec[field.name]"
-          ng-model-options="{ getterSetter : true }">
-        </input>
-        <input ng-if="field.datatype == 'float'"
-          type="number" step="0.1"
-          ng-readonly="field.readonly"
-          ng-required="field.is_required()"
-          ng-model="rec[field.name]"
-          ng-model-options="{ getterSetter : true }">
-        </input>
-        <input ng-if="field.datatype == 'bool'"
-          type="checkbox"
-          ng-readonly="field.readonly"
-          ng-model="rec[field.name]"
-          ng-model-options="{ getterSetter : true }">
-        </input>
-        <span ng-if="field.datatype == 'link'"
-          ng-class="{nullable : !field.is_required()}">
-          <select ng-if="field.datatype == 'link'"
+        <span  ng-if="field.use_custom_template">
+          <eg-fm-custom-field-input template="field.custom_template" handlers="field.handlers">
+        </span>
+        <span  ng-if="!field.use_custom_template">
+          <span  ng-if="field.datatype == 'id' && !id_is_editable">{{rec[field.name]()}}</span>
+          <input ng-if="field.datatype == 'id' &&  id_is_editable"
+            ng-readonly="field.readonly"
+            ng-required="field.is_required()"
+            ng-model="rec[field.name]"
+            ng-model-options="{ getterSetter : true }">
+          </input>
+          <input ng-if="field.datatype == 'text'"
+            ng-readonly="field.readonly"
+            ng-required="field.is_required()"
+            ng-model="rec[field.name]"
+            ng-model-options="{ getterSetter : true }">
+          </input>
+          <input ng-if="field.datatype == 'int'"
+            type="number"
+            ng-readonly="field.readonly"
+            ng-required="field.is_required()"
+            ng-model="rec[field.name]"
+            ng-model-options="{ getterSetter : true }">
+          </input>
+          <input ng-if="field.datatype == 'float'"
+            type="number" step="0.1"
             ng-readonly="field.readonly"
             ng-required="field.is_required()"
-            ng-options="item.id as item.name for item in field.linked_values"
             ng-model="rec[field.name]"
             ng-model-options="{ getterSetter : true }">
-          </select>
+          </input>
+          <input ng-if="field.datatype == 'bool'"
+            type="checkbox"
+            ng-readonly="field.readonly"
+            ng-model="rec[field.name]"
+            ng-model-options="{ getterSetter : true }">
+          </input>
+          <span ng-if="field.datatype == 'link'"
+            ng-class="{nullable : !field.is_required()}">
+            <select ng-if="field.datatype == 'link'"
+              ng-readonly="field.readonly"
+              ng-required="field.is_required()"
+              ng-options="item.id as item.name for item in field.linked_values"
+              ng-model="rec[field.name]"
+              ng-model-options="{ getterSetter : true }">
+            </select>
+          </span>
+          <eg-org-selector ng-if="field.datatype == 'org_unit' && !field.org_default_allowed"
+            selected="rec_org_values[field.name]"
+            onchange="rec_orgs[field.name]" nodefault>
+          </eg-org-selector>
+          <eg-org-selector ng-if="field.datatype == 'org_unit' && field.org_default_allowed"
+            selected="rec_org_values[field.name]"
+            onchange="rec_orgs[field.name]">
+          </eg-org-selector>
         </span>
-        <eg-org-selector ng-if="field.datatype == 'org_unit'"
-          selected="rec_org_values[field.name]"
-          onchange="rec_orgs[field.name]" nodefault>
-        </eg-org-selector>
       </div>
     </div>
   </div>
   <div class="modal-footer">
-    <button class="btn btn-primary" ng-click="ok()">[% l('Save') %]</button>
+    <button class="btn btn-primary" type="submit" ng-disabled="fm_record_form.$invalid" ng-click="ok()">[% l('Save') %]</button>
     <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
   </div>
 </form>
diff --git a/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js b/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
index 8157f18..2e08c07 100644
--- a/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
+++ b/Open-ILS/web/js/ui/default/staff/services/fm_record_editor.js
@@ -16,6 +16,14 @@ angular.module('egFmRecordEditorMod',
             // record ID to update
             recordId : '=',
 
+            // fields with custom templates
+            // hash keyed on field name; may contain
+            //   template - Angular template; should access
+            //              field value using rec_flat[field.name]
+            //   handlers - any functions you want to pass
+            //              in to the custom template
+            customFieldTemplates : '=',
+
             // comma-separated list of fields that should not be
             // displayed
             hiddenFields : '@',
@@ -28,6 +36,10 @@ angular.module('egFmRecordEditorMod',
             // supplements what the IDL considers required
             requiredFields : '@',
 
+            // comma-separated list of org_unit fields where
+            // the selector should default to the workstation OU
+            orgDefaultAllowed : '@',
+
             // hash, keyed by field name, of functions to invoke
             // to check whether a field is required.  Each
             // callback is passed the field name and the record
@@ -71,9 +83,11 @@ angular.module('egFmRecordEditorMod',
             $scope.required = list_to_hash($scope.requiredFields);
             $scope.readonly = list_to_hash($scope.readonlyFields);
             $scope.hidden = list_to_hash($scope.hiddenFields);
+            $scope.org_default_allowed = list_to_hash($scope.orgDefaultAllowed);
 
             $scope.record_label = egCore.idl.classes[$scope.idlClass].label;
             $scope.rec_orgs = {};
+            $scope.rec_flat = {};
             $scope.rec_org_values = {};
             $scope.id_is_editable = false;
 
@@ -114,6 +128,12 @@ angular.module('egFmRecordEditorMod',
                             rec[field.name]('f');
                         }
                     }
+                    // retrieve values from any fields controlled
+                    // by custom templates, which for the moment all
+                    // expect to be passed an ordinary flat value
+                    if (field.name in $scope.rec_flat) {
+                        rec[field.name]($scope.rec_flat[field.name]);
+                    }
                 });
             }
 
@@ -169,6 +189,13 @@ angular.module('egFmRecordEditorMod',
                         if ($scope.rec[field.name]()) {
                             $scope.rec_org_values[field.name] = $scope.rec_orgs[field.name]();
                         }
+                        field.org_default_allowed = (field.name in $scope.org_default_allowed);
+                    }
+                    if (field.name in $scope.customFieldTemplates) {
+                        field.use_custom_template = true;
+                        field.custom_template = $scope.customFieldTemplates[field.name].template;
+                        field.handlers = $scope.customFieldTemplates[field.name].handlers;
+                        $scope.rec_flat[field.name] = $scope.rec[field.name]();
                     }
                 });
                 return fields.filter(function(field) { return !(field.name in $scope.hidden) });
@@ -193,3 +220,17 @@ angular.module('egFmRecordEditorMod',
         }]
     };
 })
+
+.directive('egFmCustomFieldInput', function($compile) {
+    return {
+        restrict : 'E',
+        scope : {
+            template : '=',
+            handlers : '='
+        },
+        link : function(scope, element, attrs) {
+            element.html(scope.template);
+            $compile(element.contents())(scope.$parent);
+        }
+    };
+})

commit 2690328d76a972208b77ba3ad50c67fce9b436b0
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Tue May 9 14:56:04 2017 -0400

    LP#1708291: add a egShareDepthSelector directive
    
    This directive implements a selector for OU-sharing depths; depths
    and names come from the actor.org_unit_type table. If there are
    multiple types defined for a given depth, the display value in
    the selector is the concatenation of their names.
    
    The initial use of this directive is for specifying how broadly
    prediction pattern templates should be seen.
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/src/templates/staff/share/t_share_depth_selector.tt2 b/Open-ILS/src/templates/staff/share/t_share_depth_selector.tt2
new file mode 100644
index 0000000..2633eac
--- /dev/null
+++ b/Open-ILS/src/templates/staff/share/t_share_depth_selector.tt2
@@ -0,0 +1,4 @@
+<select
+  ng-options="item.id as item.name for item in values"
+  ng-model="ngModel"
+</select>
diff --git a/Open-ILS/web/js/ui/default/staff/services/ui.js b/Open-ILS/web/js/ui/default/staff/services/ui.js
index d1f87a7..ff7cfd8 100644
--- a/Open-ILS/web/js/ui/default/staff/services/ui.js
+++ b/Open-ILS/web/js/ui/default/staff/services/ui.js
@@ -976,6 +976,42 @@ function($window , egStrings) {
     }
 })
 
+/*
+ *  egShareDepthSelector - widget for selecting a share depth
+ */
+.directive('egShareDepthSelector', function() {
+    return {
+        restrict : 'E',
+        transclude : true,
+        scope : {
+            ngModel : '=',
+        },
+        require: 'ngModel',
+        templateUrl : './share/t_share_depth_selector',
+        controller : ['$scope','egCore', function($scope , egCore) {
+            $scope.values = [];
+            egCore.pcrud.search('aout',
+                { id : {'!=' : null} },
+                { order_by : {aout : ['depth', 'name']} },
+                { atomic : true }
+            ).then(function(list) {
+                var scratch = [];
+                angular.forEach(list, function(aout) {
+                    var depth = parseInt(aout.depth());
+                    if (depth in scratch) {
+                        scratch[depth].push(aout.name());
+                    } else {
+                        scratch[depth] = [ aout.name() ]
+                    }
+                });
+                scratch.forEach(function(val, idx) {
+                    $scope.values.push({ id : idx,  name : scratch[idx].join(' / ') });
+                });
+            });
+        }]
+    }
+})
+
 .factory('egWorkLog', ['egCore', function(egCore) {
     var service = {};
 

commit 973f032feb02c24cf201fdb26ec0ac78b18aaa42
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Fri Apr 21 12:05:45 2017 -0400

    LP#1708291: teach egIDL a new fieldmapper object representation
    
    This patch teaches egIDL how to represent fieldmapper objects
    as objects with attributes rather than getter/setters. This
    allows FM objects to be more easily bound to common to Angular
    input widgets, avoiding the verbosity of getterSetter ng-options
    and the fact that some widgets like uib-datepicker-popup don't
    support getterSetter in the first place.
    
    Usage is:
    
      hash = obj.toTypedHash()
    
    and
    
      obj = new egCore.idl.fromTypedHash(hash);
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/services/idl.js b/Open-ILS/web/js/ui/default/staff/services/idl.js
index 29b7989..629e595 100644
--- a/Open-ILS/web/js/ui/default/staff/services/idl.js
+++ b/Open-ILS/web/js/ui/default/staff/services/idl.js
@@ -144,6 +144,67 @@ angular.module('egCoreMod')
         return hash;
     }
 
+    service.toTypedHash = function(obj) {
+        if (!angular.isObject(obj)) return obj; // arrays are objects
+
+        if (angular.isArray(obj)) { // NOTE: flatten arrays not supported
+            return obj.map(function(item) {return service.toTypedHash(item)});
+        }
+
+        var field_names = obj.classname ? 
+            Object.keys(service.classes[obj.classname].field_map) :
+            Object.keys(obj);
+
+        var hash = {};
+        if (obj.classname) {
+            angular.extend(hash, {
+                _classname : obj.classname
+            });
+        }
+        angular.forEach(
+            field_names,
+            function(field) { 
+
+                var val = service.toTypedHash(
+                    angular.isFunction(obj[field]) ? 
+                        obj[field]() : obj[field]
+                );
+
+                if (val !== undefined) {
+                    if (obj.classname) {
+                        switch(service.classes[obj.classname].field_map[field].datatype) {
+                            case 'org_unit' :
+                                // aou fieldmapper objects get used as is because
+                                // that's what egOrgSelector expects
+                                // TODO we should probably make egOrgSelector more flexible
+                                //      in what it can bind to
+                                hash[field] = obj[field]();
+                                break;
+                            case 'timestamp':
+                                hash[field] = (val === null) ? val : new Date(val);
+                                break;
+                            case 'bool':
+                                if (val == 't') {
+                                    hash[field] = true;
+                                } else if (val == 'f') {
+                                    hash[field] = false;
+                                } else {
+                                    hash[field] = null;
+                                }
+                                break;
+                            default:
+                                hash[field] = val;
+                        }
+                    } else {
+                        hash[field] = val;
+                    }
+                }
+            }
+        );
+
+        return hash;
+    }
+
     // returns a simple string key=value string of an IDL object.
     service.toString = function(obj) {
         var s = '';
@@ -173,6 +234,46 @@ angular.module('egCoreMod')
         return new_obj;
     }
 
+    service.fromTypedHash = function(hash) {
+        if (!angular.isObject(hash)) return hash;
+        if (angular.isArray(hash)) {
+            return hash.map(function(item) {return service.fromTypedHash(item)});
+        }
+        if (!hash._classname) return;
+
+        var new_obj = new service[hash._classname];
+        var fields = service.classes[hash._classname].field_map;
+        angular.forEach(fields, function(field) {
+            switch(field.datatype) {
+                case 'org_unit':
+                    if (angular.isFunction(hash[field.name])) {
+                        new_obj[field.name] = hash[field.name];
+                    } else {
+                        new_obj[field.name](hash[field.name]);
+                    }
+                    break;
+                case 'timestamp':
+                    if (hash[field.name] instanceof Date) {
+                        new_obj[field.name](hash[field.name].toISOString());
+                    }
+                    break;
+                case 'bool':
+                    if (hash[field.name] === true) {
+                        new_obj[field.name]('t');
+                    } else if (hash[field.name] === false) {
+                        new_obj[field.name]('f');
+                    }
+                    break;
+                default:
+                    new_obj[field.name](service.fromTypedHash(hash[field.name]));
+            }
+        });
+        new_obj.isnew(hash._isnew);
+        new_obj.ischanged(hash._ischanged);
+        new_obj.isdeleted(hash._isdeleted);
+        return new_obj;
+    }
+
     // Transforms a flattened hash (see toHash() or egGridFlatDataProvider)
     // to a nested hash.
     //

commit 1660b0db5004ae6032c78dcc347305e08ac23347
Author: Galen Charlton <gmc at equinoxinitiative.org>
Date:   Thu Apr 13 15:33:06 2017 -0400

    LP#1682609: upgrade to angular-ui-bootstrap 1.3.3
    
    Signed-off-by: Galen Charlton <gmc at equinoxinitiative.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>
    Signed-off-by: Dan Wells <dbw2 at calvin.edu>

diff --git a/Open-ILS/web/js/ui/default/staff/package.json b/Open-ILS/web/js/ui/default/staff/package.json
index d972491..7ee67d9 100644
--- a/Open-ILS/web/js/ui/default/staff/package.json
+++ b/Open-ILS/web/js/ui/default/staff/package.json
@@ -7,7 +7,7 @@
   "devDependencies": {
     "angular": "~1.5",
     "angular-animate": "~1.5.3",
-    "angular-ui-bootstrap": "~1.2.4",
+    "angular-ui-bootstrap": "~1.3.3",
     "angular-cookies": "~1.5.8",
     "angular-file-saver": "~1.1.0",
     "angular-hotkeys": "^1.7.0",

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/fm_IDL.xml                       |   68 +-
 Open-ILS/src/extras/ils_events.xml                 |    9 +
 .../src/perlmods/lib/OpenILS/Application/Serial.pm |  469 ++++++++-
 Open-ILS/src/perlmods/lib/OpenILS/Utils/MFHD.pm    |    5 +-
 Open-ILS/src/sql/Pg/070.schema.container.sql       |   24 +-
 Open-ILS/src/sql/Pg/210.schema.serials.sql         |   22 +
 Open-ILS/src/sql/Pg/800.fkeys.sql                  |   34 +-
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |    5 +-
 Open-ILS/src/sql/Pg/live_t/spt-visibility.pg       |   48 +
 .../XXXX.schema.inheritance-constraint-trigger.sql |   44 +
 .../XXXX.schema.serial_pattern_templates.sql       |   25 +
 .../src/sql/Pg/upgrade/YYYY.data.spt_perms.sql     |   24 +
 .../Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql  |   18 +
 .../src/templates/staff/admin/local/t_splash.tt2   |    1 -
 .../src/templates/staff/admin/serials/index.tt2    |   33 +
 .../staff/admin/serials/pattern_template.tt2       |   44 +
 .../templates/staff/admin/serials/t_attr_edit.tt2  |  338 ++++++
 .../src/templates/staff/admin/serials/t_splash.tt2 |   38 +
 .../staff/admin/serials/t_template_list.tt2        |   54 +
 .../templates/staff/admin/serials/t_templates.tt2  |   20 +
 Open-ILS/src/templates/staff/base_js.tt2           |    2 +
 Open-ILS/src/templates/staff/cat/catalog/index.tt2 |   11 +
 .../src/templates/staff/cat/catalog/t_catalog.tt2  |   16 +
 Open-ILS/src/templates/staff/navbar.tt2            |    6 +
 Open-ILS/src/templates/staff/serials/index.tt2     |   76 ++
 .../staff/serials/share/serials_strings.tt2        |   27 +
 .../staff/serials/t_apply_binding_template.tt2     |   55 +
 .../templates/staff/serials/t_batch_receive.tt2    |  183 +++
 .../templates/staff/serials/t_chron_selector.tt2   |    5 +
 .../staff/serials/t_clone_subscription.tt2         |   57 +
 .../staff/serials/t_day_of_week_selector.tt2       |    9 +
 .../staff/serials/t_holding_code_dialog.tt2        |  100 ++
 .../src/templates/staff/serials/t_item_manager.tt2 |    7 +
 .../src/templates/staff/serials/t_link_mfhd.tt2    |   35 +
 Open-ILS/src/templates/staff/serials/t_manage.tt2  |   32 +
 .../src/templates/staff/serials/t_mfhd_manager.tt2 |   26 +
 .../src/templates/staff/serials/t_mfhd_tooltip.tt2 |   77 ++
 .../staff/serials/t_month_day_selector.tt2         |   17 +
 .../templates/staff/serials/t_month_selector.tt2   |   14 +
 Open-ILS/src/templates/staff/serials/t_notes.tt2   |  101 ++
 .../staff/serials/t_pattern_editor_dialog.tt2      |   15 +
 .../templates/staff/serials/t_pattern_summary.tt2  |   48 +
 .../staff/serials/t_prediction_manager.tt2         |   73 ++
 .../staff/serials/t_prediction_wizard.tt2          |  461 ++++++++
 .../staff/serials/t_print_routing_list.tt2         |   15 +
 .../templates/staff/serials/t_receive_alerts.tt2   |   76 ++
 .../src/templates/staff/serials/t_routing_list.tt2 |  118 ++
 .../templates/staff/serials/t_season_selector.tt2  |    6 +
 .../staff/serials/t_select_pattern_dialog.tt2      |   32 +
 .../src/templates/staff/serials/t_sub_selector.tt2 |   17 +
 .../staff/serials/t_subscription_manager.tt2       |  157 +++
 .../templates/staff/serials/t_view_items_grid.tt2  |  117 ++
 .../staff/serials/t_week_in_month_selector.tt2     |   11 +
 Open-ILS/src/templates/staff/share/t_autogrid.tt2  |    9 +-
 Open-ILS/src/templates/staff/share/t_datetime.tt2  |    1 +
 Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2 |   14 +
 .../templates/staff/share/t_fm_record_editor.tt2   |   97 +-
 .../src/templates/staff/share/t_help_popover.tt2   |   10 +
 .../templates/staff/share/t_mfhd_create_dialog.tt2 |   25 +
 .../templates/staff/share/t_org_select_dialog.tt2  |   22 +
 .../staff/share/t_share_depth_selector.tt2         |    4 +
 .../staff/share/t_subscription_select_dialog.tt2   |   22 +
 .../ui/default/serial/print_routing_list_users.js  |   16 +-
 Open-ILS/web/js/ui/default/staff/Gruntfile.js      |    3 +-
 .../web/js/ui/default/staff/admin/serials/app.js   |  592 ++++++++++
 .../staff/admin/serials/pattern_template.js        |  135 +++
 .../web/js/ui/default/staff/cat/catalog/app.js     |   63 +-
 .../js/ui/default/staff/cat/services/marcedit.js   |   30 +-
 Open-ILS/web/js/ui/default/staff/package.json      |    2 +-
 Open-ILS/web/js/ui/default/staff/serials/app.js    |   69 ++
 .../staff/serials/directives/item_manager.js       |   20 +
 .../staff/serials/directives/mfhd_manager.js       |   97 ++
 .../staff/serials/directives/prediction_manager.js |  203 ++++
 .../staff/serials/directives/prediction_wizard.js  |  711 ++++++++++++
 .../staff/serials/directives/sub_selector.js       |   34 +
 .../serials/directives/subscription_manager.js     |  936 +++++++++++++++
 .../staff/serials/directives/view-items-grid.js    |  538 +++++++++
 .../js/ui/default/staff/serials/services/core.js   | 1217 ++++++++++++++++++++
 .../web/js/ui/default/staff/services/coresvc.js    |    7 +-
 .../web/js/ui/default/staff/services/eframe.js     |    8 +-
 .../ui/default/staff/services/fm_record_editor.js  |   41 +
 Open-ILS/web/js/ui/default/staff/services/i18n.js  |   22 +
 Open-ILS/web/js/ui/default/staff/services/idl.js   |  101 ++
 Open-ILS/web/js/ui/default/staff/services/mfhd.js  |   41 +
 Open-ILS/web/js/ui/default/staff/services/ui.js    |   99 ++-
 .../web/js/ui/default/staff/test/karma.conf.js     |    1 +
 .../Serials/Webstaff_Serials.adoc                  |   26 +
 87 files changed, 8429 insertions(+), 112 deletions(-)
 create mode 100644 Open-ILS/src/sql/Pg/live_t/spt-visibility.pg
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.inheritance-constraint-trigger.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.schema.serial_pattern_templates.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/YYYY.data.spt_perms.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/ZZZZ.schema.issuance_scap_fkey.sql
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/pattern_template.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/t_attr_edit.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/t_splash.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/t_template_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/admin/serials/t_templates.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/index.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/share/serials_strings.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_apply_binding_template.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_batch_receive.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_chron_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_clone_subscription.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_day_of_week_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_holding_code_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_item_manager.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_link_mfhd.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_manage.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_mfhd_manager.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_mfhd_tooltip.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_month_day_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_month_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_notes.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_pattern_editor_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_pattern_summary.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_prediction_manager.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_prediction_wizard.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_print_routing_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_receive_alerts.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_routing_list.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_season_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_select_pattern_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_sub_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_subscription_manager.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_view_items_grid.tt2
 create mode 100644 Open-ILS/src/templates/staff/serials/t_week_in_month_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_edit_mfhd.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_help_popover.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_mfhd_create_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_org_select_dialog.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_share_depth_selector.tt2
 create mode 100644 Open-ILS/src/templates/staff/share/t_subscription_select_dialog.tt2
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/serials/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/admin/serials/pattern_template.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/app.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/item_manager.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/mfhd_manager.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/prediction_manager.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/prediction_wizard.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/sub_selector.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/subscription_manager.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/directives/view-items-grid.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/serials/services/core.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/i18n.js
 create mode 100644 Open-ILS/web/js/ui/default/staff/services/mfhd.js
 create mode 100644 docs/RELEASE_NOTES_NEXT/Serials/Webstaff_Serials.adoc


hooks/post-receive
-- 
Evergreen ILS




More information about the open-ils-commits mailing list