LP#1676608: copy alert and suppression matrix
authorMike Rylander <mrylander@gmail.com>
Tue, 20 Oct 2015 14:10:28 +0000 (10:10 -0400)
committerMike Rylander <mrylander@gmail.com>
Wed, 28 Feb 2018 15:26:41 +0000 (10:26 -0500)
The Copy Alerts feature allows library staff to add customized alert
messages to copies. The copy alerts will appear when a specific event
takes place, such as when the copy is checked in, checked out, or
renewed. Alerts can be temporary or persistent: temporary alerts will be
disabled after the initial alert and acknowledgement from staff, while
persistent alerts will display each time the alert event takes place.
Copy Alerts can be configured to display at the circulating or owning
library only or, alternatively, when the library at which the alert
event takes place is not the circulating or owning library.  Copy Alerts
can also be configured to provide options for the next copy status that
should be applied to an item.  Library administrators will have the
ability to create and customize Copy Alert Types and to suppress copy
alerts at specific org units.

Copy alerts can be added via the volume/creator and the check in,
check out, and renew pages.  Copy alerts can also be managed at the
item status page.

Copy alert types can be managed via the Copy Alert Types page in
Local Administration, and suppression of them can be adminstered
via the Copy Alert Suppression page under Local Administration.

Co-authored-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
Signed-off-by: Galen Charlton <gmc@equinoxinitiative.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
41 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Open-ILS/src/sql/Pg/040.schema.asset.sql
Open-ILS/src/sql/Pg/live_t/copy_state.pg [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_alerts.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/YYYY.data.stock_copy_alert_types.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.yaous_for_open_circ_exists_fine_handling.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/local/autoGridEditor/acas.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/local/autoGridEditor/ccat.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/local/index.tt2
Open-ILS/src/templates/staff/admin/local/t_grid_editor.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/admin/local/t_splash.tt2
Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2
Open-ILS/src/templates/staff/cat/item/t_list.tt2
Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
Open-ILS/src/templates/staff/cat/volcopy/t_attr_edit.tt2
Open-ILS/src/templates/staff/cat/volcopy/t_copy_alerts.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/cat/volcopy/t_defaults.tt2
Open-ILS/src/templates/staff/circ/checkin/t_checkin_table.tt2
Open-ILS/src/templates/staff/circ/patron/t_checkout.tt2
Open-ILS/src/templates/staff/circ/renew/t_renew.tt2
Open-ILS/src/templates/staff/circ/share/circ_strings.tt2
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/src/templates/staff/share/t_add_copy_alert_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_autogrid.tt2
Open-ILS/src/templates/staff/share/t_copy_alert_editor_dialog.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/share/t_copy_alert_manager_dialog.tt2 [new file with mode: 0644]
Open-ILS/web/js/ui/default/staff/admin/local/app.js
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
Open-ILS/web/js/ui/default/staff/cat/item/app.js
Open-ILS/web/js/ui/default/staff/cat/services/holdings.js
Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js
Open-ILS/web/js/ui/default/staff/circ/checkin/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/checkout.js
Open-ILS/web/js/ui/default/staff/circ/renew/app.js
Open-ILS/web/js/ui/default/staff/circ/services/circ.js
Open-ILS/web/js/ui/default/staff/circ/services/item.js
Open-ILS/web/js/ui/default/staff/services/grid.js
Open-ILS/web/js/ui/default/staff/services/ui.js

index 19bce3d..04f3420 100644 (file)
@@ -7204,6 +7204,7 @@ SELECT  usr,
                        <field reporter:label="Last Captured Hold" name="last_captured_hold" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Has Holds" name="holds_count" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Copy Tags" name="tags" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Copy Alerts" name="copy_alerts" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="age_protect" reltype="has_a" key="id" map="" class="crahp"/>
@@ -7230,6 +7231,7 @@ SELECT  usr,
                        <link field="floating" reltype="has_a" key="id" map="" class="cfg"/>
                        <link field="holds_count" reltype="might_have" key="id" map="" class="hasholdscount"/>
                        <link field="tags" reltype="has_many" key="copy" map="" class="acptcm"/>
+                       <link field="copy_alerts" reltype="has_many" key="copy" map="" class="aca"/>
                </links>
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
@@ -7247,6 +7249,109 @@ SELECT  usr,
         </permacrud>
        </class>
 
+       <class id="ccat" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::copy_alert_type" oils_persist:tablename="config.copy_alert_type" reporter:label="Copy Alert Type" oils_persist:restrict_primary="100">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.copy_alert_type_id_seq">
+                       <field reporter:label="Id" name="id" reporter:selector="name" reporter:datatype="id"/>
+                       <field reporter:label="Scope Org Unit" name="scope_org"  reporter:datatype="org_unit"/>
+                       <field reporter:label="Active" name="active" reporter:datatype="bool" />
+                       <field reporter:label="Name" name="name" reporter:datatype="text" />
+                       <field reporter:label="State" name="state" reporter:datatype="text"/>
+                       <field reporter:label="Event" name="event" reporter:datatype="text" />
+                       <field reporter:label="During Renewal" name="in_renew" reporter:datatype="bool" />
+                       <field reporter:label="Allow At Copy Circ Lib" name="at_circ" reporter:datatype="bool"/>
+                       <field reporter:label="Allow At Copy Owning Lib" name="at_owning" reporter:datatype="bool"/>
+                       <field reporter:label="Invert allowed locations" name="invert_location" reporter:datatype="bool"/>
+                       <field reporter:label="Next Statuses" name="next_status" reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="scope_org" reltype="has_a" key="id" map="" class="aou"/>
+               </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_COPY_ALERT_TYPE CREATE_COPY_ALERT_TYPE" context_field="scope_org"/>
+                <retrieve/>
+                <update permission="ADMIN_COPY_ALERT_TYPE UPDATE_COPY_ALERT_TYPE" context_field="scope_org"/>
+                <delete permission="ADMIN_COPY_ALERT_TYPE DELETE_COPY_ALERT_TYPE" context_field="scope_org"/>
+            </actions>
+        </permacrud>
+       </class>
+
+       <class id="acas" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="actor::copy_alert_suppress" oils_persist:tablename="actor.copy_alert_suppress" reporter:label="Copy Alert Suppression">
+               <fields oils_persist:primary="id" oils_persist:sequence="actor.copy_alert_suppress_id_seq">
+                       <field reporter:label="Id" name="id" reporter:datatype="id"/>
+                       <field reporter:label="Org Unit" name="org"  reporter:datatype="org_unit"/>
+                       <field reporter:label="Alert Type" name="alert_type" reporter:datatype="link" />
+               </fields>
+               <links>
+                       <link field="org" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="alert_type" reltype="has_a" key="id" map="" class="ccat"/>
+               </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_COPY_ALERT_SUPPRESS CREATE_COPY_ALERT_SUPPRESS" context_field="org"/>
+                <retrieve/>
+                <update permission="ADMIN_COPY_ALERT_SUPPRESS UPDATE_COPY_ALERT_SUPPRESS" context_field="org"/>
+                <delete permission="ADMIN_COPY_ALERT_SUPPRESS DELETE_COPY_ALERT_SUPPRESS" context_field="org"/>
+            </actions>
+        </permacrud>
+       </class>
+
+       <class id="aca" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_alert" oils_persist:tablename="asset.copy_alert" reporter:label="Copy Alert">
+               <fields oils_persist:primary="id" oils_persist:sequence="asset.copy_alert_id_seq">
+                       <field reporter:label="Id" name="id" reporter:datatype="id"/>
+                       <field reporter:label="Alert Type" name="alert_type" reporter:datatype="link" />
+                       <field reporter:label="Copy" name="copy"  reporter:datatype="link"/>
+                       <field reporter:label="Temporary" name="temp" reporter:datatype="bool" />
+                       <field reporter:label="Create Date/Time" name="create_time" reporter:datatype="timestamp" />
+                       <field reporter:label="Creator" name="create_staff" reporter:datatype="link"/>
+                       <field reporter:label="Note" name="note" reporter:datatype="text"/>
+                       <field reporter:label="Acknowledge Date/Time" name="ack_time" reporter:datatype="timestamp" />
+                       <field reporter:label="Acknowledger" name="ack_staff" reporter:datatype="link"/>
+               </fields>
+               <links>
+                       <link field="alert_type" reltype="has_a" key="id" map="" class="ccat"/>
+                       <link field="copy" reltype="has_a" key="id" map="" class="acp"/>
+                       <link field="create_staff" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="ack_staff" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_COPY_ALERT CREATE_COPY_ALERT" global_required="true"/>
+                <retrieve permission="ADMIN_COPY_ALERT VIEW_COPY_ALERT" global_required="true"/>
+                <update permission="ADMIN_COPY_ALERT UPDATE_COPY_ALERT" global_required="true"/>
+                <delete permission="ADMIN_COPY_ALERT DELETE_COPY_ALERT" global_required="true"/>
+            </actions>
+        </permacrud>
+       </class>
+
+       <class id="aaca" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::active_copy_alert" oils_persist:tablename="asset.active_copy_alert" reporter:label="Active Copy Alert" oils_persist:readonly="true">
+               <fields oils_persist:primary="id">
+                       <field reporter:label="Id" name="id" reporter:datatype="id"/>
+                       <field reporter:label="Alert Type" name="alert_type" reporter:datatype="link" />
+                       <field reporter:label="Copy" name="copy"  reporter:datatype="link"/>
+                       <field reporter:label="Temporary" name="temp" reporter:datatype="bool" />
+                       <field reporter:label="Create Date/Time" name="create_time" reporter:datatype="timestamp" />
+                       <field reporter:label="Creator" name="create_staff" reporter:datatype="link"/>
+                       <field reporter:label="Note" name="note" reporter:datatype="text"/>
+                       <field reporter:label="Acknowledge Date/Time" name="ack_time" reporter:datatype="timestamp" />
+                       <field reporter:label="Acknowledger" name="ack_staff" reporter:datatype="link"/>
+               </fields>
+               <links>
+                       <link field="alert_type" reltype="has_a" key="id" map="" class="ccat"/>
+                       <link field="copy" reltype="has_a" key="id" map="" class="acp"/>
+                       <link field="create_staff" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="ack_staff" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <create permission="ADMIN_COPY_ALERT CREATE_COPY_ALERT" global_required="true"/>
+                <retrieve permission="ADMIN_COPY_ALERT VIEW_COPY_ALERT" global_required="true"/>
+                <update permission="ADMIN_COPY_ALERT UPDATE_COPY_ALERT" global_required="true"/>
+                <delete permission="ADMIN_COPY_ALERT DELETE_COPY_ALERT" global_required="true"/>
+            </actions>
+        </permacrud>
+       </class>
+
        <class id="act" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_template" oils_persist:tablename="asset.copy_template" reporter:label="Asset Copy Template">
                <fields oils_persist:primary="id" oils_persist:sequence="asset.copy_template_id_seq">
                        <field reporter:label="ID" name="id" reporter:datatype="id" reporter:selector="name" />
index 4c40d65..5ed0e25 100644 (file)
@@ -2279,6 +2279,14 @@ sub unique_unnested_numbers {
     );
 }
 
+# Given a list of numbers, turn them into a PG array, skipping undef's
+sub intarray2pgarray {
+    my $class = shift;
+    no warnings 'numeric';
+
+    return '{' . join( ',', map(int, grep { defined && /^\d+$/ } @_) ) . '}';
+}
+
 # Check if a transaction should be left open or closed. Close the
 # transaction if it should be closed or open it otherwise. Returns
 # undef on success or a failure event.
index 14a52d4..e52d94f 100644 (file)
@@ -258,6 +258,39 @@ sub update_copy_notes {
     return undef;
 }
 
