RT# 32243 Package Bulk Edit UI Update
authorMitch Jackson <mitch@freeside.biz>
Tue, 27 Nov 2018 20:39:01 +0000 (15:39 -0500)
committerMitch Jackson <mitch@freeside.biz>
Tue, 27 Nov 2018 20:44:05 +0000 (15:44 -0500)
FS/FS/cust_pkg.pm
httemplate/edit/cust_pkg.cgi
httemplate/edit/process/cust_pkg.cgi
httemplate/elements/header-cust_main.html

index f29ab9f..d39dbbb 100644 (file)
@@ -5614,6 +5614,8 @@ sub _X_show_zero {
 
 =item order CUSTNUM, PKGPARTS_ARYREF, [ REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF [ REFNUM ] ] ]
 
+=item order \%PARAMS
+
 Bulk cancel + order subroutine.  Perhaps slightly deprecated, only used by the
 bulk cancel+order in the web UI and nowhere else (edit/process/cust_pkg.cgi)
 
@@ -5638,10 +5640,25 @@ setting I<refnum> to an array reference of refnums or a hash reference with
 refnums as keys.  If no I<refnum> is defined, a default FS::pkg_referral
 record will be created corresponding to cust_main.refnum.
 
+LOCATIONNUM, if specified, will be set on newly created cust_pkg records
+
 =cut
 
 sub order {
-  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
+  my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum,
+      $locationnum);
+
+  if ( ref $_[0] ) {
+    my $args = $_[0];
+    $custnum         = $args->{custnum};
+    $pkgparts        = $args->{pkgparts};
+    $remove_pkgnum   = $args->{remove_pkgnum};
+    $return_cust_pkg = $args->{return_cust_pkg};
+    $refnum          = $args->{refnum};
+    $locationnum     = $args->{locationnum};
+  } else {
+    ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_;
+  }
 
   my $conf = new FS::Conf;
 
@@ -5685,6 +5702,8 @@ sub order {
 
   }
 