+sub update_copy_alerts {
+    my($class, $editor, $copy) = @_;
+
+    return undef if $copy->isdeleted;
+
+    my $evt;
+    my $incoming_copy_alerts = $copy->copy_alerts;
+
+    for my $incoming_copy_alert (@$incoming_copy_alerts) { 
+        next unless $incoming_copy_alert;
+
+        if ($incoming_copy_alert->isnew) {
+            next if ($incoming_copy_alert->isdeleted); # if it was added and deleted in the same session
+
+            my $new_copy_alert = Fieldmapper::asset::copy_alert->new();
+            $new_copy_alert->copy( $copy->id );
+            $new_copy_alert->temp( $incoming_copy_alert->temp );
+            $new_copy_alert->ack_time( $incoming_copy_alert->ack_time );
+            $new_copy_alert->note( $incoming_copy_alert->note );
+            $new_copy_alert->alert_type( $incoming_copy_alert->alert_type );
+            $new_copy_alert->create_staff( $incoming_copy_alert->create_staff || $editor->requestor->id );
+            $incoming_copy_alert = $editor->create_asset_copy_alert($new_copy_alert)
+                or return $editor->event;
+        } elsif ($incoming_copy_alert->ischanged) {
+            $incoming_copy_alert = $editor->update_asset_copy_alert($incoming_copy_alert)
+        } elsif ($incoming_copy_alert->isdeleted) {
+            $incoming_copy_alert = $editor->delete_asset_copy_alert($incoming_copy_alert->id)
+        }
+    
+    }
+
+    return undef;
+}
 
 sub update_copy_tags {
     my($class, $editor, $copy) = @_;
@@ -303,8 +336,6 @@ sub update_copy_tags {
     return undef;
 }
 
-
-
 sub update_copy {
     my($class, $editor, $override, $vol, $copy, $retarget_holds, $force_delete_empty_bib) = @_;
 
@@ -415,9 +446,13 @@ sub update_fleshed_copies {
 
         my $notes = $copy->notes;
         $copy->clear_notes;
+
         my $tags = $copy->tags;
         $copy->clear_tags;
 
+        my $copy_alerts = $copy->copy_alerts;
+        $copy->clear_copy_alerts;
+
         if( $copy->isdeleted ) {
             $evt = $class->delete_copy($editor, $override, $vol, $copy, $retarget_holds, $force_delete_empty_bib);
             return $evt if $evt;
@@ -445,6 +480,9 @@ sub update_fleshed_copies {
         $copy->tags( $tags );
         $evt = $class->update_copy_tags($editor, $copy);
 
+        $copy->copy_alerts( $copy_alerts );
+        $evt = $class->update_copy_alerts($editor, $copy);
+
         return $evt if $evt;
     }
 
index ce05bc0..927e3f0 100644 (file)
@@ -40,6 +40,18 @@ my $MK_ENV_FLESH = {
     flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
 };
 
+# table of cases where suppressing a system-generated copy alerts
+# should generate an override of an old-style event
+my %COPY_ALERT_OVERRIDES = (
+    "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
+    "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
+    "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
+    "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
+    "MISSING\tCHECKOUT" => ['COPY_STATUS_MISSING'],
+    "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
+    "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
+);
+
 sub initialize {}
 
 __PACKAGE__->register_method(
@@ -155,6 +167,7 @@ sub run_method {
     my( $self, $conn, $auth, $args ) = @_;
     translate_legacy_args($args);
     $args->{override_args} = { all => 1 } unless defined $args->{override_args};
+    $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
     my $api = $self->api_name;
 
     my $circulator = 
@@ -227,13 +240,13 @@ sub run_method {
 
     $circulator->is_renewal(1) if $api =~ /renew/;
     $circulator->is_checkin(1) if $api =~ /checkin/;
+    $circulator->is_checkout(1) if $api =~ /checkout/;
+    $circulator->override(1) if $api =~ /override/o;
 
     $circulator->mk_env();
     $circulator->noop(1) if $circulator->claims_never_checked_out;
 
     return circ_events($circulator) if $circulator->bail_out;
-    
-    $circulator->override(1) if $api =~ /override/o;
 
     if( $api =~ /checkout\.permit/ ) {
         $circulator->do_permit();
@@ -260,7 +273,6 @@ sub run_method {
         return $data;
 
     } elsif( $api =~ /checkout/ ) {
-        $circulator->is_checkout(1);
         $circulator->do_checkout();
 
     } elsif( $circulator->is_res_checkin ) {
@@ -270,7 +282,6 @@ sub run_method {
         $circulator->do_checkin();
 
     } elsif( $api =~ /renew/ ) {
-        $circulator->is_renewal(1);
         $circulator->do_renew();
     }
 
@@ -404,6 +415,12 @@ my @AUTOLOAD_FIELDS = qw/
     copy
     copy_id
     copy_barcode
+    new_copy_alerts
+    user_copy_alerts
+    system_copy_alerts
+    overrides_per_copy_alerts
+    next_copy_status
+    copy_state
     patron
     patron_id
     patron_barcode
@@ -640,10 +657,233 @@ sub save_trimmed_copy {
     }
 }
 
+sub collect_user_copy_alerts {
+    my $self = shift;
+    my $e = $self->editor;
+
+    if($self->copy) {
+        my $alerts = $e->search_asset_copy_alert([
+            {copy => $self->copy->id, ack_time => undef},
+            {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
+        ]);
+        if (ref $alerts eq "ARRAY") {
+            $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
+                $self->copy->id);
+            $self->user_copy_alerts($alerts);
+        }
+    }
+}
+
+sub filter_user_copy_alerts {
+    my $self = shift;
+
+    my $e = $self->editor;
+
+    if(my $alerts = $self->user_copy_alerts) {
+
+        my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
+        my $suppressions = $e->search_actor_copy_alert_suppress(
+            {org => $suppress_orgs}
+        );
+
+        my @final_alerts;
+        foreach my $a (@$alerts) {
+            # filter on event type
+            if (defined $a->alert_type) {
+                next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
+                next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
+                next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
+                next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
+            }
+
+            # filter on suppression
+            next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
+
+            # filter on "only at circ lib"
+            if (defined $a->alert_type->at_circ) {
+                my $copy_circ_lib = (ref $self->copy->circ_lib) ?
+                    $self->copy->circ_lib->id : $self->copy->circ_lib;
+                my $orgs = $U->get_org_descendants($copy_circ_lib);
+
+                if ($U->is_true($a->alert_type->invert_location)) {
+                    next if (grep {$_ == $self->circ_lib} @$orgs);
+                } else {
+                    next unless (grep {$_ == $self->circ_lib} @$orgs);
+                }
+            }
+
+            # filter on "only at owning lib"
+            if (defined $a->alert_type->at_owning) {
+                my $copy_owning_lib = (ref $self->volume->owning_lib) ?
+                    $self->volume->owning_lib->id : $self->volume->owning_lib;
+                my $orgs = $U->get_org_descendants($copy_owning_lib);
+
+                if ($U->is_true($a->alert_type->invert_location)) {
+                    next if (grep {$_ == $self->circ_lib} @$orgs);
+                } else {
+                    next unless (grep {$_ == $self->circ_lib} @$orgs);
+                }
+            }
+
+            $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
+
+            push @final_alerts, $a;
+        }
+
+        $self->user_copy_alerts(\@final_alerts);
+    }
+}
+
+sub generate_system_copy_alerts {
+    my $self = shift;
+    return unless($self->copy);
+
+    # don't create system copy alerts if the copy
+    # is in a normal state; we're assuming that there's
+    # never a need to generate a popup for each and every
+    # checkin or checkout of normal items. If this assumption
+    # proves false, then we'll need to add a way to explicitly specify
+    # that a copy alert type should never generate a system copy alert
+    return if $self->copy_state eq 'NORMAL';
+
+    my $e = $self->editor;
+
+    my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
+    my $suppressions = $e->search_actor_copy_alert_suppress(
+        {org => $suppress_orgs}
+    );
+
+    # events we care about ...
+    my $event = [];
+    push(@$event, 'CHECKIN') if $self->is_checkin;
+    push(@$event, 'CHECKOUT') if $self->is_checkout;
+    return unless scalar(@$event);
+
+    my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
+    my $alert_types = $e->search_config_copy_alert_type({
+        active    => 't',
+        scope_org => $alert_orgs,
+        event     => $event,
+        state => $self->copy_state,
+        '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
+    });
+
+    my @final_types;
+    foreach my $a (@$alert_types) {
+        # filter on "only at circ lib"
+        if (defined $a->at_circ) {
+            my $copy_circ_lib = (ref $self->copy->circ_lib) ?
+                $self->copy->circ_lib->id : $self->copy->circ_lib;
+            my $orgs = $U->get_org_descendants($copy_circ_lib);
+
+            if ($U->is_true($a->invert_location)) {
+                next if (grep {$_ == $self->circ_lib} @$orgs);
+            } else {
+                next unless (grep {$_ == $self->circ_lib} @$orgs);
+            }
+        }
+
+        # filter on "only at owning lib"
+        if (defined $a->at_owning) {
+            my $copy_owning_lib = (ref $self->volume->owning_lib) ?
+                $self->volume->owning_lib->id : $self->volume->owning_lib;
+            my $orgs = $U->get_org_descendants($copy_owning_lib);
+
+            if ($U->is_true($a->invert_location)) {
+                next if (grep {$_ == $self->circ_lib} @$orgs);
+            } else {
+                next unless (grep {$_ == $self->circ_lib} @$orgs);
+            }
+        }
+
+        push @final_types, $a;
+    }
+
+    if (@final_types) {
+        $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
+            $self->copy->id);
+    }
+
+    my @alerts;
+    
+    # keep track of conditions corresponding to suppressed
+    # system alerts, as these may be used to overridee
+    # certain old-style-events
+    my %auto_override_conditions = ();
+    foreach my $t (@final_types) {
+        if ($t->next_status) {
+            if (grep { $t->id == $_->alert_type } @$suppressions) {
+                $t->next_status([]);
+            } else {
+                $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
+            }
+        }
+
+        my $alert = new Fieldmapper::asset::copy_alert ();
+        $alert->alert_type($t->id);
+        $alert->copy($self->copy->id);
+        $alert->temp(1);
+        $alert->create_staff($e->requestor->id);
+        $alert->create_time('now');
+        $alert->ack_staff($e->requestor->id);
+        $alert->ack_time('now');
+
+        $alert = $e->create_asset_copy_alert($alert);
+
+        next unless $alert;
+
+        $alert->alert_type($t->clone);
+
+        push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
+        if (grep {$_->alert_type == $t->id} @$suppressions) {
+            $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
+        }
+        push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
+    }
+
+    $self->system_copy_alerts(\@alerts);
+    $self->overrides_per_copy_alerts(\%auto_override_conditions);
+}
+
+sub add_overrides_from_system_copy_alerts {
+    my $self = shift;
+    my $e = $self->editor;
+
+    foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
+        if (exists $COPY_ALERT_OVERRIDES{$condition}) {
+            $self->override(1);
+            push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
+            # special handling for long-overdue and lost checkouts
+            if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
+                my $state = (split /\t/, $condition, -1)[0];
+                my $setting;
+                if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
+                    $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
+                } elsif ($state eq 'LONGOVERDUE') {
+                    $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
+                } else {
+                    next;
+                }
+                my $forgive = $U->ou_ancestor_setting_value(
+                    $self->circ_lib, $setting, $e
+                );
+                if ($U->is_true($forgive)) {
+                    $self->void_overdues(1);
+                }
+                $self->noop(1); # do not attempt transits, just check it in
+                $self->do_checkin();
+            }
+        }
+    }
+}
+
 sub mk_env {
     my $self = shift;
     my $e = $self->editor;
 
+    $self->next_copy_status([]) unless (defined $self->next_copy_status);
+    $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
+
     # --------------------------------------------------------------------------
     # Grab the fleshed copy
     # --------------------------------------------------------------------------
@@ -676,6 +916,7 @@ sub mk_env {
                         }
                     },
                     "where" => {
+                        deleted => 'f',
                         "+bresv" => {
                             "id" => (ref $self->reservation) ?
                                 $self->reservation->id : $self->reservation
@@ -692,6 +933,19 @@ sub mk_env {
     
         if($copy) {
             $self->save_trimmed_copy($copy);
+
+            # alerts!
+            $self->copy_state(
+                $e->json_query(
+                    {from => ['asset.copy_state', $copy->id]}
+                )->[0]{'asset.copy_state'}
+            );
+
+            $self->generate_system_copy_alerts;
+            $self->add_overrides_from_system_copy_alerts;
+            $self->collect_user_copy_alerts;
+            $self->filter_user_copy_alerts;
+
         } else {
             # We can't renew if there is no copy
             return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
@@ -1207,6 +1461,21 @@ sub run_copy_permit_scripts {
 
 sub check_copy_alert {
     my $self = shift;
+
+    if ($self->new_copy_alerts) {
+        my @alerts;
+        push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts 
+            if ($self->user_copy_alerts && @{$self->user_copy_alerts});
+
+        push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts 
+            if ($self->system_copy_alerts && @{$self->system_copy_alerts});
+
+        if (@alerts) {
+            $self->bail_out(1) if (!$self->override);
+            return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
+        }
+    }
+
     return undef if $self->is_renewal;
     return OpenILS::Event->new(
         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
@@ -2640,7 +2909,8 @@ sub do_checkin {
             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
 
         # the item was not supposed to be checked out to the user and should now be marked as missing
-        $self->copy->status(OILS_COPY_STATUS_MISSING);
+        my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
+        $self->copy->status($next_status);
         $self->update_copy;
 
     } else {
@@ -2716,13 +2986,15 @@ sub reshelve_copy {
 
    my $stat = $U->copy_status($copy->status)->id;
 
+   my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
+
    if($force || (
       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
       $stat != OILS_COPY_STATUS_CATALOGING and
       $stat != OILS_COPY_STATUS_IN_TRANSIT and
-      $stat != OILS_COPY_STATUS_RESHELVING  )) {
+      $stat != $next_status  )) {
 
-        $copy->status( OILS_COPY_STATUS_RESHELVING );
+        $copy->status( $next_status );
             $self->update_copy;
             $self->checkin_changed(1);
     }
@@ -3333,7 +3605,8 @@ sub checkin_handle_circ_start {
     } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
         $logger->info("circulator: not updating copy status on checkin because copy is missing");
     } else {
-        $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
+        my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
+        $self->copy->status($U->copy_status($next_status));
         $self->update_copy;
     }
 
@@ -3521,7 +3794,8 @@ sub checkin_handle_lost_or_longoverdue {
         if ($immediately_available) {
             # item status does not need to be retained, so give it a
             # reshelving status as if it were a normal checkin
-            $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
+            my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
+            $self->copy->status($U->copy_status($next_status));
             $self->update_copy;
         } else {
             $logger->info("circulator: leaving lost/longoverdue copy".
@@ -3530,7 +3804,8 @@ sub checkin_handle_lost_or_longoverdue {
     } else {
         # lost/longoverdue item is home and processed, treat like a normal 
         # checkin from this point on
-        $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
+        my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
+        $self->copy->status($U->copy_status($next_status));
         $self->update_copy;
     }
 }
@@ -3571,7 +3846,8 @@ sub check_checkin_copy_status {
    my $status = $U->copy_status($copy->status)->id;
 
    return undef
-      if(   $status == OILS_COPY_STATUS_AVAILABLE   ||
+      if(   $self->new_copy_alerts ||
+            $status == OILS_COPY_STATUS_AVAILABLE   ||
             $status == OILS_COPY_STATUS_CHECKED_OUT ||
             $status == OILS_COPY_STATUS_IN_PROCESS  ||
             $status == OILS_COPY_STATUS_ON_HOLDS_SHELF  ||
index f9948cb..d17dcfa 100644 (file)
@@ -987,5 +987,97 @@ CREATE INDEX asset_copy_tag_copy_map_copy_idx
 CREATE INDEX asset_copy_tag_copy_map_tag_idx
     ON asset.copy_tag_copy_map (tag);
 
+CREATE OR REPLACE FUNCTION asset.copy_state (cid BIGINT) RETURNS TEXT AS $$
+DECLARE
+    last_circ_stop     TEXT;
+    the_copy       asset.copy%ROWTYPE;
+BEGIN
+
+    SELECT * INTO the_copy FROM asset.copy WHERE id = cid;
+    IF NOT FOUND THEN RETURN NULL; END IF;
+
+    IF the_copy.status = 3 THEN -- Lost
+        RETURN 'LOST';
+    ELSIF the_copy.status = 4 THEN -- Missing
+        RETURN 'MISSING';
+    ELSIF the_copy.status = 14 THEN -- Damaged
+        RETURN 'DAMAGED';
+    ELSIF the_copy.status = 17 THEN -- Lost and paid
+        RETURN 'LOST_AND_PAID';
+    END IF;
+
+    SELECT stop_fines INTO last_circ_stop
+      FROM  action.circulation
+      WHERE target_copy = cid
+      ORDER BY xact_start DESC LIMIT 1;
+
+    IF FOUND THEN
+        IF last_circ_stop IN (
+            'CLAIMSNEVERCHECKEDOUT',
+            'CLAIMSRETURNED',
+            'LONGOVERDUE'
+        ) THEN
+            RETURN last_circ_stop;
+        END IF;
+    END IF;
+
+    RETURN 'NORMAL';
+END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TYPE config.copy_alert_type_state AS ENUM (
+    'NORMAL',
+    'LOST',
+    'LOST_AND_PAID',
+    'MISSING',
+    'DAMAGED',
+    'CLAIMSRETURNED',
+    'LONGOVERDUE',
+    'CLAIMSNEVERCHECKEDOUT'
+);
+
+CREATE TYPE config.copy_alert_type_event AS ENUM (
+    'CHECKIN',
+    'CHECKOUT'
+);
+
+CREATE TABLE config.copy_alert_type (
+    id      serial  primary key, -- reserve 1-100 for system
+    scope_org   int not null references actor.org_unit (id) on delete cascade,
+    active      bool    not null default true,
+    name        text    not null unique,
+    state       config.copy_alert_type_state,
+    event       config.copy_alert_type_event,
+    in_renew    bool,
+    invert_location bool    not null default false,
+    at_circ     bool,
+    at_owning   bool,
+    next_status int[]
+);
+SELECT SETVAL('config.copy_alert_type_id_seq'::TEXT, 100);
+
+CREATE TABLE actor.copy_alert_suppress (
+    id          serial primary key,
+    org         int not null references actor.org_unit (id) on delete cascade,
+    alert_type  int not null references config.copy_alert_type (id) on delete cascade
+);
+
+CREATE TABLE asset.copy_alert (
+    id      bigserial   primary key,
+    alert_type  int     not null references config.copy_alert_type (id) on delete cascade,
+    copy        bigint  not null references asset.copy (id) on delete cascade,
+    temp        bool    not null default false,
+    create_time timestamptz not null default now(),
+    create_staff    bigint  not null references actor.usr (id) on delete set null,
+    note        text,
+    ack_time    timestamptz,
+    ack_staff   bigint references actor.usr (id) on delete set null
+);
+
+CREATE VIEW asset.active_copy_alert AS
+    SELECT  *
+      FROM  asset.copy_alert
+      WHERE ack_time IS NULL;
+
 COMMIT;
 
diff --git a/Open-ILS/src/sql/Pg/live_t/copy_state.pg b/Open-ILS/src/sql/Pg/live_t/copy_state.pg
new file mode 100644 (file)
index 0000000..e09e62b
--- /dev/null
@@ -0,0 +1,11 @@
+BEGIN;
+
+SELECT plan(1);
+
+\set copy_id 245
+
+UPDATE asset.copy SET status = 4 WHERE id = :copy_id;
+
+SELECT is(asset.copy_state(:copy_id), 'MISSING', 'asset.copy_state detects missing state');
+
+ROLLBACK;
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_alerts.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.copy_alerts.sql
new file mode 100644 (file)
index 0000000..db4cd25
--- /dev/null
@@ -0,0 +1,96 @@
+BEGIN;
+
+CREATE OR REPLACE FUNCTION asset.copy_state (cid BIGINT) RETURNS TEXT AS $$
+DECLARE
+    last_circ_stop     TEXT;
+    the_copy       asset.copy%ROWTYPE;
+BEGIN
+
+    SELECT * INTO the_copy FROM asset.copy WHERE id = cid;
+    IF NOT FOUND THEN RETURN NULL; END IF;
+
+    IF the_copy.status = 3 THEN -- Lost
+        RETURN 'LOST';
+    ELSIF the_copy.status = 4 THEN -- Missing
+        RETURN 'MISSING';
+    ELSIF the_copy.status = 14 THEN -- Damaged
+        RETURN 'DAMAGED';
+    ELSIF the_copy.status = 17 THEN -- Lost and paid
+        RETURN 'LOST_AND_PAID';
+    END IF;
+
+    SELECT stop_fines INTO last_circ_stop
+      FROM  action.circulation
+      WHERE target_copy = cid
+      ORDER BY xact_start DESC LIMIT 1;
+
+    IF FOUND THEN
+        IF last_circ_stop IN (
+            'CLAIMSNEVERCHECKEDOUT',
+            'CLAIMSRETURNED',
+            'LONGOVERDUE'
+        ) THEN
+            RETURN last_circ_stop;
+        END IF;
+    END IF;
+
+    RETURN 'NORMAL';
+END;
+$$ LANGUAGE PLPGSQL;
+
+CREATE TYPE config.copy_alert_type_state AS ENUM (
+    'NORMAL',
+    'LOST',
+    'LOST_AND_PAID',
+    'MISSING',
+    'DAMAGED',
+    'CLAIMSRETURNED',
+    'LONGOVERDUE',
+    'CLAIMSNEVERCHECKEDOUT'
+);
+
+CREATE TYPE config.copy_alert_type_event AS ENUM (
+    'CHECKIN',
+    'CHECKOUT'
+);
+
+CREATE TABLE config.copy_alert_type (
+    id         serial  primary key, -- reserve 1-100 for system
+    scope_org   int not null references actor.org_unit (id) on delete cascade,
+    active      bool    not null default true,
+    name        text    not null unique,
+    state       config.copy_alert_type_state,
+    event       config.copy_alert_type_event,
+    in_renew    bool,
+    invert_location bool    not null default false,
+    at_circ     bool,
+    at_owning   bool,
+    next_status int[]
+);
+SELECT SETVAL('config.copy_alert_type_id_seq'::TEXT, 100);
+
+CREATE TABLE actor.copy_alert_suppress (
+    id          serial primary key,
+    org         int not null references actor.org_unit (id) on delete cascade,
+    alert_type  int not null references config.copy_alert_type (id) on delete cascade
+);
+
+CREATE TABLE asset.copy_alert (
+    id      bigserial   primary key,
+    alert_type  int     not null references config.copy_alert_type (id) on delete cascade,
+    copy        bigint  not null references asset.copy (id) on delete cascade,
+    temp        bool    not null default false,
+    create_time timestamptz not null default now(),
+    create_staff    bigint  not null references actor.usr (id) on delete set null,
+    note        text,
+    ack_time    timestamptz,
+    ack_staff   bigint references actor.usr (id) on delete set null
+);
+
+CREATE VIEW asset.active_copy_alert AS
+    SELECT  *
+      FROM  asset.copy_alert
+      WHERE ack_time IS NULL;
+
+COMMIT;
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.data.stock_copy_alert_types.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.data.stock_copy_alert_types.sql
new file mode 100644 (file)
index 0000000..cc09016
--- /dev/null
@@ -0,0 +1,62 @@
+BEGIN;
+
+-- staff-usable alert types with no location awareness
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew)
+VALUES (1, 1, FALSE, 'Normal checkout', 'NORMAL', 'CHECKOUT', FALSE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew)
+VALUES (2, 1, FALSE, 'Normal checkin', 'NORMAL', 'CHECKIN', FALSE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew)
+VALUES (3, 1, FALSE, 'Normal renewal', 'NORMAL', 'CHECKIN', TRUE);
+
+-- copy alerts upon checkin or renewal of exceptional copy statuses are not active by
+-- default; they're meant to be turned once a site is ready to fully
+-- commit to using the webstaff client for circulation
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (4, 1, FALSE, 'Checkin of lost copy', 'LOST', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (5, 1, FALSE, 'Checkin of missing copy', 'MISSING', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (6, 1, FALSE, 'Checkin of lost-and-paid copy', 'LOST_AND_PAID', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (7, 1, FALSE, 'Checkin of damaged copy', 'DAMAGED', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (8, 1, FALSE, 'Checkin of claims-returned copy', 'CLAIMSRETURNED', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (9, 1, FALSE, 'Checkin of long overdue copy', 'LONGOVERDUE', 'CHECKIN');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (10, 1, FALSE, 'Checkin of claims-never-checked-out copy', 'CLAIMSNEVERCHECKEDOUT', 'CHECKIN');
+
+-- copy alerts upon checkout of exceptional copy statuses are not active by
+-- default; they're meant to be turned once a site is ready to fully
+-- commit to using the webstaff client for circulation
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (11, 1, FALSE, 'Checkout of lost copy', 'LOST', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (12, 1, FALSE, 'Checkout of missing copy', 'MISSING', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (13, 1, FALSE, 'Checkout of lost-and-paid copy', 'LOST_AND_PAID', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (14, 1, FALSE, 'Checkout of damaged copy', 'DAMAGED', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (15, 1, FALSE, 'Checkout of claims-returned copy', 'CLAIMSRETURNED', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (16, 1, FALSE, 'Checkout of long overdue copy', 'LONGOVERDUE', 'CHECKOUT');
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event)
+VALUES (17, 1, FALSE, 'Checkout of claims-never-checked-out copy', 'CLAIMSNEVERCHECKEDOUT', 'CHECKOUT');
+
+-- staff-usable alert types based on location
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_circ)
+VALUES (18, 1, FALSE, 'Normal checkout at circ lib', 'NORMAL', 'CHECKOUT', FALSE, TRUE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_circ)
+VALUES (19, 1, FALSE, 'Normal checkin at circ lib', 'NORMAL', 'CHECKIN', FALSE, TRUE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_circ)
+VALUES (20, 1, FALSE, 'Normal renewal at circ lib', 'NORMAL', 'CHECKIN', TRUE, TRUE);
+
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_owning)
+VALUES (21, 1, FALSE, 'Normal checkout at owning lib', 'NORMAL', 'CHECKOUT', FALSE, TRUE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_owning)
+VALUES (22, 1, FALSE, 'Normal checkin at owning lib', 'NORMAL', 'CHECKIN', FALSE, TRUE);
+INSERT INTO config.copy_alert_type (id, scope_org, active, name, state, event, in_renew, at_owning)
+VALUES (23, 1, FALSE, 'Normal renewal at owning lib', 'NORMAL', 'CHECKIN', TRUE, TRUE);
+
+COMMIT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.yaous_for_open_circ_exists_fine_handling.sql b/Open-ILS/src/sql/Pg/upgrade/ZZZZ.data.yaous_for_open_circ_exists_fine_handling.sql
new file mode 100644 (file)
index 0000000..4235527
--- /dev/null
@@ -0,0 +1,35 @@
+BEGIN;
+
+--- SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype)
+    VALUES
+        ('circ.copy_alerts.forgive_fines_on_lost_checkin',
+         'circ',
+         oils_i18n_gettext('circ.copy_alerts.forgive_fines_on_lost_checkin',
+            'Forgive fines when checking out a lost item and copy alert is suppressed?',
+            'coust', 'label'),
+         oils_i18n_gettext('circ.copy_alerts.forgive_fines_on_lost_checkin',
+            'Controls whether fines are automatically forgiven when checking out an '||
+            'item that has been marked as lost, and the corresponding copy alert has been '||
+            'suppressed.',
+            'coust', 'description'),
+        'bool');
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype)
+    VALUES
+        ('circ.copy_alerts.forgive_fines_on_long_overdue_checkin',
+         'circ',
+         oils_i18n_gettext('circ.copy_alerts.forgive_fines_on_long_overdue_checkin',
+            'Forgive fines when checking out a long-overdue item and copy alert is suppressed?',
+            'coust', 'label'),
+         oils_i18n_gettext('circ.copy_alerts.forgive_fines_on_lost_checkin',
+            'Controls whether fines are automatically forgiven when checking out an '||
+            'item that has been marked as lost, and the corresponding copy alert has been '||
+            'suppressed.',
+            'coust', 'description'),
+        'bool');
+
+COMMIT;
diff --git a/Open-ILS/src/templates/staff/admin/local/autoGridEditor/acas.tt2 b/Open-ILS/src/templates/staff/admin/local/autoGridEditor/acas.tt2
new file mode 100644 (file)
index 0000000..eab1609
--- /dev/null
@@ -0,0 +1,30 @@
+[% ctx.page_title = l("Copy Alert Suppression"); %]
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(record)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 ng-if="creating"  class="modal-title">[% l('Create copy alert suppression rule') %]</h4>
+      <h4 ng-if="!creating" class="modal-title">[% l('Update copy alert suppression rule') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="copy-alert-type-selector">[% l('Alert Type') %]</label>
+        <select id="copy-alert-type-selector" class="form-control"
+          ng-model="record.alert_type"
+          ng-options="at.id() as at.name() for at in ccat">
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="select-org-unit">[% l('Org Unit') %]</label>
+        <eg-org-selector selected="record.org"></eg-org-selector>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Save') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
diff --git a/Open-ILS/src/templates/staff/admin/local/autoGridEditor/ccat.tt2 b/Open-ILS/src/templates/staff/admin/local/autoGridEditor/ccat.tt2
new file mode 100644 (file)
index 0000000..727bfd9
--- /dev/null
@@ -0,0 +1,92 @@
+[% ctx.page_title = l("Copy Alert Types"); %]
+<!-- use <form> so we get submit-on-enter for free -->
+<form class="form-validated" novalidate name="form" ng-submit="ok(record)">
+  <div>
+    <div class="modal-header">
+      <button type="button" class="close" 
+        ng-click="cancel()" aria-hidden="true">&times;</button>
+      <h4 ng-if="creating"  class="modal-title">[% l('Create copy alert type') %]</h4>
+      <h4 ng-if="!creating" class="modal-title">[% l('Update copy alert type') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="form-group">
+        <label for="edit-alert-name">[% l('Name') %]</label>
+        <input type="text" class="form-control" focus-me='focusMe' required
+          id="edit-alert-name" ng-model="record.name" placeholder="[% l('Name...') %]"/>
+      </div>
+      <div class="form-group">
+        <label for="active-selector">[% l('Active') %]</label>
+        <select id="active-selector" class="form-control" ng-model="record.active">
+            <option value="t">[% l('Yes') %]</option>
+            <option value="f">[% l('No') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="state-selector">[% l('State') %]</label>
+        <select id="state-selector" class="form-control" ng-model="record.state">
+            <option value="NORMAL">[% l('Normal') %]</option>
+            <option value="LOST">[% l('Lost') %]</option>
+            <option value="LOST_AND_PAID">[% l('Lost and paid for') %]</option>
+            <option value="LONGOVERDUE">[% l('Long Overdue') %]</option>
+            <option value="MISSING">[% l('Missing') %]</option>
+            <option value="DAMAGED">[% l('Damaged') %]</option>
+            <option value="CLAIMSRETURNED">[% l('Claims returned') %]</option>
+            <option value="CLAIMSNEVERCHECKEDOUT">[% l('Claims never checked out') %]</option>
+        </select>
+      </div>
+      <div class="form-group nullable">
+        <label for="event-selector">[% l('Event') %]</label>
+        <select id="event-selector" class="form-control" ng-model="record.event"
+          ng-init="event_list = [{l:'[% l('Checkin') %]',v:'CHECKIN'},{l:'[% l('Checkout') %]',v:'CHECKOUT'}]"
+          ng-options="e.v as e.l for e in event_list">
+            <option value="">[% l('Any Event') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="select-org-unit">[% l('Scope Org Unit') %]</label>
+        <eg-org-selector selected="record.scope_org"></eg-org-selector>
+      </div>
+      <div class="form-group">
+        <label for="edit-alert-next-statuses">[% l('Next Status') %]</label>
+        <select id="edit-alert-next-statuses" class="form-control" focus-me='focusMe'
+                multiple="multiple" ng-model="record.next_status">
+            <option ng-repeat="s in ccs" value="{{s.id()}}">{{s.name()}}</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="inrenew-selector">[% l('Renewing?') %]</label>
+        <select id="inrenew-selector" class="form-control" ng-model="record.in_renew">
+            <option value="">[% l('Any') %]</option>
+            <option value="t">[% l('Yes') %]</option>
+            <option value="f">[% l('No') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="invert-location-selector">[% l('Invert location?') %]</label>
+        <select id="invert-location-selector" class="form-control" ng-model="record.invert_location">
+            <option value="t">[% l('Yes') %]</option>
+            <option value="f">[% l('No') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="at-circ-selector">[% l('At Circulation Library?') %]</label>
+        <select id="at-circ-selector" class="form-control" ng-model="record.at_circ">
+            <option value="">[% l('Do not care') %]</option>
+            <option value="t">[% l('Yes') %]</option>
+        </select>
+      </div>
+      <div class="form-group">
+        <label for="at-owning-selector">[% l('At Owning Library?') %]</label>
+        <select id="at-owning-selector" class="form-control" ng-model="record.at_owning">
+            <option value="">[% l('Do not care') %]</option>
+            <option value="t">[% l('Yes') %]</option>
+        </select>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <input type="submit" ng-disabled="form.$invalid" 
+          class="btn btn-primary" value="[% l('Save') %]"/>
+      <button class="btn btn-warning" ng-click="cancel()">[% l('Cancel') %]</button>
+    </div>
+  </div> <!-- modal-content -->
+</form>
index 02d604f..6200f6e 100644 (file)
@@ -7,8 +7,14 @@
 
 [% BLOCK APP_JS %]
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/services/eframe.js"></script>
+<script src="[% ctx.media_prefix %]/js/ui/default/staff/services/grid.js"></script>
 <script src="[% ctx.media_prefix %]/js/ui/default/staff/admin/local/app.js"></script>
 <link rel="stylesheet" href="[% ctx.base_path %]/staff/css/admin.css" />
+<script>
+angular.module('egCoreMod').run(['egStrings', function(s) {
+  s.REMOVE_ITEM_CONFIRM = '[% l('Delete rows?') %]';
+}]);
+</script
 [% END %]
 
 <div ng-view></div>
diff --git a/Open-ILS/src/templates/staff/admin/local/t_grid_editor.tt2 b/Open-ILS/src/templates/staff/admin/local/t_grid_editor.tt2
new file mode 100644 (file)
index 0000000..54f3da0
--- /dev/null
@@ -0,0 +1,12 @@
+<eg-grid
+  id-field="id"
+  idl-class="{{baseFmClass}}"
+  features="-multisort,-multiselect"
+  grid-controls="gridControls"
+  auto-fields="true"
+  persist-key="admin.local.config.grideditor.{{baseFmClass}}">
+
+  <eg-grid-menu-item label="[% l('Create') %]" handler="createHandler"></eg-grid-menu-item>
+  <eg-grid-action handler="editHandler" label="[% l('Edit') %]"></eg-grid-action>
+  <eg-grid-action handler="deleteHandler" label="[% l('Delete') %]"></eg-grid-action>
+</eg-grid>
index 82599b3..151f2bb 100644 (file)
@@ -17,6 +17,8 @@
     ,[ l('Circ Limit Sets'), "./admin/local/config/circ_limit_set" ]
     ,[ l('Circulation Policies'), "./admin/local/config/circ_matrix_matchpoint" ]
     ,[ l('Closed Dates Editor'), "./admin/local/actor/closed_dates" ]
+    ,[ l('Copy Alert Types'), "./admin/local/config/copy_alert_types" ]
+    ,[ l('Copy Alert Suppression'), "./admin/local/actor/copy_alert_suppress" ]
     ,[ l('Copy Location Groups'), "./admin/local/asset/copy_location_group" ]
     ,[ l('Copy Location Order'), "./admin/local/asset/copy_location_order" ]
     ,[ l('Copy Locations Editor'), "./admin/local/asset/copy_locations" ]
index b506f3f..c6bf4e0 100644 (file)
@@ -71,6 +71,8 @@
       label="[% l('Copies') %]"></eg-grid-action>
     <eg-grid-action handler="selectedHoldingsVolCopyAdd" group="[% l('Add') %]"
       label="[% l('Volumes and Copies') %]"></eg-grid-action>
+    <eg-grid-action handler="selectedHoldingsCopyAlertsAdd" group="[% l('Add') %]" disabled="vols_not_shown"
+      label="[% l('Copy Alerts') %]"></eg-grid-action>
 
     <eg-grid-action handler="selectedHoldingsVolEdit" group="[% l('Edit') %]"
       label="[% l('Volumes') %]"></eg-grid-action>
@@ -80,6 +82,8 @@
       label="[% l('Volumes and Copies') %]"></eg-grid-action>
     <eg-grid-action handler="replaceBarcodes" group="[% l('Edit') %]"
       label="[% l('Replace Barcodes') %]"></eg-grid-action>
+    <eg-grid-action handler="selectedHoldingsCopyAlertsManage" group="[% l('Edit') %]"
+      label="[% l('Manage Copy Alerts') %]"></eg-grid-action>
 
     <eg-grid-action handler="selectedHoldingsEmptyVolCopyDelete" group="[% l('Delete') %]" disabled="vols_not_shown"
       label="[% l('Empty Volumes') %]"></eg-grid-action>
     <eg-grid-field label="[% l('Holdable') %]"               datatype="bool" path="holdable"></eg-grid-field>
     <eg-grid-field label="[% l('Age-based Hold Protection') %]" path="age_protect.name"></eg-grid-field>
     <eg-grid-field label="[% l('Reference') %]"              datatype="bool" path="ref"></eg-grid-field>
+    <eg-grid-field label="[% l('Alerts') %]" path="copy_alert_count" handlers="gridCellHandlers" visible compiled>
+      {{item['copy_alert_count']}}
+      <button ng-disabled="item['copy_alert_count'] <= 0" class="btn btn-sm btn-default" ng-click="col.handlers.copyAlertsEdit(item['id'])">[% l('Manage') %]</button>
+    </eg-grid-field>
   
   </eg-grid>
 </div>
index eb584c9..5a5d5be 100644 (file)
@@ -46,6 +46,8 @@
     label="[% l('Items') %]"></eg-grid-action>
   <eg-grid-action handler="selectedHoldingsVolCopyAdd" group="[% l('Add') %]"
     label="[% l('Volumes and Items') %]"></eg-grid-action>
+  <eg-grid-action handler="selectedHoldingsCopyAlertsAdd" group="[% l('Add') %]"
+    label="[% l('Copy Alerts') %]"></eg-grid-action>
 
   <eg-grid-action handler="selectedHoldingsVolEdit" group="[% l('Edit') %]"
     label="[% l('Volumes') %]"></eg-grid-action>
@@ -55,6 +57,8 @@
     label="[% l('Volumes and Items') %]"></eg-grid-action>
   <eg-grid-action handler="replaceBarcodes" group="[% l('Edit') %]"
     label="[% l('Replace Barcodes') %]"></eg-grid-action>
+  <eg-grid-action handler="selectedHoldingsCopyAlertsEdit" group="[% l('Edit') %]"
+    label="[% l('Manage Copy Alerts') %]"></eg-grid-action>
 
   <eg-grid-action handler="changeItemOwningLib" group="[% l('Transfer') %]"
     label="[% l('Items to Previously Marked Library') %]"></eg-grid-action>
@@ -75,7 +79,6 @@
     </a>
   </eg-grid-field>
 
-
   <eg-grid-field label="[% l('Acquisition Cost') %]"     path="cost" hidden></eg-grid-field>
   <eg-grid-field label="[% l('Age-Based Hold Protection') %]"  path="age_protect" hidden></eg-grid-field>
   <eg-grid-field label="[% l('Author') %]"               path="call_number.record.simple_record.author"  hidden></eg-grid-field>
   <eg-grid-field label="[% l('TCN') %]"                   path="call_number.record.tcn_value" hidden></eg-grid-field>
   <eg-grid-field label="[% l('TCN Source') %]"            path="call_number.record.tcn_source" hidden></eg-grid-field>
   <eg-grid-field label="[% l('Transaction Complete') %]"  path="_circ.xact_finish" datatype="timestamp" hidden></eg-grid-field>
-</eg-grid>
+  <eg-grid-field label="[% l('Alerts') %]" path="copy_alert_count" handlers="gridCellHandlers" visible compiled>
+    {{item['copy_alert_count']}}
+    <button ng-disabled="item['copy_alert_count'] <= 0" class="btn btn-sm btn-default" ng-click="col.handlers.copyAlertsEdit(item['id'])">[% l('Manage') %]</button>
+  </eg-grid-field>
+  
 </eg-grid>
 
 <div class="flex-row pad-vert">
index 4b9abe3..9e37939 100644 (file)
   </div>
 
   <div class="flex-row">
-    <div class="flex-cell">[% l('Alert Message') %]</div>
-    <div class="well" style="flex:7">{{copy.alert_message()}}</div>
+    <div class="flex-cell">[% l('Copy Alerts') %]</div>
+    <div id="item-status-alert-msg">
+      <button class="btn btn-default" ng-click="addCopyAlerts(copy.id())" >[% l('Add') %]</button>
+      <button class="btn btn-default" ng-click="manageCopyAlerts(copy.id())" >[% l('Manage') %]</button>
+    </div>
   </div>
 
 </div>
index 5b9181e..3d60493 100644 (file)
                       type="button">
                         [% l('Copy Notes') %]
                     </button>
+                    <button
+                      class="btn btn-default"
+                      ng-disabled="!defaults.copy_alerts"
+                      ng-click="copy_alerts_dialog(workingGridControls.selectedItems())"
+                      type="button">
+                        [% l('Copy Alerts') %]
+                    </button>
                 </div>
             </div>
 
diff --git a/Open-ILS/src/templates/staff/cat/volcopy/t_copy_alerts.tt2 b/Open-ILS/src/templates/staff/cat/volcopy/t_copy_alerts.tt2
new file mode 100644 (file)
index 0000000..b86fef3
--- /dev/null
@@ -0,0 +1,94 @@
+<form ng-submit="ok(copy_alert)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('New Copy Alert') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-6 form-inline">
+          <label for="copy-alert-type-selector"> [% l('Type') %]</label>
+          <select id="copy-alert-type-selector" class="form-control"
+            ng-model="copy_alert.alert_type"
+            ng-options="at.id() as at.name() for at in alert_types">
+          </select>
+        </div>
+        <div class="col-md-3">
+          <label>
+            <input type="checkbox" ng-model="copy_alert.temp"/>
+            [% l('Temporary') %]
+          </label>
+        </div>
+      </div>
+      <div class="row pad-vert">
+        <div class="col-md-12">
+          <textarea class="form-control" 
+            ng-model="copy_alert.note" placeholder="[% l('Alert...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <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="copy_alert_list.length &gt; 0"> 
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-12">
+              <hr/>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">
+              <h4 class="pull-left">[% l('Existing Copy Alerts') %]</h4>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="row" ng-repeat="a in copy_alert_list" ng-init="temp = (a.temp() == 't'); note = a.note(); acked = (a.ack_time() !== null); alert_type = a.alert_type().id()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 form-inline">
+              <label for="copy-alert-type-select-{{a.id()}}">[% l('Type') %]</label>
+              <select id="copy-alert-type-select-{{a.id()}}" class="form-control"
+                      ng-model="alert_type"
+                      ng-change="a.alert_type(alert_type) && a.ischanged(1)"
+                      ng-options="at.id() as at.name() for at in alert_types">
+              </select>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="temp" ng-change="a.temp(temp ? 't' : 'f') && a.ischanged(1)" ng-disabled="acked"/>
+                [% l('Temporary') %]
+              </label>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="acked" ng-change="(acked ? a.ack_time('now') : a.ack_time(null)) && a.ischanged(1)"/>
+                [% l('Clear?') %]
+              </label>
+            </div>
+          </div>
+          <div class="row pad-vert">
+            <div class="col-md-12">
+              <textarea class="form-control" ng-change="a.note(note) && a.ischanged(1)"
+                ng-model="note" placeholder="[% l('Alert...') %]" ng-disabled="acked">
+              </textarea>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">
+              <hr/>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+</form>
index 89e4ebe..77316e6 100644 (file)
                     </label>
                 </div>
             </div>
+
+            <div class="row">
+                <div class="col-xs-6">
+                    <label>
+                        <input type="checkbox" ng-change="saveDefaults()" ng-model="defaults.copy_alerts"/>
+                        [% l('Add/Edit Copy Alerts') %]
+                    </label>
+                </div>
+            </div>
         </div>
 
         <div class="col-md-4">
index 8ff4853..6c7aab0 100644 (file)
     handler="abortTransit"
     label="[% l('Cancel Transits') %]">
   </eg-grid-action>
+  <eg-grid-action 
+    handler="addCopyAlerts"
+    label="[% l('Add Copy Alerts') %]">
+  </eg-grid-action>
+  <eg-grid-action 
+    handler="manageCopyAlerts"
+    label="[% l('Manage Copy Alerts') %]">
+  </eg-grid-action>
+
   <!-- Show Group -->
   <eg-grid-action handler="showBibHolds" group="[% l('Show') %]"
     label="[% l('Record Holds') %]">
@@ -47,8 +56,6 @@
   <eg-grid-action handler="printSpineLabels" group="[% l('Print') %]"
     label="[% l('Spine Labels') %]">
   </eg-grid-action>
-  <eg-grid-field label="[% l('Alert Msg') %]"   
-    path="acp.alert_message"></eg-grid-field>
 
   <eg-grid-field label="[% l('Balance Owed') %]"     
     path='mbts.balance_owed' comparator="sort_money"></eg-grid-field>
index ebcfb44..f52451d 100644 (file)
   persist-key="circ.patron.checkout"
   dateformat="{{$root.egDateAndTimeFormat}}">
 
-  <eg-grid-field label="[% l('Alert Msg') %]"   
-    path="acp.alert_message"></eg-grid-field>
+  <eg-grid-action
+    handler="addCopyAlerts"
+    label="[% l('Add Copy Alerts') %]">
+  </eg-grid-action>
+  <eg-grid-action
+    handler="manageCopyAlerts"
+    label="[% l('Manage Copy Alerts') %]">
+  </eg-grid-action>
 
   <eg-grid-field label="[% l('Balance Owed') %]"     
     path='mbts.balance_owed'></eg-grid-field>
   <eg-grid-field path="acp.circ_modifier.name" label="[% l('Circulation Modifier') %]"></eg-grid-field>
   <eg-grid-field path="acp.circ_lib.shortname" label="[% l('Circulation Library') %]"></eg-grid-field>
   <eg-grid-field path="acn.owning_lib.shortname" label="[% l('Owning Library') %]"></eg-grid-field>
+
+  <eg-grid-field label="[% l('Alerts') %]" path="copy_alert_count" handlers="gridCellHandlers" visible compiled>
+    {{item['copy_alert_count']}}
+    <button ng-disabled="item['copy_alert_count'] <= 0" class="btn btn-sm btn-default" ng-click="col.handlers.copyAlertsEdit(item['acp'].id())">[% l('Manage') %]</button>
+  </eg-grid-field>
+
   <eg-grid-field path="circ.*" parent-idl-class="circ" hidden></eg-grid-field>
   <eg-grid-field path="acp.*" parent-idl-class="acp" hidden></eg-grid-field>
   <eg-grid-field path="acn.*" parent-idl-class="acn" hidden></eg-grid-field>
index 344e851..41866f4 100644 (file)
     handler="abortTransit"
     label="[% l('Cancel Transits') %]">
   </eg-grid-action>
+  <eg-grid-action divider="true"></eg-grid-action>
+  <eg-grid-action
+    handler="addCopyAlerts"
+    label="[% l('Add Copy Alerts') %]">
+  </eg-grid-action>
+  <eg-grid-action
+    handler="manageCopyAlerts"
+    label="[% l('Manage Copy Alerts') %]">
+  </eg-grid-action>
 
 
-  <eg-grid-field label="[% l('Alert Msg') %]"   
-    path="acp.alert_message"></eg-grid-field>
-
   <eg-grid-field label="[% l('Balance Owed') %]"     
     path='mbts.balance_owed' comparator="sort_money"></eg-grid-field>
 
index 6ad5c78..6675b99 100644 (file)
@@ -34,7 +34,29 @@ s.COPY_IN_TRANSIT = '[% l("Copy is In-Transit") %]';
 s.TOO_MANY_CLAIMS_RETURNED = 
   '[% l("Patron exceeds claims returned count.  Force this action?") %]';
 s.MARK_NEVER_CHECKED_OUT = 
-  '[% l("Mark Never Checked Out: [_1]", "{{barcodes.toString()}}") %]'
+  '[% l("Mark Never Checked Out: [_1]", "{{barcodes.toString()}}") %]';
+s.ON_DEMAND_COPY_ALERT = {
+    'CHECKIN': {
+        'NORMAL' : '[% l("Normal checkin") %]',
+        'LOST' : '[% l("Copy was marked lost") %]',
+        'LOST_AND_PAID' : '[% l("Copy was marked lost and paid for") %]',
+        'MISSING' : '[% l("Copy was marked missing") %]',
+        'DAMAGED' : '[% l("Copy was marked damaged") %]',
+        'CLAIMSRETURNED' : '[% l("Copy was marked claims returned") %]',
+        'LONGOVERDUE' : '[% l("Copy was marked long overdue") %]',
+        'CLAIMSNEVERCHECKEDOUT' : '[% l("Copy was marked claims never checked out") %]'
+    },
+    'CHECKOUT': {
+        'NORMAL' : '[% l("Normal checkout") %]',
+        'LOST' : '[% l("Copy was marked lost") %]',
+        'LOST_AND_PAID' : '[% l("Copy was marked lost and paid for") %]',
+        'MISSING' : '[% l("Copy was marked missing") %]',
+        'DAMAGED' : '[% l("Copy was marked damaged") %]',
+        'CLAIMSRETURNED' : '[% l("Copy was marked claims returned") %]',
+        'LONGOVERDUE' : '[% l("Copy was marked long overdue") %]',
+        'CLAIMSNEVERCHECKEDOUT' : '[% l("Copy was marked claims never checked out") %]'
+    }
+};
 }]);
 </script>
 
index b20f720..7d32275 100644 (file)
@@ -179,6 +179,10 @@ table.list tr.selected td { /* deprecated? */
 /* barcode inputs are everywhere.  Let's have a consistent style. */
 .barcode { width: 16em !important; }
 
+/* use strike-through to mark something that has been acknowledged,
+   e.g., a copy alert */
+.acknowledged { text-decoration: line-through; }
+
 /* bootstrap alerts are heavily padded.  use this to reduce */
 .alert-less-pad {padding: 5px;}
 
diff --git a/Open-ILS/src/templates/staff/share/t_add_copy_alert_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_add_copy_alert_dialog.tt2
new file mode 100644 (file)
index 0000000..0b97a3f
--- /dev/null
@@ -0,0 +1,40 @@
+<form ng-submit="ok(copy_alert)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Add Copy Alert') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row">
+        <div class="col-md-6 form-inline">
+          <label for="copy-alert-type-selector"> [% l('Type') %]</label>
+          <select id="copy-alert-type-selector" class="form-control"
+            ng-model="copy_alert.alert_type"
+            ng-options="at.id() as at.name() for at in alert_types">
+          </select>
+        </div>
+        <div class="col-md-3">
+          <label>
+            <input type="checkbox" ng-model="copy_alert.temp"/>
+            [% l('Temporary') %]
+          </label>
+        </div>
+      </div>
+      <div class="row pad-vert">
+        <div class="col-md-12">
+          <textarea class="form-control" 
+            ng-model="copy_alert.note" placeholder="[% l('Alert...') %]">
+          </textarea>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <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>
+</form>
index c6b45d9..4a91d0c 100644 (file)
 <div class="eg-grid" ng-class="{'eg-grid-as-conf' : showGridConf}">
 
   <!-- import our eg-grid-field defs -->
-  <div ng-transclude></div>
+  <div style="display: none;" ng-transclude></div>
 
   <div class="eg-grid-row eg-grid-header-row">
     <div class="eg-grid-cell eg-grid-cell-stock" ng-show="showIndex">
 
           <!-- if the cell comes with its own template,
                translate that content into HTML and insert it here -->
-          <span ng-if="col.template" style="padding-left:5px; padding-right:10px;"
+          <span ng-if="col.template && !col.compiled" style="padding-left:5px; padding-right:10px;"
             ng-bind-html="translateCellTemplate(col, item)">
           </span>
 
+          <span ng-if="col.template && col.compiled" style="padding-left:5px; padding-right:10px;"
+            compile="col.template">
+          </span>
+
           <!-- otherwise, simply display the item value, which may 
                pass through datatype-specific filtering. -->
           <span ng-if="!col.template" style="padding-left:5px; padding-right:10px;">
diff --git a/Open-ILS/src/templates/staff/share/t_copy_alert_editor_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_copy_alert_editor_dialog.tt2
new file mode 100644 (file)
index 0000000..aad1a6b
--- /dev/null
@@ -0,0 +1,55 @@
+<form ng-submit="ok(copy_alert)" role="form">
+    <div class="modal-header">
+      <button type="button" class="close" ng-click="cancel()" 
+        aria-hidden="true">&times;</button>
+      <h4 class="modal-title">[% l('Manage Copy Alerts') %]</h4>
+    </div>
+    <div class="modal-body">
+      <div class="row" ng-repeat="a in copy_alert_list" ng-init="temp = (a.temp() == 't'); note = a.note(); acked = (a.ack_time() !== null); alert_type = a.alert_type().id()">
+        <div class="col-md-12">
+          <div class="row">
+            <div class="col-md-6 form-inline">
+              <label for="copy-alert-type-select-{{a.id()}}">[% l('Type') %]</label>
+              <select id="copy-alert-type-select-{{a.id()}}" class="form-control"
+                      ng-model="alert_type"
+                      ng-change="a.alert_type(alert_type) && a.ischanged(1)"
+                      ng-options="at.id() as at.name() for at in alert_types">
+              </select>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="temp" ng-change="a.temp(temp ? 't' : 'f') && a.ischanged(1)" ng-disabled="acked"/>
+                [% l('Temporary') %]
+              </label>
+            </div>
+            <div class="col-md-3">
+              <label>
+                <input type="checkbox" ng-model="acked" ng-change="(acked ? a.ack_time('now') : a.ack_time(null)) && a.ischanged(1)"/>
+                [% l('Clear?') %]
+              </label>
+            </div>
+          </div>
+          <div class="row pad-vert">
+            <div class="col-md-12">
+              <textarea class="form-control" ng-change="a.note(note) && a.ischanged(1)"
+                ng-model="note" placeholder="[% l('Alert...') %]" ng-disabled="acked">
+              </textarea>
+            </div>
+          </div>
+          <div class="row">
+            <div class="col-md-12">
+              <hr/>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="modal-footer">
+      <div class="row">
+        <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>
+</form>
diff --git a/Open-ILS/src/templates/staff/share/t_copy_alert_manager_dialog.tt2 b/Open-ILS/src/templates/staff/share/t_copy_alert_manager_dialog.tt2
new file mode 100644 (file)
index 0000000..843ea0d
--- /dev/null
@@ -0,0 +1,49 @@
+<!--
+  Copy alert manager dialog
+-->
+<div>
+  <div class="modal-header">
+    <h4 class="modal-title alert alert-info">[% l('Copy alerts') %]</h4> 
+  </div>
+  <div class="modal-body">
+    <div>
+      <div class="row" ng-repeat="alert in alerts" style="border-bottom: 1px solid grey; margin-top: 3px;">
+        <div class="col-md-2">{{alert.evt}}</div>
+        <div class="col-md-8" ng-class="{ acknowledged: isAcknowledged(alert) }">{{alert.message}}</div>
+        <div class="col-md-2">
+          <button ng-if="canBeAcknowledged(alert)"
+                  class="btn btn-xs btn-default"
+                  ng-click="alert.acked = !alert.acked" >[% l('Clear') %]</button>
+          <button ng-if="canBeRemoved(alert) && mode == 'manage'"
+                  class="btn btn-xs btn-default"
+                  ng-click="alert.acked = !alert.acked" >[% l('Remove') %]</button>
+        </div>
+      </div>
+    </div>
+    <div ng-if="mode == 'checkin' && next_statuses.length > 0">
+        <div ng-if="next_statuses.length == 1" class="row">
+            <div class="col-md-8">
+                <b>[% l('Next copy status: ') %]</b> {{next_statuses[0].name()}}
+            </div>
+        </div>
+        <div ng-if="next_statuses.length > 1" class="row">
+          <div class="col-md-4">
+            <label for="select-next-status"><b>[% l('Next copy status') %]<b></label>
+          </div>
+          <div class="col-md-4">
+            <select id="select-next-status" class="form-control"
+                    ng-model="params.the_next_status" focus-me="true"
+                    ng-options="st.id() as st.name() for st in next_statuses">
+            </select>
+          </select>
+        </div>
+    </div>
+  </div>
+  <div class="modal-footer">
+    [% dialog_footer %]
+    <input type="submit" class="btn btn-primary" 
+      ng-click="ok()" value="[% l('OK/Continue') %]"/>
+    <button class="btn btn-warning" 
+      ng-click="cancel()">[% l('Cancel') %]</button>
+  </div>
+</div>
index f2b286f..8758257 100644 (file)
@@ -1,5 +1,5 @@
 angular.module('egLocalAdmin',
-    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod'])
+    ['ngRoute', 'ui.bootstrap', 'egCoreMod','egUiMod','egGridMod'])
 
 .config(['$routeProvider','$locationProvider','$compileProvider', 
  function($routeProvider , $locationProvider , $compileProvider) {
@@ -52,6 +52,29 @@ angular.module('egLocalAdmin',
         resolve : resolver
     });
 
+    $routeProvider.when('/admin/local/config/copy_alert_types', {
+        templateUrl: './admin/local/t_grid_editor',
+        controller: 'AutoGridEditorCtl',
+        fmBase: 'ccat',
+        createEditPrefetch: {
+            ccs : { id : {'!=' : null} }
+        },
+        createDefaults : { 'in_renew' : 'f', 'next_status' : [] },
+        createEditOrgExpand: ['scope_org'],
+        createEditIntarray: ['next_status'],
+        createEditNullableBool : ['in_renew', 'at_circ', 'at_owning']
+    });
+
+    $routeProvider.when('/admin/local/actor/copy_alert_suppress', {
+        templateUrl: './admin/local/t_grid_editor',
+        controller: 'AutoGridEditorCtl',
+        fmBase: 'acas',
+        createEditPrefetch: {
+            ccat : { active: 't' }
+        },
+        createEditOrgExpand: ['org']
+    });
+
     // Conify page handler
     $routeProvider.when('/admin/local/:schema/:page', {
         template: eframe_template,
@@ -106,3 +129,138 @@ function($scope , $location , egCore , $timeout) {
     $scope.local_admin_url = $location.absUrl().replace(/\/.*/, url);
 }])
 
+.controller('AutoGridEditorCtl',
+       ['$scope','$route','$location','egCore','$timeout','egConfirmDialog','$uibModal',
+function($scope , $route , $location , egCore , $timeout , egConfirmDialog , $uibModal) {
+
+    $scope.funcs = {};
+
+    $scope.baseFmClass = $route.current.$$route.fmBase;
+    $scope.createEditPrefetch = $route.current.$$route.createEditPrefetch || {};
+    $scope.createEditOrgExpand = $route.current.$$route.createEditOrgExpand || [];
+    $scope.createEditNullableBool = $route.current.$$route.createEditNullableBool || [];
+    $scope.createEditIntarray = $route.current.$$route.createEditIntarray || [];
+    $scope.createDefaults = $route.current.$$route.createDefaults || {};
+    $scope.gridControls = {
+        setQuery : function(q) {
+            if (q) query = q;
+            return query;
+        },
+        activateItem : function (item) {
+            $scope.editHandler([item])
+        }
+    };
+    $scope.gridControls.setQuery({id : {'!=' : null}});
+
+    function openCreateEditDialog(id) {
+        return $uibModal.open({
+            templateUrl : './admin/local/autoGridEditor/' + $scope.baseFmClass,
+            scope : $scope,
+            controller :
+                ['$scope', '$uibModalInstance', function($scope, $uibModalInstance) {
+                $scope.creating = id ? false : true;
+                angular.forEach($scope.$parent.createEditPrefetch, function(where, fmClass) {
+                    egCore.pcrud.search(
+                        fmClass, where, {},
+                        {atomic : true, authoritative : true}
+                    ).then(function(vals) {
+                        $scope[fmClass] = vals;
+                    });
+                });
+                if ($scope.creating) {
+                    $scope.record = $scope.createDefaults;
+                } else {
+                    egCore.pcrud.retrieve($scope.baseFmClass, id).then(function(to_edit) {
+                        $scope.record = egCore.idl.toHash(to_edit);
+                        angular.forEach($scope.createEditOrgExpand, function(ou_field) {
+                            $scope.record[ou_field] = egCore.org.get($scope.record[ou_field]);
+                        });
+                        angular.forEach($scope.createEditIntarray, function(intarray_field) {
+                            if (!($scope.record[intarray_field] == null) && $scope.record[intarray_field] != "") {
+                                $scope.record[intarray_field] = $scope.record[intarray_field]
+                                                    .replace('{', '')
+                                                    .replace('}', '')
+                                                    .split(',');
+                            } else {
+                                $scope.record[intarray_field] = [];
+                            }
+                        });
+                    });
+                }
+                $scope.ok = function(record) { $uibModalInstance.close(record) };
+                $scope.cancel = function () { $uibModalInstance.dismiss() }
+            }]
+        });
+    }
+
+    $scope.createHandler = function() {
+        openCreateEditDialog().result.then(function(record) {
+            var newRec = new egCore.idl[$scope.baseFmClass]();
+            angular.forEach(record, function(val, key) {
+                if (typeof(val) === 'object' && !angular.isArray(val)) {
+                    newRec[key](val.id());
+                } else {
+                    newRec[key](val);
+                }
+            });
+            angular.forEach($scope.createEditNullableBool, function(nb_field) {
+                if (!(record[nb_field] == null) && record[nb_field] == "")
+                    newRec[nb_field](null);
+            });
+            angular.forEach($scope.createEditIntarray, function(intarray_field) {
+                if (newRec[intarray_field]().length > 0) {
+                    newRec[intarray_field]('{' + newRec[intarray_field]().join(',') + '}');
+                } else {
+                    newRec[intarray_field](null);
+                }
+            });
+            return egCore.pcrud.create(newRec);
+        }).then(function(){
+            $scope.gridControls.refresh();
+        });
+    };
+    $scope.editHandler = function(items) {
+        openCreateEditDialog(items[0].id).result.then(function(record) {
+            var editedRec = new egCore.idl[$scope.baseFmClass]();
+            angular.forEach(record, function(val, key) {
+                if (angular.isObject(val) && !angular.isArray(val)) {
+                    editedRec[key](val.id());
+                } else {
+                    editedRec[key](val);
+                }
+            });
+            angular.forEach($scope.createEditNullableBool, function(nb_field) {
+                if (!(record[nb_field] == null) && record[nb_field] == "")
+                    editedRec[nb_field](null);
+            });
+            angular.forEach($scope.createEditIntarray, function(intarray_field) {
+                if (editedRec[intarray_field]().length > 0) {
+                    editedRec[intarray_field]('{' + editedRec[intarray_field]().join(',') + '}');
+                } else {
+                    editedRec[intarray_field](null);
+                }
+            });
+            return egCore.pcrud.update(editedRec);
+        }).then(function(){
+            $scope.gridControls.refresh();
+        });
+    };
+    $scope.deleteHandler = function(items) {
+        egConfirmDialog.open(
+            egCore.strings.REMOVE_ITEM_CONFIRM,
+            '',
+            {}
+        ).result.then(function() {
+            var ids = items.map(function(s){ return s.id });
+            egCore.pcrud.search(
+                $scope.baseFmClass, {id : ids}, {},
+                {atomic : true, authoritative : true}
+            ).then(function(to_delete) {
+                return egCore.pcrud.remove(to_delete);
+            }).then(function() {
+                $scope.gridControls.refresh();
+            });
+        });
+    };
+}])
+
index 63b9c04..4b3d186 100644 (file)
@@ -1477,6 +1477,13 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
+    $scope.gridCellHandlers = {};
+    $scope.gridCellHandlers.copyAlertsEdit = function(id) {
+        egCirc.manage_copy_alerts([id]).then(function() {
+            // update grid items?
+        });
+    };
+
     $scope.transferItems = function (){
         var xfer_target = egCore.hatch.getLocalItem('eg.cat.item_transfer_target');
         var copy_ids = gatherSelectedHoldingsIds();
@@ -1586,6 +1593,17 @@ function($scope , $routeParams , $location , $window , $q , egCore , egHolds , e
         });
     }
 
+    $scope.selectedHoldingsCopyAlertsAdd = function() {
+        egCirc.add_copy_alerts(gatherSelectedHoldingsIds()).then(function() {
+            // no need to refresh grid
+        });
+    }
+    $scope.selectedHoldingsCopyAlertsManage = function() {
+        egCirc.manage_copy_alerts(gatherSelectedHoldingsIds()).then(function() {
+            // no need to refresh grid
+        });
+    }
+
     $scope.attach_to_peer_bib = function() {
         var copy_list = gatherSelectedHoldingsIds();
         if (copy_list.length == 0) return;
index be8e797..2a3d7a7 100644 (file)
@@ -455,6 +455,33 @@ function($scope , $q , $routeParams , $location , $timeout , $window , egCore ,
         itemSvc.spawnHoldingsAdd(copyGrid.selectedItems(),false,true);
     }
 
+    $scope.selectedHoldingsCopyAlertsAdd = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.id) copy_ids.push(item.id);
+        });
+        egCirc.add_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.selectedHoldingsCopyAlertsEdit = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.id) copy_ids.push(item.id);
+        });
+        egCirc.manage_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.gridCellHandlers = {};
+    $scope.gridCellHandlers.copyAlertsEdit = function(id) {
+        egCirc.manage_copy_alerts([id]).then(function() {
+            // update grid items?
+        });
+    };
+
     $scope.showBibHolds = function () {
         angular.forEach(gatherSelectedRecordIds(), function (r) {
             var url = egCore.env.basePath + 'cat/catalog/record/' + r + '/holds';
@@ -522,8 +549,8 @@ function($scope , $q , $routeParams , $location , $timeout , $window , egCore ,
  * Detail view -- shows one copy
  */
 .controller('ViewCtrl', 
-       ['$scope','$q','$location','$routeParams','$timeout','$window','egCore','egItem','egBilling',
-function($scope , $q , $location , $routeParams , $timeout , $window , egCore , itemSvc , egBilling) {
+       ['$scope','$q','$location','$routeParams','$timeout','$window','egCore','egItem','egBilling','egCirc',
+function($scope , $q , $location , $routeParams , $timeout , $window , egCore , itemSvc , egBilling , egCirc) {
     var copyId = $routeParams.id;
     $scope.args.copyId = copyId;
     $scope.tab = $routeParams.tab || 'summary';
@@ -940,6 +967,17 @@ function($scope , $q , $location , $routeParams , $timeout , $window , egCore ,
         return;
     }
 
+    $scope.addCopyAlerts = function(copy_id) {
+        egCirc.add_copy_alerts([copy_id]).then(function() {
+            // update grid items?
+        });
+    }
+    $scope.manageCopyAlerts = function(copy_id) {
+        egCirc.manage_copy_alerts([copy_id]).then(function() {
+            // update grid items?
+        });
+    }
+
     $scope.context.toggleDisplay = function() {
         $location.path('/cat/item/search');
     }
index f0e0185..e6cc145 100644 (file)
@@ -15,7 +15,7 @@ function(egCore , $q) {
     service.prototype.flesh = {   
         flesh : 2, 
         flesh_fields : {
-            acp : ['status','location','circ_lib','parts','age_protect'],
+            acp : ['status','location','circ_lib','parts','age_protect','copy_alerts'],
             acn : ['prefix','suffix','copies']
         }
     }
@@ -117,6 +117,11 @@ function(egCore , $q) {
                     }
                 });
 
+                // create virtual field for copy alert count
+                angular.forEach(svc.copies, function (cp) {
+                    cp.copy_alert_count = cp.copy_alerts.length;
+                });
+
                 // create a label using just the unique part of the owner list
                 var index = 0;
                 var prev_owner_list;
index 39e4aaf..03cde64 100644 (file)
@@ -136,6 +136,21 @@ function(egCore , $q) {
         );
     };
 
+    service.get_copy_alert_types = function(orgs) {
+        return egCore.pcrud.search('ccat',
+            { active : 't' },
+            {},
+            { atomic : true }
+        );
+    };
+
+    service.get_copy_alerts = function(copy_id) {
+        return egCore.pcrud.search('aca', { copy : copy_id, ack_time : null },
+            { flesh : 1, flesh_fields : { aca : ['alert_type'] } },
+            { atomic : true }
+        );
+    };
+
     service.get_locations = function(orgs) {
         return egCore.pcrud.search('acpl',
             {owning_lib : orgs, deleted : 'f'},
@@ -374,6 +389,10 @@ function(egCore , $q) {
 
         if (!cp.parts()) cp.parts([]); // just in case...
 
+        service.get_copy_alerts(cp.id()).then(function(aca) {
+            cp.copy_alerts(aca);
+        });
+
         var lib = cp.call_number().owning_lib();
         var cn = cp.call_number().id();
 
@@ -907,6 +926,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         statcats : true,
         copy_notes : true,
         copy_tags : true,
+        copy_alerts : true,
         attributes : {
             status : true,
             loan_duration : true,
@@ -1101,6 +1121,58 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         statcat_filter: undefined
     };
 
+    $scope.copyAlertUpdate = function (alerts) {
+        if (!$scope.in_item_select &&
+            $scope.workingGridControls &&
+            $scope.workingGridControls.selectedItems) {
+            itemSvc.get_copy_alert_types().then(function(ccat) {
+                var ccat_map = {};
+                $scope.alert_types = ccat;
+                angular.forEach(ccat, function(t) {
+                    ccat_map[t.id()] = t;
+                });
+                angular.forEach(
+                    $scope.workingGridControls.selectedItems(),
+                    function (cp) {
+                        $scope.dirty = true;
+                        angular.forEach(alerts, function(alrt) {
+                            var a = egCore.idl.fromHash('aca', alrt);
+                            a.isnew(1);
+                            a.create_staff(egCore.auth.user().id());
+                            a.alert_type(ccat_map[a.alert_type()]);
+                            a.ack_time(null);
+                            a.copy(cp.id());
+                            cp.copy_alerts().push( a );
+                        });
+                        cp.ischanged(1);
+                    }
+                );
+            });
+        }
+    };
+
+    $scope.copyNoteUpdate = function (notes) {
+        if (!$scope.in_item_select &&
+            $scope.workingGridControls &&
+            $scope.workingGridControls.selectedItems) {
+            angular.forEach(
+                $scope.workingGridControls.selectedItems(),
+                function (cp) {
+                    $scope.dirty = true;
+                    angular.forEach(notes, function(note) {
+                        var n = egCore.idl.fromHash('acpn', note);
+                        n.isnew(1);
+                        n.creator(egCore.auth.user().id());
+                        n.owning_copy(cp.id());
+                        cp.notes().push( n );
+                    });
+                    cp.ischanged(1);
+                }
+            );
+
+        }
+    }
+
     $scope.statcatUpdate = function (id) {
         var newval = $scope.working.statcats[id];
 
@@ -1184,6 +1256,10 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
             angular.forEach($scope.templates[n], function (v,k) {
                 if (k == 'circ_lib') {
                     $scope.working[k] = egCore.org.get(v);
+                } else if (k == 'copy_notes' && v.length) {
+                    $scope.copyNoteUpdate(v);
+                } else if (k == 'copy_alerts' && v.length) {
+                    $scope.copyAlertUpdate(v);
                 } else if (!angular.isObject(v)) {
                     $scope.working[k] = angular.copy(v);
                 } else {
@@ -1936,6 +2012,75 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         });
     }
 
+    $scope.copy_alerts_dialog = function(copy_list) {
+        if (!angular.isArray(copy_list)) copy_list = [copy_list];
+
+        return $uibModal.open({
+            templateUrl: './cat/volcopy/t_copy_alerts',
+            animation: true,
+            controller:
+                   ['$scope','$uibModalInstance',
+            function($scope , $uibModalInstance) {
+
+                itemSvc.get_copy_alert_types().then(function(ccat) {
+                    $scope.alert_types = ccat;
+                });
+
+                $scope.focusNote = true;
+                $scope.copy_alert = {
+                    create_staff : egCore.auth.user().id(),
+                    note         : '',
+                    temp         : false
+                };
+
+                egCore.hatch.getItem('cat.copy.alerts.last_type').then(function(t) {
+                    if (t) $scope.copy_alert.alert_type = t;
+                });
+
+                if (copy_list.length == 1) {
+                    $scope.copy_alert_list = copy_list[0].copy_alerts();
+                }
+
+                $scope.ok = function(copy_alert) {
+
+                    if (typeof(copy_alert.note) != 'undefined' &&
+                        copy_alert.note != '') {
+                        angular.forEach(copy_list, function (cp) {
+                            var a = new egCore.idl.aca();
+                            a.isnew(1);
+                            a.create_staff(copy_alert.create_staff);
+                            a.note(copy_alert.note);
+                            a.temp(copy_alert.temp ? 't' : 'f');
+                            a.copy(cp.id());
+                            a.ack_time(null);
+                            a.alert_type(
+                                $scope.alert_types.filter(function(at) {
+                                    return at.id() == copy_alert.alert_type;
+                                })[0]
+                            );
+                            cp.copy_alerts().push( a );
+                        });
+
+                        if (copy_alert.alert_type) {
+                            egCore.hatch.setItem(
+                                'cat.copy.alerts.last_type',
+                                copy_alert.alert_type
+                            );
+                        }
+
+                    }
+
+                    $uibModalInstance.close();
+                }
+
+                $scope.cancel = function($event) {
+                    $uibModalInstance.dismiss();
+                    $event.preventDefault();
+                }
+            }]
+        });
+    }
+
 }])
 
 .directive("egVolTemplate", function () {
@@ -1946,8 +2091,8 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
         scope: {
             editTemplates: '=',
         },
-        controller : ['$scope','$window','itemSvc','egCore','ngToast',
-            function ( $scope , $window , itemSvc , egCore , ngToast) {
+        controller : ['$scope','$window','itemSvc','egCore','ngToast','$uibModal',
+            function ( $scope , $window , itemSvc , egCore , ngToast , $uibModal) {
 
                 $scope.i18n = egCore.i18n;
 
@@ -1957,6 +2102,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                     statcats : true,
                     copy_notes : true,
                     copy_tags : true,
+                    copy_alerts : true,
                     attributes : {
                         status : true,
                         loan_duration : true,
@@ -2029,7 +2175,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                     angular.forEach($scope.templates[n], function (v,k) {
                         if (k == 'circ_lib') {
                             $scope.working[k] = egCore.org.get(v);
-                        } else if (!angular.isObject(v)) {
+                        } else if (angular.isArray(v) || !angular.isObject(v)) {
                             $scope.working[k] = angular.copy(v);
                         } else {
                             angular.forEach(v, function (sv,sk) {
@@ -2080,7 +2226,7 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                     }
                     ngToast.create(egCore.strings.VOL_COPY_TEMPLATE_SUCCESS_SAVE);
                 }
-            
+
                 $scope.templates = {};
                 $scope.imported_templates = { data : '' };
                 $scope.template_name = '';
@@ -2134,6 +2280,8 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                 }
             
                 $scope.working = {
+                    copy_notes: [],
+                    copy_alerts: [],
                     statcats: {},
                     statcat_filter: undefined
                 };
@@ -2213,7 +2361,142 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore ,
                         createStatcatUpdateWatcher(s.id());
                     });
                 });
+
+                $scope.copy_notes_dialog = function() {
+                    var default_pub = Boolean($scope.defaults.copy_notes_pub);
+                    var working = $scope.working;
+            
+                    return $uibModal.open({
+                        templateUrl: './cat/volcopy/t_copy_notes',
+                        animation: true,
+                        controller:
+                            ['$scope','$uibModalInstance',
+                        function($scope , $uibModalInstance) {
+                            $scope.focusNote = true;
+                            $scope.note = {
+                                title   : '',
+                                value   : '',
+                                pub     : default_pub,
+                            };
+
+                            $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 = [];
+                            angular.forEach(working.copy_notes, function(note) {
+                                var acpn = egCore.idl.fromHash('acpn', note);
+                                $scope.note_list.push(acpn);
+                            });
+
+                            $scope.ok = function(note) {
+
+                                if (!working.copy_notes) {
+                                    working.copy_notes = [];
+                                }
+
+                                // clear slate
+                                working.copy_notes.length = 0;
+                                angular.forEach($scope.note_list, function(existing_note) {
+                                    if (!existing_note.isdeleted()) {
+                                        working.copy_notes.push({
+                                            pub : existing_note.pub() ? 't' : 'f',
+                                            title : existing_note.title(),
+                                            value : existing_note.value()
+                                        });
+                                    }
+                                });
+
+                                // add new note, if any
+                                if (note.initials) note.value += ' [' + note.initials + ']';
+                                note.pub = note.pub ? 't' : 'f';
+                                if (note.title.length && note.value.length) {
+                                    working.copy_notes.push(note);
+                                }
+
+                                $uibModalInstance.close();
+                            }
+
+                            $scope.cancel = function($event) {
+                                $uibModalInstance.dismiss();
+                                $event.preventDefault();
+                            }
+                        }]
+                    });
+                }
+            
+                $scope.copy_alerts_dialog = function() {
+                    var working = $scope.working;
+
+                    return $uibModal.open({
+                        templateUrl: './cat/volcopy/t_copy_alerts',
+                        animation: true,
+                        controller:
+                            ['$scope','$uibModalInstance',
+                        function($scope , $uibModalInstance) {
+
+                            itemSvc.get_copy_alert_types().then(function(ccat) {
+                                var ccat_map = {};
+                                $scope.alert_types = ccat;
+                                angular.forEach(ccat, function(t) {
+                                    ccat_map[t.id()] = t;
+                                });
+                                $scope.copy_alert_list = [];
+                                angular.forEach(working.copy_alerts, function (alrt) {
+                                    var aca = egCore.idl.fromHash('aca', alrt);
+                                    aca.alert_type(ccat_map[alrt.alert_type]);
+                                    aca.ack_time(null);
+                                    $scope.copy_alert_list.push(aca);
+                                });
+                            });
+
+                            $scope.focusNote = true;
+                            $scope.copy_alert = {
+                                note         : '',
+                                temp         : false
+                            };
+
+                            $scope.ok = function(copy_alert) {
             
+                                if (!working.copy_alerts) {
+                                    working.copy_alerts = [];
+                                }
+                                // clear slate
+                                working.copy_alerts.length = 0;
+
+                                angular.forEach($scope.copy_alert_list, function(alrt) {
+                                    if (alrt.ack_time() == null) {
+                                        working.copy_alerts.push({
+                                            note : alrt.note(),
+                                            temp : alrt.temp(),
+                                            alert_type : alrt.alert_type().id()
+                                        });
+                                    }
+                                });
+
+                                if (typeof(copy_alert.note) != 'undefined' &&
+                                    copy_alert.note != '') {
+                                    working.copy_alerts.push({
+                                        note : copy_alert.note,
+                                        temp : copy_alert.temp ? 't' : 'f',
+                                        alert_type : copy_alert.alert_type
+                                    });
+                                }
+
+                                $uibModalInstance.close();
+                            }
+
+                            $scope.cancel = function($event) {
+                                $uibModalInstance.dismiss();
+                                $event.preventDefault();
+                            }
+                        }]
+                    });
+                }
+
                 $scope.status_list = [];
                 itemSvc.get_magic_statuses().then(function(list){
                     $scope.magic_status_list = list;
index 0a71c3f..24e4a3b 100644 (file)
@@ -387,5 +387,26 @@ function($scope , $q , $window , $location , $timeout , egCore , checkinSvc , eg
         });
         itemSvc.print_spine_labels(copy_ids);
     }
+
+    $scope.addCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.add_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.manageCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.manage_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
 }])
 
index 10a97e4..99b001c 100644 (file)
@@ -252,9 +252,47 @@ function($scope , $q , $routeParams , egCore , egUser , patronSvc ,
             // Non-cat circs don't return the full list of circs.
             // Refresh the list of non-cat circs from the server.
             patronSvc.getUserNonCats(patronSvc.current.id());
+            row_item.copy_alert_count = 0;
+        } else {
+            row_item.copy_alert_count = 0;
+            egCore.pcrud.search(
+                'aca',
+                { copy : co_resp.data.acp.id(), ack_time : null },
+                null,
+                { atomic : true }
+            ).then(function(list) {
+                row_item.copy_alert_count = list.length;
+            });
         }
     }
 
+    $scope.addCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.add_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.manageCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.manage_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.gridCellHandlers = {};
+    $scope.gridCellHandlers.copyAlertsEdit = function(id) {
+        egCirc.manage_copy_alerts([id]).then(function() {
+            // update grid items?
+        });
+    };
+
     $scope.print_receipt = function() {
         var print_data = {circulations : []};
         var cusr = patronSvc.current;
index 2c907bd..2c6ba63 100644 (file)
@@ -201,6 +201,26 @@ function($scope , $window , $location , egCore , egGridDataProvider , egCirc) {
         });
     }
 
+    $scope.addCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.add_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
+    $scope.manageCopyAlerts = function(items) {
+        var copy_ids = [];
+        angular.forEach(items, function(item) {
+            if (item.acp) copy_ids.push(item.acp.id());
+        });
+        egCirc.manage_copy_alerts(copy_ids).then(function() {
+            // update grid items?
+        });
+    }
+
     $scope.print_receipt = function() {
         var print_data = {circulations : []}
 
index d43ad41..91ad8fb 100644 (file)
@@ -5,9 +5,10 @@
 angular.module('egCoreMod')
 
 .factory('egCirc',
-       ['$uibModal','$q','egCore','egAlertDialog','egConfirmDialog',
+
+       ['$uibModal','$q','egCore','egAlertDialog','egConfirmDialog','egAddCopyAlertDialog','egCopyAlertManagerDialog','egCopyAlertEditorDialog',
         'egWorkLog',
-function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
+function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,  egAddCopyAlertDialog , egCopyAlertManagerDialog,  egCopyAlertEditorDialog ,
          egWorkLog) {
 
     var service = {
@@ -123,6 +124,8 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
     // options : non-parameter controls.  e.g. "override", "check_barcode"
     service.checkout = function(params, options) {
         if (!options) options = {};
+        params.new_copy_alerts = 1;
+
         console.debug('egCirc.checkout() : ' 
             + js2JSON(params) + ' : ' + js2JSON(options));
 
@@ -177,6 +180,7 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
     // Rejected if the renewal cannot be completed.
     service.renew = function(params, options) {
         if (!options) options = {};
+        params.new_copy_alerts = 1;
 
         console.debug('egCirc.renew() : ' 
             + js2JSON(params) + ' : ' + js2JSON(options));
@@ -224,6 +228,7 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
     // Rejected if the checkin cannot be completed.
     service.checkin = function(params, options) {
         if (!options) options = {};
+        params.new_copy_alerts = 1;
 
         console.debug('egCirc.checkin() : ' 
             + js2JSON(params) + ' : ' + js2JSON(options));
@@ -947,12 +952,24 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
         if (angular.isArray(evt)) evt = evt[0];
 
         if (!evt.payload.old_circ) {
-            return egCore.pcrud.search('circ',
-                {target_copy : evt.payload.copy.id(), checkin_time : null},
-                {limit : 1} // should only ever be 1
-            ).then(function(old_circ) {
-                evt.payload.old_circ = old_circ;
-               return service.circ_exists_dialog_impl(evt, params, options);
+            return egCore.net.request(
+                'open-ils.search',
+                'open-ils.search.asset.copy.fleshed2.find_by_barcode',
+                params.copy_barcode
+            ).then(function(resp){
+                console.log(resp);
+                if (egCore.evt.parse(resp)) {
+                    console.error(egCore.evt.parse(resp));
+                } else {
+                    return egCore.net.request(
+                         'open-ils.circ',
+                         'open-ils.circ.copy_checkout_history.retrieve',
+                         egCore.auth.token(), resp.id(), 1
+                    ).then( function (circs) {
+                        evt.payload.old_circ = circs[0];
+                        return service.circ_exists_dialog_impl( evt, params, options );
+                    });
+                }
             });
         } else {
             return service.circ_exists_dialog_impl( evt, params, options );
@@ -983,14 +1000,12 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
             function(args) {
                 if (sameUser) {
                     params.void_overdues = args.forgive_fines;
-                    options.override = true;
                     return service.renew(params, options);
                 }
 
                 return service.checkin({
                     barcode : params.copy_barcode,
                     noop : true,
-                    override : true,
                     void_overdues : args.forgive_fines
                 }).then(function(checkin_resp) {
                     if (checkin_resp.evt[0].textcode == 'SUCCESS') {
@@ -1344,7 +1359,21 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
         });
     }
 
+    service.add_copy_alerts = function(item_ids) {
+        return egAddCopyAlertDialog.open({
+            copy_ids : item_ids,
+            ok : function() { },
+            cancel : function() {}
+        }).result.then(function() { });
+    }
 
+    service.manage_copy_alerts = function(item_ids) {
+        return egCopyAlertEditorDialog.open({
+            copy_id : item_ids[0],
+            ok : function() { },
+            cancel : function() {}
+        }).result.then(function() { });
+    }
 
     // alert when copy location alert_message is set.
     // This does not affect processing, it only produces a click-through
@@ -1617,17 +1646,33 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog,
     // action == what action to take if the user confirms the alert
     service.copy_alert_dialog = function(evt, params, options, action) {
         if (angular.isArray(evt)) evt = evt[0];
-        return egConfirmDialog.open(
-            egCore.strings.COPY_ALERT_MSG_DIALOG_TITLE, 
-            evt.payload,  // payload == alert message text
-            {   copy_barcode : params.copy_barcode,
-                ok : function() {},
+        if (!angular.isArray(evt.payload)) {
+            return egConfirmDialog.open(
+                egCore.strings.COPY_ALERT_MSG_DIALOG_TITLE, 
+                evt.payload,  // payload == alert message text
+                {   copy_barcode : params.copy_barcode,
+                    ok : function() {},
+                    cancel : function() {}
+                }
+            ).result.then(function() {
+                options.override = true;
+                return service[action](params, options);
+            });
+        } else { // we got a list of copy alert objects ...
+            return egCopyAlertManagerDialog.open({
+                alerts : evt.payload,
+                mode : action,
+                ok : function(the_next_status) {
+                        if (the_next_status !== null) {
+                            params.next_copy_status = [ the_next_status ];
+                        }
+                     },
                 cancel : function() {}
-            }
-        ).result.then(function() {
-            options.override = true;
-            return service[action](params, options);
-        });
+            }).result.then(function() {
+                options.override = true;
+                return service[action](params, options);
+            });
+        }
     }
 
     // action == what action to take if the user confirms the alert
index fa9f440..02a58d9 100644 (file)
@@ -16,7 +16,7 @@ function(egCore , egCirc , $uibModal , $q , $timeout , $window , egConfirmDialog
         flesh : 3, 
         flesh_fields : {
             acp : ['call_number','location','status','location','floating','circ_modifier',
-                'age_protect','circ_lib'],
+                'age_protect','circ_lib','copy_alerts'],
             acn : ['record','prefix','suffix','label_class'],
             bre : ['simple_record','creator','editor']
         },
@@ -152,6 +152,10 @@ function(egCore , egCirc , $uibModal , $q , $timeout , $window , egConfirmDialog
                     flatCopy._duration = copyData.circ.duration();
                 }
                 flatCopy.index = service.index++;
+                flatCopy.copy_alert_count = copyData.copy.copy_alerts().filter(function(aca) {
+                    return !aca.ack_time();
+                }).length;
+
                 service.copies.unshift(flatCopy);
             }
 
index b2d760a..ed21ae0 100644 (file)
@@ -1263,7 +1263,12 @@ angular.module('egGridMod',
             // optional: for non-IDL columns, specifying a datatype
             // lets the caller control which display filter is used.
             // datatype should match the standard IDL datatypes.
-            datatype : '@'
+            datatype : '@',
+
+            // optional hash of functions that can be imported into
+            // the directive's scope; meant for cases where the "compiled"
+            // attribute is set
+            handlers : '='
         },
         link : function(scope, element, attrs, egGridCtrl) {
 
@@ -1271,6 +1276,7 @@ angular.module('egGridMod',
             angular.forEach(
                 [
                     'visible', 
+                    'compiled', 
                     'hidden', 
                     'sortable', 
                     'nonsortable',
@@ -1538,6 +1544,8 @@ angular.module('egGridMod',
                 linkpath : colSpec.linkpath,
                 template : colSpec.template,
                 visible  : colSpec.visible,
+                compiled : colSpec.compiled,
+                handlers : colSpec.handlers,
                 hidden   : colSpec.hidden,
                 datatype : colSpec.datatype,
                 sortable : colSpec.sortable,
@@ -2076,6 +2084,31 @@ angular.module('egGridMod',
     };
 })
 
+/* https://stackoverflow.com/questions/17343696/adding-an-ng-click-event-inside-a-filter/17344875#17344875 */
+.directive('compile', ['$compile', function ($compile) {
+    return function(scope, element, attrs) {
+      // pass through column defs from grid cell's scope
+      scope.col = scope.$parent.col;
+      scope.$watch(
+        function(scope) {
+          // watch the 'compile' expression for changes
+          return scope.$eval(attrs.compile);
+        },
+        function(value) {
+          // when the 'compile' expression changes
+          // assign it into the current DOM
+          element.html(value);
+
+          // compile the new DOM and link it to the current
+          // scope.
+          // NOTE: we only compile .childNodes so that
+          // we don't get into infinite loop compiling ourselves
+          $compile(element.contents())(scope);
+        }
+    );
+  };
+}])
+
 
 
 /**
index 47632ed..1a3f822 100644 (file)
@@ -634,6 +634,256 @@ function($window , egStrings) {
     return service;
 }])
 
+/**
+ * egAddCopyAlertDialog - manage copy alerts
+ */
+.factory('egAddCopyAlertDialog', 
+       ['$uibModal','$interpolate','egCore',
+function($uibModal , $interpolate , egCore) {
+    var service = {};
+
+    service.open = function(args) {
+        return $uibModal.open({
+            templateUrl: './share/t_add_copy_alert_dialog',
+            controller: ['$scope','$q','$uibModalInstance',
+                function( $scope , $q , $uibModalInstance) {
+
+                    $scope.copy_ids = args.copy_ids;
+                    egCore.pcrud.search('ccat',
+                        { active : 't' },
+                        {},
+                        { atomic : true }
+                    ).then(function (ccat) {
+                        $scope.alert_types = ccat;
+                    }); 
+
+                    $scope.copy_alert = {
+                        create_staff : egCore.auth.user().id(),
+                        note         : '',
+                        temp         : false
+                    };
+
+                    $scope.ok = function(copy_alert) {
+                        if (typeof(copy_alert.note) != 'undefined' &&
+                            copy_alert.note != '') {
+                            copy_alerts = [];
+                            angular.forEach($scope.copy_ids, function (cp_id) {
+                                var a = new egCore.idl.aca();
+                                a.isnew(1);
+                                a.create_staff(copy_alert.create_staff);
+                                a.note(copy_alert.note);
+                                a.temp(copy_alert.temp ? 't' : 'f');
+                                a.copy(cp_id);
+                                a.ack_time(null);
+                                a.alert_type(
+                                    $scope.alert_types.filter(function(at) {
+                                        return at.id() == copy_alert.alert_type;
+                                    })[0]
+                                );
+                                copy_alerts.push( a );
+                            });
+                            if (copy_alerts.length > 0) {
+                                egCore.pcrud.apply(copy_alerts);
+                            }
+                        }
+                        if (args.ok) args.ok();
+                        $uibModalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (args.cancel) args.cancel();
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+/**
+ * egCopyAlertManagerDialog - manage copy alerts
+ */
+.factory('egCopyAlertManagerDialog', 
+       ['$uibModal','$interpolate','egCore',
+function($uibModal , $interpolate , egCore) {
+    var service = {};
+
+    service.get_user_copy_alerts = function(copy_id) {
+        return egCore.pcrud.search('aca', { copy : copy_id, ack_time : null },
+            { flesh : 1, flesh_fields : { aca : ['alert_type'] } },
+            { atomic : true }
+        );
+    }
+
+    service.open = function(args) {
+        return $uibModal.open({
+            templateUrl: './share/t_copy_alert_manager_dialog',
+            controller: ['$scope','$q','$uibModalInstance',
+                function( $scope , $q , $uibModalInstance) {
+
+                    function init(args) {
+                        var defer = $q.defer();
+                        if (args.copy_id) {
+                            service.get_user_copy_alerts(args.copy_id).then(function(aca) {
+                                defer.resolve(aca);
+                            });
+                        } else {
+                            defer.resolve(args.alerts);
+                        }
+                        return defer.promise;
+                    }
+
+                    // returns a promise resolved with the list of circ statuses
+                    $scope.get_copy_statuses = function() {
+                        if (egCore.env.ccs)
+                            return $q.when(egCore.env.ccs.list);
+
+                        return egCore.pcrud.retrieveAll('ccs', null, {atomic : true})
+                        .then(function(list) {
+                            egCore.env.absorbList(list, 'ccs');
+                            return list;
+                        });
+                    };
+
+                    $scope.mode = args.mode || 'checkin';
+
+                    var next_statuses = [];
+                    var seen_statuses = {};
+                    $scope.next_statuses = [];
+                    $scope.params = {
+                        'the_next_status' : null
+                    }
+                    init(args).then(function(copy_alerts) {
+                        $scope.alerts = copy_alerts;
+                        angular.forEach($scope.alerts, function(copy_alert) {
+                            var state = copy_alert.alert_type().state();
+                            copy_alert.evt = copy_alert.alert_type().event();
+
+                            copy_alert.message = copy_alert.note() ||
+                                egCore.strings.ON_DEMAND_COPY_ALERT[copy_alert.evt][state];
+
+                            if (copy_alert.temp() == 't') {
+                                angular.forEach(copy_alert.alert_type().next_status(), function (st) {
+                                    if (!seen_statuses[st]) {
+                                        seen_statuses[st] = true;
+                                        next_statuses.push(st);
+                                    }
+                                });
+                            }
+                        });
+                        if ($scope.mode == 'checkin' && next_statuses.length > 0) {
+                            $scope.get_copy_statuses().then(function() {
+                                angular.forEach(next_statuses, function(st) {
+                                    if (egCore.env.ccs.map[st])
+                                       $scope.next_statuses.push(egCore.env.ccs.map[st]);
+                                });
+                                $scope.params.the_next_status = $scope.next_statuses[0].id();
+                            });
+                        }
+                    });
+
+                    $scope.isAcknowledged = function(copy_alert) {
+                        return (copy_alert.acked);
+                    };
+                    $scope.canBeAcknowledged = function(copy_alert) {
+                        return (!copy_alert.ack_time() && copy_alert.temp() == 't');
+                    };
+                    $scope.canBeRemoved = function(copy_alert) {
+                        return (!copy_alert.ack_time() && copy_alert.temp() == 'f');
+                    };
+
+                    $scope.ok = function() {
+                        var acks = [];
+                        angular.forEach($scope.alerts, function (copy_alert) {
+                            if (copy_alert.acked) {
+                                copy_alert.ack_time('now');
+                                copy_alert.ack_staff(egCore.auth.user().id());
+                                copy_alert.ischanged(true);
+                                acks.push(copy_alert);
+                            }
+                        });
+                        if (acks.length > 0) {
+                            egCore.pcrud.apply(acks);
+                        }
+                        if (args.ok) args.ok($scope.params.the_next_status);
+                        $uibModalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (args.cancel) args.cancel();
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
+
+/**
+ * egCopyAlertEditorDialog - manage copy alerts
+ */
+.factory('egCopyAlertEditorDialog', 
+       ['$uibModal','$interpolate','egCore',
+function($uibModal , $interpolate , egCore) {
+    var service = {};
+
+    service.get_user_copy_alerts = function(copy_id) {
+        return egCore.pcrud.search('aca', { copy : copy_id, ack_time : null },
+            { flesh : 1, flesh_fields : { aca : ['alert_type'] } },
+            { atomic : true }
+        );
+    }
+
+    service.get_copy_alert_types = function() {
+        return egCore.pcrud.search('ccat',
+            { active : 't' },
+            {},
+            { atomic : true }
+        );
+    };
+
+    service.open = function(args) {
+        return $uibModal.open({
+            templateUrl: './share/t_copy_alert_editor_dialog',
+            controller: ['$scope','$q','$uibModalInstance',
+                function( $scope , $q , $uibModalInstance) {
+
+                    function init(args) {
+                        var defer = $q.defer();
+                        if (args.copy_id) {
+                            service.get_user_copy_alerts(args.copy_id).then(function(aca) {
+                                defer.resolve(aca);
+                            });
+                        } else {
+                            defer.resolve(args.alerts);
+                        }
+                        return defer.promise;
+                    }
+
+                    init(args).then(function(copy_alerts) {
+                        $scope.copy_alert_list = copy_alerts;
+                    });
+                    service.get_copy_alert_types().then(function(ccat) {
+                        $scope.alert_types = ccat;
+                    });
+
+                    $scope.ok = function() {
+                        egCore.pcrud.apply($scope.copy_alert_list);
+                        $uibModalInstance.close()
+                    }
+                    $scope.cancel = function() {
+                        if (args.cancel) args.cancel();
+                        $uibModalInstance.dismiss();
+                    }
+                }
+            ]
+        })
+    }
+
+    return service;
+}])
 .directive('aDisabled', function() {
     return {
         restrict : 'A',
@@ -676,9 +926,9 @@ function($window , egStrings) {
         template:
             '<div class="input-group">'+
                 '<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()" ng-disabled="egDisabled" class="btn btn-default dropdown-toggle"><span class="caret"></span></button>'+
-                    '<ul class="dropdown-menu dropdown-menu-right">'+
+                '<div class="input-group-btn" uib-dropdown ng-class="{open:isopen}">'+
+                    '<button type="button" ng-click="showAll()" ng-disabled="egDisabled" class="btn btn-default" uib-dropdown-toggle><span class="caret"></span></button>'+
+                    '<ul dropdown-menu class="dropdown-menu-right">'+
                         '<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>'+