+  $hash{locationnum} = $locationnum if $locationnum;
+
   # Create the new packages.
   foreach my $pkgpart (@$pkgparts) {
 
index 7ffbb1f..4e4f4d2 100755 (executable)
-<% include('/elements/header.html', "Add/Edit Packages", '') %>
+<%doc>
 
+    Bulk package Edit Page
+
+</%doc>
+<& /elements/header-cust_main.html,
+    view              => 'packages',
+    cust_main         => $cust_main,
+    include_selectize => 1,
+&>
 <% include('/elements/error.html') %>
 
-<FORM ACTION="<% $p1 %>process/cust_pkg.cgi" METHOD=POST>
-<INPUT TYPE="hidden" NAME="action" VALUE="bulk">
-<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
-
-%#current packages
-%if (@cust_pkg) {
-
-  Current packages - select to remove (services are moved to a new package below)
-  <TABLE>
-    <TR STYLE="background-color: #cccccc;">
-      <TH COLSPAN="2">Pkg #</TH>
-      <TH>Package description</TH>
-    </TR>
-  <BR><BR>
-%
-%  foreach ( @main_pkgs ) {
-%    my($pkgnum,$pkgpart)=( $_->getfield('pkgnum'), $_->getfield('pkgpart') );
-%    my $checked = $remove_pkg{$pkgnum} ? ' CHECKED' : '';
-%
-%  
-
-
-    <TR>
-      <TD><INPUT TYPE="checkbox" NAME="remove_pkg" VALUE="<% $pkgnum %>"<% $checked %>></TD>
-      <TD ALIGN="right"><% $pkgnum %>:</TD>
-      <TD><% $all_pkg{$pkgpart} |h %> - <% $all_comment{$pkgpart} |h %></TD>
-    </TR>
-%   foreach my $supp_pkg ( @{ $supp_pkgs_of{$pkgnum} } ) {
-    <TR>
-      <TD></TD>
-      <TD></TD>
-      <TD>+ <% $all_pkg{$supp_pkg->pkgpart} |h %> - <% $all_comment{$supp_pkg->pkgpart} |h %></TD>
-    </TR>
+<script>
+
+  let locationnum = "<% $cust_main->ship_locationnum %>";
+
+  function location_changed(e) {
+    locationnum = $(e).val();
+    $('tr[data-locationnum]').find('input').prop('checked', false);
+    $('tr[data-locationnum]').each( function() {
+      let tr_el = $(this);
+      tr_el.css(
+        'display',
+        locationnum == tr_el.data('locationnum') ? 'table-row' : 'none'
+      );
+    });
+  }
+
+  function pkg_class_filter_onchange( selected ) {
+    if ( selected.length == 0 ) {
+      $('tr[data-classnum]').css('display', 'table-row');
+    } else {
+      $('tr[data-classnum]').each( function() {
+        let tr_el = $(this);
+        let classnum = tr_el.data('classnum');
+        let is_displayed = $.grep( selected, function( item ) {
+          return item == classnum;
+        });
+        let display = is_displayed.length ? 'table-row' : 'none';
+        tr_el.css( 'display', is_displayed.length ? 'table-row' : 'none' );
+      });
+    }
+  }
+
+  function confirm_form() {
+    let cust_pkg_removed = [];
+    let pkg_part_added   = [];
+
+    $('input[data-locationnum]:checked').each( function() {
+      let this_el = $(this);
+      cust_pkg_removed.push(
+        '#' + this_el.data('pkgnum') + ' ' + this_el.data('pkg')
+      );
+    });
+
+    $('input[data-pkgpart]').each( function() {
+      qty_el = $(this);
+      qty = qty_el.val();
+
+      if ( qty < 1 ) { return; }
+
+      pkg_part_added.push( qty + ' x ' + qty_el.data('pkg') );
+    });
+
+    if ( cust_pkg_removed.length == 0 ) {
+      cust_pkg_removed.push('No Existing Packages Selected');
+    }
+    if ( pkg_part_added.length == 0 ) {
+      pkg_part_added.push('No New Packages Selected');
+    }
+
+    console.log( cust_pkg_removed );
+    console.log( pkg_part_added );
+
+    confirm_html =
+      '<div style="margin: 1em;">'
+      + '<b><u>Removed Packages:</u></b><br>'
+      + cust_pkg_removed.join('<br>')
+      + '<br><br>'
+      + '<b><u>Replacement Packages:</u></b><br>'
+      + pkg_part_added.join('<br>')
+      + '<br><br>'
+      + '<input type="button" role="button" onclick="submit_form();" value="Confirm Order">'
+      + '</div>';
+
+      overlib(
+        confirm_html,
+        CAPTION, 'Confirm bulk change',
+        STICKY,
+        AUTOSTATUSCAP,
+        MIDX, 0,
+        MIDY, 0,
+        WIDTH, 300,
+        HEIGHT, 200,
+        TEXTSIZE, 3,
+        BGCOLOR, '#ff0000',
+        CGCOLOR, '#ff0000'
+      );
+  }
+
+  function submit_form() {
+    $('#formBulkEdit').submit();
+  }
+</script>
+
+<form action="<% $fsurl %>edit/process/cust_pkg.cgi" method="POST" id="formBulkEdit">
+<input type="hidden" name="custnum" value="<% $custnum %>">
+<input type="hidden" name="action" value="bulk">
+
+<p style="margin-bottom: 2em;">
+  <label for="locationnum">Service Location</label>
+  <% include( '/elements/select-cust_location.html',
+      cust_main => $cust_main,
+      addnew    => 0,
+      onchange  => 'javascript:location_changed(this);',
+  ) %><br>
+  <span style="font-size: .8em; padding-left: 1em;">
+    Bulk-edit works with one customer location at a time
+  </span>
+</p>
+
+<table style="margin-bottom: 2em;">
+  <thead>
+    <tr style="background-color: #ccc;">
+      <th colspan="2" style="text-align: left;">
+        Pkg #
+      </th>
+      <th style="text-align: left;">
+        Current Packages<br>
+        <div style="font-size: .8em; padding-left: 1em; font-weight: normal;">
+          Selected packages are removed.<br>
+          Attached services are moved to the new package selected below
+        </span>
+      </th>
+    </tr>
+  </thead>
+  <tbody>
+%   for my $cust_pkg ( @cust_pkg ) {
+%     my $id = sprintf 'remove_cust_pkg[%s]', $cust_pkg->pkgnum;
+%     my $is_displayed = $cust_main->ship_locationnum == $cust_pkg->locationnum ? 1 : 0;
+      <tr data-locationnum="<% $cust_pkg->locationnum %>" data-pkg="<% $cust_pkg->pkg |h %>" style="display: <% $is_displayed ? 'table-row' : 'none' %>;">
+        <td>
+          <input type="checkbox"
+                 name="<% $id %>"
+                 id="<% $id %>"
+                 data-pkgnum="<% $cust_pkg->pkgnum %>"
+                 data-locationnum="<% $cust_pkg->locationnum %>"
+                 data-pkg="<% $part_pkg{ $cust_pkg->pkgpart }->pkg |h %>">
+        </td>
+        <td>#<% $cust_pkg->pkgnum %></td>
+        <td>
+          <label for="<% $id %>">
+            <% $part_pkg{ $cust_pkg->pkgpart }->pkg %><br>
+%           for my $cust_pkg_supp ( @{ $cust_pkg_supp_of{ $cust_pkg->pkgnum }} ) {
+              <span style="font-size: .8em; padding-left: 1em;">
+                <b>Supplementary:</b> <% $part_pkg{ $cust_pkg_supp->pkgpart }->pkg %>
+              </span>
+            </label>
+%         }
+        </td>
+      </tr>
 %   }
-% } 
-
-
-  </TABLE>
-  <BR><BR>
-% } 
-
-
-Order new packages
-<BR><BR>
-
-%my $cust_main = qsearchs('cust_main',{'custnum'=>$custnum});
-%my $agent = qsearchs('agent',{'agentnum'=> $cust_main->agentnum });
-%
-%my %agent_pkgs = map { ( $_->pkgpart => $all_pkg{$_->pkgpart} ) }
-%                   ( qsearch('type_pkgs',{ typenum  => $agent->typenum      }),
-%                     qsearch('part_pkg', { agentnum => $cust_main->agentnum }),
-%                   );
-%
-%my $count = 0;
-%my $pkgparts = 0;
-
-<TABLE>
-  <TR STYLE="background-color: #cccccc;">
-    <TH>Qty.</TH>
-    <TH COLSPAN="2">Package Description</TH>
-  </TR>
-%
-%#foreach my $type_pkgs ( qsearch('type_pkgs',{'typenum'=> $agent->typenum }) ) {
-%foreach my $pkgpart ( sort { $agent_pkgs{$a} cmp $agent_pkgs{$b} }
-%                             keys(%agent_pkgs) ) {
-%  $pkgparts++;
-%  next unless exists $pkg{$pkgpart}; #skip disabled ones
-%  #print qq!<TR>! if ( $count == 0 );
-%  my $value = $cgi->param("pkg$pkgpart") || 0;
-%
-
-
-  <TR>
-    <TD>
-      <INPUT TYPE="text" NAME="<% "pkg$pkgpart" %>" VALUE="<% $value %>" SIZE="2" MAXLENGTH="2">
-    </TD>
-    <TD ALIGN="right"><% $pkgpart %>:</TD>
-    <TD><% $pkg{$pkgpart} |h %> - <% $comment{$pkgpart} |h %></TD>
-  </TR>
-%
-%  $count ++ ;
-%  #if ( $count == 2 ) {
-%  #  print qq!</TR>\n! ;
-%  #  $count = 0;
-%  #}
-%}
-%
-
-
-</TABLE>
-% unless ( $pkgparts ) {
-%     my $p2 = popurl(2);
-%     my $typenum = $agent->typenum;
-%     my $agent_type = qsearchs( 'agent_type', { 'typenum' => $typenum } );
-%     my $atype = $agent_type->atype;
-%
-
-
-     (No <A HREF="<% $p2 %>browse/part_pkg.cgi">package definitions</A>,
-     or agent type
-     <A HREF="<% $p2 %>edit/agent_type.cgi?<% $typenum %>"><% $atype %></a>
-     is not allowed to purchase any packages.)
-% } 
-
-
-<P><INPUT TYPE="submit" VALUE="Order">
-
-</FORM>
+  </tbody>
+</table>
+
+<table style="margin-bottom: 2em;">
+  <thead>
+    <tr style="background-color: #ccc;">
+      <th colspan="3">
+        <% include('/elements/selectize/select-multiple-pkg_class.html',
+            id       => 'filter_pkg_class',
+            onchange => 'pkg_class_filter_onchange',
+        ) %>
+      </th>
+    </tr>
+    <tr style="background-color: #ccc;">
+      <th>Qty</th>
+      <th>Class</th>
+      <th style="text-align: left;">Order New Packages</th>
+    </tr>
+  </thead>
+  <tbody>
+%   for my $part_pkg ( @part_pkg_enabled ) {
+%     my $id = sprintf 'qty_part_pkg[%s]', $part_pkg->pkgpart;
+      <tr data-classnum="<% $part_pkg->classnum %>">
+        <td>
+          <input type="text"
+                 name="<% $id %>"
+                 id="<% $id %>"
+                 value="0"
+                 size="2"
+                 data-pkgpart="<% $part_pkg->pkgpart %>"
+                 data-pkg="<% $part_pkg->pkg %>">
+          </td>
+        <td><% $part_pkg->classname || '(none)' %></td>
+        <td><% $part_pkg->pkg %></td>
+      </tr>
+%   }
+  </tbody>
+</table>
+
+<input type="button" role="button" value="Order" onclick="confirm_form();">
+
+</form>
 
 <% include('/elements/footer.html') %>
 
@@ -118,53 +210,41 @@ Order new packages
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Bulk change customer packages');
 
-my %pkg = ();
-my %comment = ();
-my %all_pkg = ();
-my %all_comment = ();
-#foreach (qsearch('part_pkg', { 'disabled' => '' })) {
-#  $pkg{ $_ -> getfield('pkgpart') } = $_->getfield('pkg');
-#  $comment{ $_ -> getfield('pkgpart') } = $_->getfield('comment');
-#}
-foreach (qsearch('part_pkg', {} )) {
-  $all_pkg{ $_ -> getfield('pkgpart') } = $_->getfield('pkg');
-  $all_comment{ $_ -> getfield('pkgpart') } = $_->custom_comment;
-  next if $_->disabled;
-  $pkg{ $_ -> getfield('pkgpart') } = $_->getfield('pkg');
-  $comment{ $_ -> getfield('pkgpart') } = $_->custom_comment;
-}
+my $custnum = $cgi->param('keywords') || $cgi->param('custnum');
+$custnum =~ /^\d+$/
+  or die "Invalid custnum($custnum)";
 
-my($custnum, %remove_pkg);
-if ( $cgi->param('error') ) {
-  $custnum = $cgi->param('custnum');
-  %remove_pkg = map { $_ => 1 } $cgi->param('remove_pkg');
-} else {
-  my($query) = $cgi->keywords;
-  $query =~ /^(\d+)$/;
-  $custnum = $1;
-  %remove_pkg = ();
-}
+my $cust_main = qsearchs( cust_main => { custnum => $custnum })
+  or die "Invalid custnum ($custnum)";
 
-my $p1 = popurl(1);
+my %part_pkg;
+my @part_pkg_enabled;
 
-my @cust_pkg = qsearch('cust_pkg', { 'custnum' => $custnum, 'cancel' => '' } );
-my @main_pkgs;
-my %supp_pkgs_of; # main pkgnum => arrayref of cust_pkgs
-
-
-foreach my $cust_pkg
-  ( sort { $all_pkg{ $a->pkgpart } cmp $all_pkg{ $b->getfield('pkgpart') } }
-    @cust_pkg
+for my $part_pkg ( qsearch( part_pkg => {} )) {
+  $part_pkg{ $part_pkg->pkgpart } = $part_pkg;
+  push @part_pkg_enabled, $part_pkg
+    unless $part_pkg->disabled;
+}
+@part_pkg_enabled =
+  sort { $a->classname cmp $b->classname || $a->pkg cmp $b->pkg }
+  @part_pkg_enabled;
+
+my @cust_pkg;
+my %cust_pkg_supp_of;
+for my $cust_pkg (
+  qsearch(
+    cust_pkg => {
+      custnum  => $custnum,
+      cancel   => '',
+    }
   )
-  # XXX does not properly handle recursive supplemental links
-{
+) {
   if ( my $main_pkgnum = $cust_pkg->main_pkgnum ) {
-    $supp_pkgs_of{$main_pkgnum} ||= [];
-    push @{ $supp_pkgs_of{$main_pkgnum} }, $cust_pkg;
+    $cust_pkg_supp_of{ $main_pkgnum } //= [];
+    push @{ $cust_pkg_supp_of{ $main_pkgnum } }, $cust_pkg;
   } else {
-    push @main_pkgs, $cust_pkg;
-    $supp_pkgs_of{$cust_pkg->pkgnum} ||= [];
+    $cust_pkg_supp_of{ $cust_pkg->pkgnum } //= [];
+    push @cust_pkg, $cust_pkg;
   }
 }
-
 </%init>
index c564c41..82a9e23 100755 (executable)
@@ -5,38 +5,61 @@
 <% $cgi->redirect(popurl(3). "view/cust_main.cgi?$custnum") %>
 % }
 <%init>
-
+use Data::Dumper;
+my $DEBUG = 0;
 my $curuser = $FS::CurrentUser::CurrentUser;
 
 die "access denied"
   unless $curuser->access_right('Bulk change customer packages');
 
 my $error = '';
+my %param = $cgi->Vars;
+
+my $custnum = $param{custnum};
+$error = "Invalid custnum ($custnum)" if $custnum =~ /\D/;
+
+my $locationnum = $param{locationnum};
+$error = "Invalid locationnum ($locationnum)" if $locationnum =~ /\D/;
+
+my @remove_pkgnum =
+  map { $_ =~ /remove_cust_pkg\[(\d+)\]/ ? $1 : () }
+  keys %param;
+
+my @pkgparts;
+for my $k ( keys %param ) {
+  next unless $k =~ /qty_part_pkg\[(\d+)\]/;
+  my $pkgpart = $1;
+  my $qty     = $param{$k};
+  $qty =~ s/(^\s+|\s+$)//g;
 
-#untaint custnum
-$cgi->param('custnum') =~ /^(\d+)$/;
-my $custnum = $1;
-
-my @remove_pkgnums = map {
-  /^(\d+)$/ or die "Illegal remove_pkg value!";
-  $1;
-} $cgi->param('remove_pkg');
-
-my( $action, $error_redirect ) = ( '', '' );
-my @pkgparts = ();
-
-foreach my $pkgpart ( map /^pkg(\d+)$/ ? $1 : (), $cgi->param ) {
-  if ( $cgi->param("pkg$pkgpart") =~ /^(\d+)$/ ) {
-    my $num_pkgs = $1;
-    while ( $num_pkgs-- ) {
-      push @pkgparts,$pkgpart;
-    }
-  } else {
-    $error = "Illegal quantity";
+  warn "k($k) param{k}($param{$k}) pkgpart($pkgpart) qty($qty)\n"
+    if $DEBUG;
+
+  if ( $qty =~ /\D/ ) {
+    $error = "Invalid quantity $qty for pkgpart $pkgpart - please use a number";
     last;
   }
+
+  next if $qty == 0;
+
+  push ( @pkgparts, $pkgpart ) for ( 1..$qty );
+}
+
+if ( $DEBUG ) {
+  warn Dumper({
+    custnum       => $custnum,
+    locationnum   => $locationnum,
+    remove_pkgnum => \@remove_pkgnum,
+    pkgparts      => \@pkgparts,
+    param         => \%param,
+  });
 }
 
-$error ||= FS::cust_pkg::order($custnum,\@pkgparts,\@remove_pkgnums);
+$error ||= FS::cust_pkg::order({
+  custnum       => $custnum,
+  pkgparts      => \@pkgparts,
+  remove_pkgnum => \@remove_pkgnum,
+  locationnum   => $locationnum,
+});
 
 </%init>
index c094f95..f29f325 100644 (file)
@@ -10,12 +10,12 @@ Examples:
 
 </%doc>
 <& /elements/header.html, {
-             'title'          => $title,
-             'title_noescape' => $title_noescape,
-             #'nobr'           => 1,
-             'etc'            => $opt{'etc'},
-          }
-&>
+    'title'           => $title,
+    'title_noescape'  => $title_noescape,
+    #'nobr'           => 1,
+    'etc'             => $opt{'etc'},
+    include_selectize => $opt{include_selectize} ? 1 : 0,
+&>
 
 % my @part_tag = $cust_main->part_tag;
 % if ( $conf->config('cust_tag-location') eq 'top' && @part_tag ) {