[freeside-commits] branch master updated. 062f38f2f5d0da64c6fd0702d4df2e805473a1f5

Mark Wells mark at 420.am
Sat Jun 27 19:35:00 PDT 2015


The branch, master has been updated
       via  062f38f2f5d0da64c6fd0702d4df2e805473a1f5 (commit)
      from  68546df9b125f73764eda31f1dcb4e2c0555f859 (commit)

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

- Log -----------------------------------------------------------------
commit 062f38f2f5d0da64c6fd0702d4df2e805473a1f5
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Jun 27 15:51:29 2015 -0700

    reports with row grouping for payment/refund search, #25944

diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 6163197..f05ac03 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -57,7 +57,7 @@ if ( -e $addl_handler_use_file ) {
   use CGI::Cookie;
   use List::Util qw( max min sum );
   use List::MoreUtils qw( first_index uniq );
-  use Scalar::Util qw( blessed );
+  use Scalar::Util qw( blessed looks_like_number );
   use Data::Dumper;
   use Date::Format;
   use Time::Local;
@@ -156,6 +156,8 @@ if ( -e $addl_handler_use_file ) {
   use FS::Locales;
   use FS::Maketext qw( mt emt js_mt );
 
+  use FS::Query;
+
   use FS::agent;
   use FS::agent_type;
   use FS::domain_record;
diff --git a/FS/FS/Query.pm b/FS/FS/Query.pm
new file mode 100644
index 0000000..8ecf1c4
--- /dev/null
+++ b/FS/FS/Query.pm
@@ -0,0 +1,118 @@
+package FS::Query;
+
+use strict;
+use FS::Record; # don't import qsearch
+use Storable 'dclone';
+
+=head1 NAME
+
+FS::Query - A thin wrapper around qsearch argument hashes.
+
+=head1 DESCRIPTION
+
+This module exists because we pass qsearch argument lists around a lot,
+and add new joins or WHERE expressions in several stages, and I got tired
+of doing this:
+
+  my $andwhere = "mycolumn IN('perl','python','javascript')";
+  if ( ($search->{hashref} and keys( %{$search->{hashref}} ))
+       or $search->{extra_sql} =~ /^\s*WHERE/ ) {
+    $search->{extra_sql} .= " AND $andwhere";
+  } else {
+    $search->{extra_sql} = " WHERE $andwhere ";
+  }
+
+and then having it fail under some conditions if it's done wrong (as the above
+example is, obviously).
+
+We may eventually switch over to SQL::Abstract or something for this, but for
+now it's a couple of crude manipulations and a wrapper to qsearch.
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Turns HASHREF (a qsearch argument list) into an FS::Query object. None of
+the params are really required, but you should at least supply C<table>.
+
+In the Future this may do a lot more stuff.
+
+=cut
+
+sub new {
+  my ($class, $hashref) = @_;
+
+  my $self = bless {
+    table     => '',
+    select    => '*',
+    hashref   => {},
+    addl_from => '',
+    extra_sql => '',
+    order_by  => '',
+    %$hashref,
+  };
+  # load FS::$table? validate anything?
+  $self;
+}
+
+=item clone
+
+Returns another object that's a copy of this one.
+
+=cut
+
+sub clone {
+  my $self = shift;
+  $self->new( dclone($self) );
+}
+
+=item and_where EXPR
+
+Adds a constraint to the WHERE clause of the query. All other constraints in
+the WHERE clause should be joined with AND already; if not, they should be
+grouped with parentheses.
+
+=cut
+
+sub and_where {
+  my $self = shift;
+  my $where = shift;
+
+  if ($self->{extra_sql} =~ /^\s*(?:WHERE|AND)\s+(.*)/is) {
+    $where = "($where) AND $1";
+  }
+  if (keys %{ $self->{hashref} }) {
+    $where = " AND $where";
+  } else {
+    $where = " WHERE $where";
+  }
+  $self->{extra_sql} = $where;
+
+  return $self;
+}
+
+=item qsearch
+
+Runs the query and returns all results.
+
+=cut
+
+sub qsearch {
+  my $self = shift;
+  FS::Record::qsearch({ %$self });
+}
+
+=item qsearchs
+
+Runs the query and returns only one result.
+
+=cut
+
+sub qsearchs {
+  my $self = shift;
+  FS::Record::qsearchs({ %$self });
+}
+
+1;
diff --git a/httemplate/elements/freeside.css b/httemplate/elements/freeside.css
index ece57a0..3dc344c 100644
--- a/httemplate/elements/freeside.css
+++ b/httemplate/elements/freeside.css
@@ -254,6 +254,24 @@ table.grid {
   border-radius: 2px;
 }
 
+tr.row0 {
+  background-color: #eeeeee;
+}
+
+tr.row1 {
+  background-color: #ffffff;
+}
+
+.grid tfoot tr {
+  background-color: #dddddd;
+  font-style: italic;
+}
+
+/* border at the top of the footer, but not between footer rows */
+.grid tfoot tr:first-child td {
+  border-top: 1px dashed black;
+}
+
 th, td {
   vertical-align: top;
 }
diff --git a/httemplate/elements/select.html b/httemplate/elements/select.html
index 67ef514..4492681 100644
--- a/httemplate/elements/select.html
+++ b/httemplate/elements/select.html
@@ -4,6 +4,7 @@
         ID            = "<% $opt{id} %>"
         previousValue = "<% $curr_value %>"
         previousText  = "<% $labels->{$curr_value} || $curr_value %>"
+        <% $multiple %>
         <% $size %>
         <% $style %>
         <% $opt{disabled} %>
@@ -74,4 +75,6 @@ my $style = scalar(@style) ? 'STYLE="'. join(';', @style). '"' : '';
 
 my $size = $opt{'size'} ? 'SIZE='.$opt{'size'} : '';
 
+my $multiple = $opt{'multiple'} ? 'MULTIPLE' : '';
+
 </%init>
diff --git a/httemplate/search/elements/cust_pay_or_refund.html b/httemplate/search/elements/cust_pay_or_refund.html
index 0e3fce3..ce513ab 100755
--- a/httemplate/search/elements/cust_pay_or_refund.html
+++ b/httemplate/search/elements/cust_pay_or_refund.html
@@ -39,7 +39,7 @@ Examples:
          )
 
 </%doc>
-<& search.html,
+<& grouped-search.html,
                 'title'          => $title, # XXX: translate
                 'name_singular'  => $name_singular,
                 'query'          => $sql_query,
@@ -54,6 +54,16 @@ Examples:
                 'link_onclicks'  => \@link_onclicks,
                 'color'          => \@color,
                 'style'          => \@style,
+
+                'group_column'   => 'payby',
+                'group_label'    => 'payby_name',
+                'subtotal'       => { 'paid' => 'sum(paid)' },
+                'subtotal_row'   => [ 'Subtotal',
+                                      sub { sprintf($money, $_[0]->paid) },
+                                    ],
+                'total_row'      => [ '<B>Total</B>',
+                                      sub { sprintf("<B>$money</B>", $_[0]->paid) },
+                                    ],
 &>
 <%init>
 
@@ -61,6 +71,9 @@ my %opt = @_;
 
 my $curuser = $FS::CurrentUser::CurrentUser;
 
+my $conf = FS::Conf->new;
+my $money = ($conf->config('money_char') || '$') . '%.2f';
+
 die "access denied"
   unless $curuser->access_right('Financial reports');
 
@@ -165,7 +178,7 @@ push @header, "\u$name_singular",
 $align .= 'rr';
 push @links, '', '';
 push @fields, 'payby_payinfo_pretty',
-              sub { sprintf('$%.2f', shift->$amount_field() ) },
+              sub { sprintf($money, shift->$amount_field() ) },
 ;
 push @link_onclicks, $sub_receipt, '';
 push @sort_fields, '', $amount_field;
@@ -174,7 +187,7 @@ if ( $unapplied ) {
   push @header, emt('Unapplied');
   $align .= 'r';
   push @links, '';
-  push @fields, sub { sprintf('$%.2f', shift->unapplied_amount) };
+  push @fields, sub { sprintf($money, shift->unapplied_amount) };
   push @sort_fields, '';
 }
 
@@ -252,14 +265,10 @@ if ( $cgi->param('magic') ) {
       $title = $part_referral->referral. " $title";
     }
 
-    # cust_classnum (false laziness w/ elements/cust_main_dayranges.html, prepaid_income.html, cust_bill_pay.html, cust_bill_pkg.html cust_bill_pkg_referral.html, unearned_detail.html, cust_credit.html, cust_credit_refund.html, cust_main::Search::search_sql)
-    if ( grep { $_ eq 'cust_classnum' } $cgi->param ) {
-      my @classnums = grep /^\d*$/, $cgi->param('cust_classnum');
-      push @search, 'COALESCE( cust_main.classnum, 0) IN ( '.
-                        join(',', map { $_ || '0' } @classnums ).
-                    ' )'
-        if @classnums;
-    }
+    # cust_classnum - standard matching
+    push @search, $m->comp('match-classnum',
+        param => 'cust_classnum', field => 'cust_main.classnum'
+      );
 
     if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
       push @search, "$table.custnum = $1";
@@ -321,7 +330,6 @@ if ( $cgi->param('magic') ) {
 
           } elsif ( $cardtype eq 'Discover' ) {
 
-            my $conf = new FS::Conf;
             my $country = $conf->config('countrydefault') || 'US';
 
             $search =
diff --git a/httemplate/search/elements/grouped-search.html b/httemplate/search/elements/grouped-search.html
new file mode 100644
index 0000000..56fc88d
--- /dev/null
+++ b/httemplate/search/elements/grouped-search.html
@@ -0,0 +1,49 @@
+<%doc>
+
+<& elements/grouped-search/html,
+
+   # required
+  'title'         => 'Page title',
+  
+  'name_singular' => 'item',
+
+  'query' => {
+                'table'     => 'tablename',
+                'hashref'   => { 'field' => 'value', ... }, # optional
+                'select'    => '*',
+                'addl_from' => '',
+                'extra_sql' => '',
+                #'order_by' is not allowed
+             },
+  'group_column' => 'classnum', # must be a single field
+  # 'group_table' => 'mytable', # optional; defaults to the one in the query
+  'group_label'  => 'classname',
+  'subtotal' => { amount => 'sum(amount)', ... }
+    # The subtotal row will be generated as an FS::* object of the same type
+    # as all the detail rows.
+    # The only fields present will be the grouping column and any subtotals 
+    # defined in 'subtotal'. Key is a field name (in the FS::* object), 
+    # value is an SQL aggregate expression.
+
+    # How to display that object: arrayref of literal strings or coderefs
+    # (the subtotal object will be passed). These will be placed in table
+    # cells, and can contain HTML.
+  'subtotal_row' =>
+    [ 'Total'
+      sub { sprintf('%.2f', $_[0]->amount) },
+    ],
+
+  'order_by' => '_date', # SQL ordering expression for the detail rows
+
+  'header' => [ ... ], # per search.html
+  'fields' => [ ... ],
+
+&>
+</%doc>
+<%init>
+my $type = 'html';
+if ($cgi->param('type') =~ /^(html|html-print|xls)$/) {
+  $type = $1;
+}
+</%init>
+<& "grouped-search/$type", @_ &>
diff --git a/httemplate/search/elements/grouped-search/core b/httemplate/search/elements/grouped-search/core
new file mode 100644
index 0000000..fe47fb7
--- /dev/null
+++ b/httemplate/search/elements/grouped-search/core
@@ -0,0 +1,153 @@
+<%doc>
+
+my $group_data = $m->comp( 'elements/grouped-search/core',
+
+  'query' => {
+                'table'     => 'tablename',
+                'hashref'   => { 'field' => 'value', ... }, # optional
+                'select'    => '*',
+                'addl_from' => '',
+                'extra_sql' => '',
+                #'order_by' is not allowed
+             },
+  'group_column' => 'classnum', # must be a single field
+  # 'group_table' => 'mytable', # optional; defaults to the one in the query
+  'group_label'  => 'classname',
+  'subtotal' => { amount => 'sum(amount)', ... }
+    # The subtotal row will be generated as an FS::* object of the same type
+    # as all the detail rows.
+    # The only fields present will be the grouping column and any subtotals 
+    # defined in 'subtotal'. Key is a field name (in the FS::* object), 
+    # value is an SQL aggregate expression.
+
+    # How to display that object: arrayref of literal strings or coderefs
+    # (the subtotal object will be passed). These will be placed in table
+    # cells, and can contain HTML.
+  'subtotal_row' =>
+    [ 'Subtotal',  sub { sprintf('%.2f', $_[0]->amount) }, ],
+
+    # The subtotal aggregates will also be calculated for the entire data
+    # set, without grouping. How to display those (if not present, will 
+    # not be displayed);
+  'total_row' =>
+    [ 'Total', sub { sprintf('%.2f', $_[0]->amount) }, ],
+
+  'order_by' => '_date', # SQL ordering expression for the detail rows
+
+  'header' => [ ... ], # per search.html
+  'fields' => [ ... ],
+);
+
+returns:
+{
+  num           => number of groups,
+  groups        => [ group summary objects,
+                     with group key columns, subtotals, and "num_rows" ],
+  group_labels  => [ label strings ],
+  group_footers => [ formatted subtotal row arrayrefs ],
+  queries       => [ FS::Query objects to evaluate to get detail rows ],
+}
+
+</%doc>
+<%shared>
+my $conf = FS::Conf->new;
+</%shared>
+<%init>
+my %opt = @_;
+
+my $base_query = FS::Query->new( $opt{'query'} );
+my $main_table = $base_query->{table};
+
+# $opt{subtotal} is a hashref of field names => SQL aggregate expressions.
+
+my @subtotal_names = sort keys(%{ $opt{'subtotal'} });
+my @subtotal_exprs = map { $opt{'subtotal'}->{$_} } @subtotal_names;
+
+# qualify the group key to the main table unless otherwise specified
+my $group_table = $opt{group_table} || $main_table;
+my $group_key = $group_table . '.' . $opt{group_column};
+
+my @select = (
+  $group_key,             # need this to identify groups
+  'COUNT(*) as num_rows', # and this for pagination
+  map { '(' . $subtotal_exprs[$_] . ') AS "' . $subtotal_names[$_] . '"' }
+    0 .. (scalar(@subtotal_names) - 1)
+);
+
+my $group_query = $base_query->clone;
+$group_query->{order_by} = "GROUP BY $group_key";
+$group_query->{select} = join(',', @select);
+# keep table, addl_from, extra_sql, and hashref as they are
+
+#warn Dumper($group_query); #DEBUG
+
+# run the group query and create a tab label for each group
+my @groups = $group_query->qsearch;
+
+# also run it with no grouping, and just get overall totals
+my $total_query = $base_query->clone;
+shift @select; # remove $group_key
+$total_query->{select} = join(',', @select);
+$total_query->{order_by} = '';
+
+my $totals = $total_query->qsearchs;
+
+my $order_by = '';
+if ( $cgi->param('order_by') =~ /^(\w+\.?\w*(?: DESC)?)$/ ) {
+  $order_by = $1;
+}
+if ( $opt{order_by} ) {
+  $order_by .= ', ' if length($order_by); # user selection takes priority
+  $order_by .= $opt{order_by};
+}
+$order_by = "ORDER BY $order_by " if $order_by;
+$base_query->{order_by} = $order_by;
+
+my $group_label = $opt{group_label} || $opt{group_column};
+my (@group_labels, @group_footers, @queries);
+for my $i (0 .. scalar(@groups) - 1) {
+  my $label = $groups[$i]->$group_label . ' (' . $groups[$i]->num_rows . ')';
+  push @group_labels, $label;
+
+  my @footer;
+  if ($opt{'subtotal_row'}) {
+    for( my $col = 0;
+         exists($opt{'subtotal_row'}[$col]) or exists($opt{'header'}[$col]);
+         $col++
+    ) {
+      my $value = $opt{'subtotal_row'}[$col] || '';
+      $value = &$value( $groups[$i] ) if ref($value) eq 'CODE';
+      $footer[$col] = $value;
+    }
+  }
+  push @group_footers, \@footer;
+
+  my $detail_query = $base_query->clone;
+  my $group_key_value = $groups[$i]->get( $opt{group_column} );
+  $group_key_value = dbh->quote($group_key_value)
+    unless looks_like_number($group_key_value);
+  $detail_query->and_where("$group_key = $group_key_value");
+  push @queries, $detail_query;
+}
+
+my @total_footer;
+if ($opt{'total_row'}) {
+  for( my $col = 0;
+       exists($opt{'total_row'}[$col]) or exists($opt{'header'}[$col]);
+       $col++
+  ) {
+    my $value = $opt{'total_row'}[$col] || '';
+    $value = &$value( $totals ) if ref($value) eq 'CODE';
+    $total_footer[$col] = $value;
+  }
+}
+
+return {
+  num           => scalar(@groups),
+  groups        => \@groups,
+  group_labels  => \@group_labels,
+  group_footers => \@group_footers,
+  queries       => \@queries,
+  total_footer  => \@total_footer,
+};
+</%init>
diff --git a/httemplate/search/elements/grouped-search/html b/httemplate/search/elements/grouped-search/html
new file mode 100644
index 0000000..aff17ce
--- /dev/null
+++ b/httemplate/search/elements/grouped-search/html
@@ -0,0 +1,131 @@
+<%shared>
+my $conf = FS::Conf->new;
+</%shared>
+<%init>
+my %opt = @_;
+$opt{'name'} ||= PL($opt{'name_singular'});
+
+my $group_info = $m->comp('core', %opt);
+
+my $redirect;
+
+if ( $group_info->{num} == 0 ) {
+  $redirect = $opt{'redirect_empty'};
+  if ($redirect) {
+    $redirect = &$redirect($cgi) if ref($redirect) eq 'CODE';
+    redirect( $redirect );
+  } else { # just print this stuff and exit
+    $m->comp('/elements/header.html', $opt{'title'});
+    $m->print('<BR><BR>No matching ' . $opt{'name'} . ' found.<BR>');
+    $m->comp('/elements/footer.html');
+    $m->abort;
+  }
+}
+
+# this mode has a concept of "current group"
+my $curr_group = 0;
+if ($cgi->param('group') =~ /^(\d+)$/) {
+  $curr_group = $1;
+}
+
+my $group = $group_info->{groups}[$curr_group];
+my $query = $group_info->{queries}[$curr_group];
+my $footer = $group_info->{group_footers}[$curr_group];
+my $total_footer =  $group_info->{total_footer} || [];
+# pagination
+my ($limit, $offset);
+my $maxrecords = $conf->config('maxsearchrecordsperpage') || 50;
+if ( $cgi->param('maxrecords') =~ /^(\d+)$/ ) {
+  $maxrecords = $1;
+}
+if ( $maxrecords ) {
+  $limit = "LIMIT $maxrecords";
+  if ( $cgi->param('offset') =~ /^(\d+)$/ ) {
+    $offset = $1;
+    $limit .= " OFFSET $offset";
+  }
+}
+$query->{order_by} .= $limit if $limit;
+
+#warn Dumper($query); #DEBUG
+
+# run the query
+my @rows = $query->qsearch;
+
+#warn Dumper(\@rows); #DEBUG
+
+my $pager = '';
+# show pager if needed
+if ( $group->num_rows > scalar(@rows) ) {
+  $pager = include( '/elements/pager.html',
+    'offset'      => $offset,
+    'num_rows'    => scalar(@rows),
+    'total'       => $group->num_rows,
+    'maxrecords'  => $maxrecords,
+  );
+}
+
+# set up tab bar
+my @menubar;
+for (my $i = 0; $i < $group_info->{num}; $i++) {
+  push @menubar, $group_info->{group_labels}[$i], ";group=$i";
+}
+
+</%init>
+
+<& /elements/header.html, $opt{title} &>
+
+%# tab bar
+% $cgi->delete('group');
+% $cgi->delete('offset');
+% $cgi->delete('type');
+<& /elements/menubar.html,
+  { newstyle => 1,
+    url_base => $cgi->self_url,
+    selected => $group_info->{group_labels}[$curr_group] },
+  @menubar
+&>
+
+<DIV CLASS="fstabcontainer">
+%# download links
+<P><% emt('Download full results') %><BR>
+% $cgi->param('type', 'xls');
+<A HREF="<% $cgi->self_url %>"><% emt('as Excel spreadsheet') %></A><BR>
+% $cgi->param('type', 'html-print');
+<A HREF="<% $cgi->self_url %>"><% emt('as printable copy') %></A><BR>
+</P>
+
+<% $pager %>
+
+<STYLE>
+  table.grid {
+    border-spacing: 0;
+  }
+</STYLE>
+<table class="grid">
+  <thead>
+  <& /search/elements/search-html.html:header_row,
+    'header'      => $opt{'header'},
+    'header2'     => $opt{'header2'},
+    'sort_fields' => ($opt{'sort_fields'} || $opt{'fields'}),
+  &>
+  </thead>
+  <tbody>
+  <& /search/elements/search-html.html:data_rows,
+    'rows' => \@rows,
+    'opt'  => \%opt,
+  &>
+  </tbody>
+  <tfoot>
+  <& /search/elements/search-html.html:footer_row, row => $footer, opt => \%opt &>
+%   if ( scalar @$total_footer ) {
+  <& /search/elements/search-html.html:footer_row, row => $total_footer, opt => \%opt &>
+%   }
+  </tfoot>
+</table>
+
+<% $pager %>
+</DIV>
+
+<& /elements/footer.html &>
+
diff --git a/httemplate/search/elements/grouped-search/html-print b/httemplate/search/elements/grouped-search/html-print
new file mode 100644
index 0000000..c4c3b1e
--- /dev/null
+++ b/httemplate/search/elements/grouped-search/html-print
@@ -0,0 +1,79 @@
+<%doc>
+
+The "printable" view (all groups on one page).
+</%doc>
+<%init>
+my %opt = @_;
+
+my $group_info = $m->comp('core', %opt);
+my $ncols = scalar(@{ $opt{header} });
+
+my $total_footer = $group_info->{total_footer} || [];
+</%init>
+<& /elements/header-popup.html, $opt{title} &>
+
+<STYLE>
+.grouphead {
+  text-align: left;
+  font-size: 120%;
+  padding: 1ex 0 0.5ex 0.1ex;
+  border-top: 1px solid black;
+}
+.subtotal td {
+  background-color: #dddddd;
+  font-style: italic;
+  border-top: 1px dashed black;
+}
+.total td {
+  background-color: #dddddd;
+  font-style: italic;
+  font-weight: bold;
+  border-top: 2px solid black !important;
+}
+</STYLE>
+<& /elements/table-grid.html &>
+<THEAD>
+<& /search/elements/search-html.html:header_row,
+  'header'      => $opt{'header'},
+  'header2'     => $opt{'header2'},
+  'sort_fields' => ($opt{'sort_fields'} || $opt{'fields'}),
+&>
+</THEAD>
+
+% for (my $curr_group = 0; $curr_group < $group_info->{num}; $curr_group++) {
+%   my $group = $group_info->{groups}[$curr_group];
+%   my $query = $group_info->{queries}[$curr_group];
+%   my $footer = $group_info->{group_footers}[$curr_group];
+%   my $label = $group_info->{group_labels}[$curr_group];
+%   # run the query
+%   my @rows = $query->qsearch;
+%   #warn Dumper(\@rows); #DEBUG
+
+<TBODY>
+<TR><TH CLASS="grouphead" COLSPAN="<% $ncols %>">
+  <% $label %>
+</TH></TR>
+
+<& /search/elements/search-html.html:data_rows,
+  rows  => \@rows,
+  opt   => \%opt,
+&>
+</TBODY>
+<TBODY CLASS="subtotal">
+<& /search/elements/search-html.html:footer_row,
+  row   => $footer,
+  opt   => \%opt,
+&>
+</TBODY>
+% } # for $curr_group
+
+% if ( scalar @$total_footer ) {
+<TFOOT CLASS="total">
+<& /search/elements/search-html.html:footer_row,
+  row   => $total_footer,
+  opt   => \%opt,
+&>
+</TFOOT>
+% }
+</TABLE>
+<& /elements/footer.html &>
diff --git a/httemplate/search/elements/grouped-search/xls b/httemplate/search/elements/grouped-search/xls
new file mode 100644
index 0000000..8540dcf
--- /dev/null
+++ b/httemplate/search/elements/grouped-search/xls
@@ -0,0 +1,61 @@
+<%doc>
+
+Excel spreadsheet view.
+
+</%doc>
+<%init>
+my %opt = @_;
+
+my $group_info = $m->comp('core', %opt);
+
+# minor false laziness with search-xls.html
+my $override = '';
+$override = 'XLSX' if grep { $_->num_rows >= 65536 }
+                      @{ $group_info->{groups} };
+
+my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override);
+
+my $filename = $opt{'name'} || PL($opt{'name_singular'});
+$filename .= $format->{extension};
+
+http_header('Content-Type' => $format->{mime_type} );
+http_header('Content-Disposition' => qq!attachment;filename="$filename"! );
+$HTML::Mason::Commands::r->headers_out->{'Cache-control'} = 'max-age=0';
+
+my $data = '';
+my $XLS = new IO::Scalar \$data;
+my $workbook = $format->{class}->new($XLS)
+  or die "Error opening Excel file: $!";
+
+my $title = $opt{'title'};
+$title =~ s/[\[\]\:\*\?\/\/]//g;
+$title = substr($title, 0, 31);
+
+for (my $curr_group = 0; $curr_group < $group_info->{num}; $curr_group++) {
+  my $group = $group_info->{groups}[$curr_group];
+  my $query = $group_info->{queries}[$curr_group];
+  my $footer = $group_info->{group_footers}[$curr_group];
+  my $label = $group_info->{group_labels}[$curr_group];
+  # run the query
+  my @rows = $query->qsearch;
+  #warn Dumper(\@rows); #DEBUG
+
+  # pass arrayrefs to write_row to write multiple rows
+  $opt{footer} = [ List::MoreUtils::pairwise { [ $a, $b ] }
+    @$footer,
+    @{$group_info->{total_footer}}
+  ];
+  $m->comp('/search/elements/search-xls.html:worksheet',
+    workbook  => $workbook,
+    title     => $label,
+    header    => $opt{header},
+    opt       => \%opt,
+    rows      => \@rows,
+  );
+}
+
+$workbook->close();
+
+$m->clear_buffer();
+$m->print($data);
+</%init>
diff --git a/httemplate/search/elements/match-classnum b/httemplate/search/elements/match-classnum
new file mode 100644
index 0000000..ed1efd5
--- /dev/null
+++ b/httemplate/search/elements/match-classnum
@@ -0,0 +1,12 @@
+<%args>
+$param            # name of the form field containing the classnum
+$field => $param  # SQL field that must match that form field
+</%args>
+<%init>
+my @values = grep /^(\d+)$/, $cgi->param( $param );
+if (@values) {
+  return "COALESCE($field, 0) IN (" . join(',', @values) . ')';
+} else {
+  return;
+}
+</%init>
diff --git a/httemplate/search/elements/report_cust_pay_or_refund.html b/httemplate/search/elements/report_cust_pay_or_refund.html
index 9c4ca27..70727c0 100644
--- a/httemplate/search/elements/report_cust_pay_or_refund.html
+++ b/httemplate/search/elements/report_cust_pay_or_refund.html
@@ -27,36 +27,14 @@ Examples:
     </TH>
   </TR>
 
-  <TR>
-    <TD ALIGN="right"><% ucfirst(PL($name_singular)) %> of type: </TD>
-    <TD>
-      <SELECT NAME="payby" SIZE=16 MULTIPLE>
-
-%#        <OPTION VALUE=""><% mt('all') |h %></OPTION>
-%#        <OPTION VALUE="CARD"><% mt('credit card (all)') |h %></OPTION>
-
-        <OPTION VALUE="CARD-VisaMC" SELECTED><% mt('credit card (Visa/MasterCard)') |h %></OPTION>
-        <OPTION VALUE="CARD-Amex" SELECTED><% mt('credit card (American Express)') |h %></OPTION>
-        <OPTION VALUE="CARD-Discover" SELECTED><% mt('credit card (Discover)') |h %></OPTION>
-        <OPTION VALUE="CARD-Maestro" SELECTED><% mt('credit card (Maestro/Switch/Solo)') |h %></OPTION>
-        <OPTION VALUE="CARD-Tokenized" SELECTED><% mt('credit card (Tokenized)') |h %></OPTION>
-
-        <OPTION VALUE="CHEK" SELECTED><% mt('electronic check / ACH') |h %></OPTION>
-        <OPTION VALUE="BILL" SELECTED><% mt('check') |h %></OPTION>
-        <OPTION VALUE="CASH" SELECTED><% mt('cash') |h %></OPTION>
-        <OPTION VALUE="PPAL" SELECTED><% mt('Paypal') |h %></OPTION>
-        <OPTION VALUE="APPL" SELECTED><% mt('Apple Store') |h %></OPTION>
-        <OPTION VALUE="ANRD" SELECTED><% mt('Android Market') |h %></OPTION>
-
-        <OPTION VALUE="PREP" SELECTED><% mt('prepaid card') |h %></OPTION>
-        <OPTION VALUE="WIRE" SELECTED><% mt('wire transfer') |h %></OPTION>
-        <OPTION VALUE="WEST" SELECTED><% mt('Western Union') |h %></OPTION>
-        <OPTION VALUE="EDI" SELECTED><% mt('Electronic Debit (EDI)') |h %></OPTION>
-        <OPTION VALUE="MCRD" SELECTED><% mt('manual credit card') |h %></OPTION>
-        <OPTION VALUE="MCHK" SELECTED><% mt('manual electronic check') |h %></OPTION>
-      </SELECT>
-    </TD>
-  </TR>
+  <& /elements/tr-select.html,
+    label     => ucfirst(PL($name_singular)) . ' of type:',
+    field     => 'payby',
+    options   => [ keys(\%payby) ],
+    labels    => \%payby,
+    multiple  => 1,
+    size      => 16
+  &>
 
   <TR>
     <TD ALIGN="right"><% mt('Check #:') |h %> </TD>
@@ -137,8 +115,7 @@ Examples:
        'label'        => emt('Customer class'),
        'field'        => 'cust_classnum',
        'multiple'     => 1,
-       'pre_options'  => [ '' => emt('(none)') ],
-       'all_selected' => 1,
+       'pre_options'  => [ 0 => emt('(none)') ],
   &>
 
 </TABLE>
@@ -189,4 +166,26 @@ my $title = $void ? "Voided $name_singular report" :
             "\u$name_singular report" ;
 $table .= '_void' if $void;
 
+tie (my %payby, 'Tie::IxHash',
+  'CARD-VisaMC'    => 'credit card (Visa/MasterCard)',
+  'CARD-Amex'      => 'credit card (American Express)',
+  'CARD-Discover'  => 'credit card (Discover)',
+  'CARD-Maestro'   => 'credit card (Maestro/Switch/Solo)',
+  'CARD-Tokenized' => 'credit card (Tokenized)',
+
+  'CHEK'           => 'electronic check / ACH',
+  'BILL'           => 'check',
+  'CASH'           => 'cash',
+  'PPAL'           => 'Paypal',
+  'APPL'           => 'Apple Store',
+  'ANRD'           => 'Android Market',
+
+  'PREP'           => 'prepaid card',
+  'WIRE'           => 'wire transfer',
+  'WEST'           => 'Western Union',
+  'EDI'            => 'Electronic Debit (EDI)',
+  'MCRD'           => 'manual credit card',
+  'MCHK'           => 'manual electronic check',
+);
+
 </%init>
diff --git a/httemplate/search/elements/search-html.html b/httemplate/search/elements/search-html.html
index 10cc955..d8ab30a 100644
--- a/httemplate/search/elements/search-html.html
+++ b/httemplate/search/elements/search-html.html
@@ -181,300 +181,13 @@
 
 %             }
 
-              <% include('/elements/table-grid.html') %>
-
-                <TR>
-%                 my $h2 = 0;
-%                 my $colspan = 0;
-%                 my @fields = @{ $opt{'sort_fields'} || $opt{'fields'} || [] };
-%                 my $order_by = $cgi->param('order_by');
-%                 foreach my $header ( @{ $opt{header} } ) { 
-%
-%                   my $field = shift @fields;
-%
-%                   $colspan-- if $colspan > 0;
-%                   next if $colspan;
-%
-%                   my $label = ref($header) ? $header->{label} : $header;
-%                   unless ( ref($field) || !$field ) {
-%                     if ( $order_by eq $field ) {
-%                       $cgi->param('order_by', "$field DESC");
-%                     } else {
-%                       $cgi->param('order_by', $field);
-%                     }
-%                     $label = qq(<A HREF="$self_url?). $cgi->query_string.
-%                              qq(">$label</A>);
-%                   }
-%
-%                   $colspan = ref($header) ? $header->{colspan} : 0;
-%                   my $rowspan = 1;
-%                   my $style = '';
-%                   if ( $opt{header2} ) {
-%                     if ( !length($opt{header2}->[$h2]) ) {
-%                       $rowspan = 2;
-%                       splice @{ $opt{header2} }, $h2, 1;
-%                     } else {
-%                       $h2++;
-%                       $style = 'STYLE="border-bottom: none"'
-%                     }
-%                   }
-                    <TH CLASS   = "grid"
-                        BGCOLOR = "#cccccc"
-                        ROWSPAN = "<% $rowspan %>"
-                        <% $colspan ? 'COLSPAN = "'.$colspan.'"' : '' %>
-                        <% $style %>
-
-                    >
-                      <% $label %>
-                    </TH>
-%                 } 
-                </TR>
-
-%               if ( $opt{header2} ) {
-                  <TR>
-%                   foreach my $header ( @{ $opt{header2} } ) { 
-%                     my $label = ref($header) ? $header->{label} : $header;
-                      <TH CLASS="grid" BGCOLOR="#cccccc">
-                        <FONT SIZE="-1"><% $label %></FONT>
-                      </TH>
-%                   } 
-                  </TR>
-%               }
-
-%               my $bgcolor1 = '#eeeeee';
-%               my $bgcolor2 = '#ffffff';
-%               my $bgcolor;
-%
-%               foreach my $row ( @$rows ) {
-%
-%                 if ( $bgcolor eq $bgcolor1 ) {
-%                   $bgcolor = $bgcolor2;
-%                 } else {
-%                   $bgcolor = $bgcolor1;
-%                 }
-
-%                 my $rowstyle = '';
-%                 if ( $row eq $opt{'footer_data'} ) {
-%                   $rowstyle = ' STYLE="border-top: dashed 1px black; font-style: italic"';
-%                   $bgcolor = '#dddddd';
-%                 }
-
-%                 my $trid = '';
-%                   if ( $opt{'link_field' } ) {
-%                     my $link_field = $opt{'link_field'};
-%                     if ( ref($link_field) eq 'CODE' ) {
-%                       $trid = &{$link_field}($row);
-%                     } else {
-%                       $trid = $row->$link_field();
-%                     }
-%                   }
-                  <TR ID="<%$trid |h%>"<%$rowstyle%>>
-                      
-
-%                   if ( $opt{'fields'} ) {
-%
-%                     my $links    = $opt{'links'} ? [ @{$opt{'links'}} ] : '';
-%                     my $onclicks = $opt{'link_onclicks'} ? [ @{$opt{'link_onclicks'}} ] : [];
-%                     my $tooltips = $opt{'tooltips'} ? [ @{$opt{'tooltips'}} ] : [];
-%                     my $aligns   = $opt{'align'} ? [ @{$opt{'align'}} ] : '';
-%                     my $colors   = $opt{'color'} ? [ @{$opt{'color'}} ] : [];
-%                     my $sizes    = $opt{'size'}  ? [ @{$opt{'size'}}  ] : [];
-%                     my $styles   = $opt{'style'} ? [ @{$opt{'style'}} ] : [];
-%                     my $cstyles  = $opt{'cell_style'} ? [ @{$opt{'cell_style'}} ] : [];
-%
-%                     foreach my $field (
-%
-%                       map {
-%                             if ( ref($_) eq 'ARRAY' ) {
-%
-%                               my $tableref = $_;
-%
-%                               '<TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0 WIDTH="100%">'.
-%
-%                               join('', map {
-%
-%                                 my $rowref = $_;
-%
-%                                 '<tr>'.
-%
-%                                 join('', map {
-%
-%                                   my $e = $_;
-%
-%                                   '<TD '.
-%                                     join(' ', map {
-%                                       uc($_).'="'. $e->{$_}. '"';
-%                                     }
-%                                     grep exists($e->{$_}),
-%                                          qw( align bgcolor colspan rowspan
-%                                              style valign width )
-%                                     ).
-%                                   '>'.
-%
-%                                   ( $e->{'link'}
-%                                       ? '<A HREF="'. $e->{'link'}. '">'
-%                                       : ''
-%                                   ).
-%                                   ( $e->{'onclick'} # don't use with 'link'
-%                                       ? '<A HREF="#" onclick="' .
-%                                         $e->{'onclick'}.'">'
-%                                       : ''
-%                                   ).
-%                                   ( $e->{'size'}
-%                                      ? '<FONT SIZE="'.uc($e->{'size'}).'">'
-%                                      : ''
-%                                   ).
-%                                   ( $e->{'data_style'}
-%                                       ? '<'. uc($e->{'data_style'}). '>'
-%                                       : ''
-%                                   ).
-%                                   $e->{'data'}.
-%                                   ( $e->{'data_style'}
-%                                       ? '</'. uc($e->{'data_style'}). '>'
-%                                       : ''
-%                                   ).
-%                                   ( $e->{'size'} ? '</FONT>' : '' ).
-%                                   ( $e->{'link'} || $e->{'onclick'} 
-%                                       ? '</A>'
-%                                       : '' ).
-%                                   '</td>';
-%
-%                                 } @$rowref ).
-%
-%                                 '</tr>';
-%                               } @$tableref ).
-%
-%                               '</table>';
-%
-%                             } else {
-%                               $_;
-%                             }
-%                           }
-%
-%                       map {
-%                             if ( ref($_) eq 'CODE' ) {
-%                               &{$_}($row);
-%                             } elsif ( ref($row) eq 'ARRAY' and 
-%                                       $_ =~ /^\d+$/ ) {
-%                             # for the 'straight SQL' case: specify fields
-%                             # by position
-%                               encode_entities($row->[$_]);
-%                             } else {
-%                               encode_entities($row->$_());
-%                             }
-%                           }
-%                       @{$opt{'fields'}}
-%
-%                     ) {
-%
-%#                       my $class = ( $field =~ /^<TABLE/i ) ? 'inv' : 'grid';
-%                       my $class = 'grid';
-%
-%                       my $align = $aligns ? shift @$aligns : '';
-%                       $align = " ALIGN=$align" if $align;
-%
-%                       my $a = '';
-%                       if ( $links ) {
-%                         my $link = shift @$links;
-%                         my $onclick = shift @$onclicks;
-%                         my $tooltip = shift @$tooltips;
-%
-%                         if (    ! $opt{'agent_virt'}
-%                              || ( $null_link && ! $row->agentnum )
-%                              || grep { $row->agentnum == $_ }
-%                                      @link_agentnums
-%                            ) {
-%        
-%                           $link = &{$link}($row)
-%                             if ref($link) eq 'CODE';
-%
-%                           $onclick = &{$onclick}($row)
-%                             if ref($onclick) eq 'CODE';
-%                           $onclick = qq( onClick="$onclick") if $onclick;
-%
-%                           $tooltip = &{$tooltip}($row)
-%                             if ref($tooltip) eq 'CODE';
-%                           $tooltip = qq! id="a$id" !.
-%                             qq! onmouseover="return overlib(!.
-%                             $m->interp->apply_escapes($tooltip, 'h', 'js_string').
-%                             qq!, FGCLASS, 'tooltip', REF, 'a$id', !.
-%                             qq!REFC, 'LL', REFP, 'UL')"! if $tooltip;
-%
-%                           if ( $link ) {
-%                             my( $url, $method ) = @{$link};
-%                             if ( ref($method) eq 'CODE' ) {
-%                               $a = $url. &{$method}($row);
-%                             } else {
-%                               $a = $url. $row->$method();
-%                             }
-%                             $a = qq(<A HREF="$a"$onclick$tooltip>);
-%                           }
-%                           elsif ( $onclick ) {
-%                             $a = qq(<A HREF="javascript:void(0);"$onclick>);
-%                           }
-%                           elsif ( $tooltip ) {
-%                             $a = qq(<A $tooltip>);
-%                           }
-%                           $id++;
-
-%                         }
-%
-%                       }
-%
-%                       my $font = '';
-%                       my $color = shift @$colors;
-%                       $color = &{$color}($row) if ref($color) eq 'CODE';
-%                       my $size = shift @$sizes;
-%                       $size = &{$size}($row) if ref($size) eq 'CODE';
-%                       if ( $color || $size ) {
-%                         $font = '<FONT '.
-%                                 ( $color ? "COLOR=#$color "   : '' ).
-%                                 ( $size  ? qq(SIZE="$size" )  : '' ).
-%                                 '>';
-%                       }
-%
-%                       my($s, $es) = ( '', '' );
-%                       my $style = shift @$styles;
-%                       $style = &{$style}($row) if ref($style) eq 'CODE';
-%                       if ( $style ) {
-%                         $s = join( '', map "<$_>", split('', $style) );
-%                         $es = join( '', map "</$_>", split('', $style) );
-%                       }
-%
-%                       my $cstyle = shift @$cstyles;
-%                       $cstyle = &{$cstyle}($row) if ref($cstyle) eq 'CODE';
-%                       $cstyle = qq(STYLE="$cstyle")
-%                         if $cstyle;
-
-                        <TD CLASS="<% $class %>" BGCOLOR="<% $bgcolor %>" <% $align %> <% $cstyle %>><% $a %><% $font %><% $s %><% $field %><% $es %><% $font ? '</FONT>' : '' %><% $a ? '</A>' : '' %></TD>
-
-%                     } 
-%
-%                   } else { 
-%
-%                     foreach ( @$row ) { 
-                        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $_ %></TD>
-%                     }
-%
-%                   }
-
-                  </TR>
-
-%               } 
-
-%               if ( $opt{'footer'} ) {
-
-                  <TR>
-
-%                   foreach my $footer ( @{ $opt{'footer'} } ) { 
-%                     $footer = &{$footer}() if ref($footer) eq 'CODE';
-                      <TD CLASS="grid" BGCOLOR="#dddddd" STYLE="border-top: dashed 1px black;"><i><% $footer %></i></TD>
-%                   } 
-
-                  </TR>
-%               } 
-            
-              </TABLE>
+              <& SELF:data_table,
+                  rows            => $rows,
+                  null_link       => $null_link,
+                  link_agentnums  => \@link_agentnums,
+                  self_url        => $self_url,
+                  %opt
+              &>
 
               <% $pager %>
   
@@ -521,14 +234,358 @@ my $confmax        = $args{'confmax'};
 my $maxrecords     = $args{'maxrecords'};
 my $offset         = $args{'offset'};
 my %opt            = %{ $args{'opt'} };
-my $self_url       = $opt{'url'} || $cgi->url('-path_info' => 1, '-full' =>1);
 
-my $count_sth = dbh->prepare($opt{'count_query'})
-  or die "Error preparing $opt{'count_query'}: ". dbh->errstr;
-$count_sth->execute
-  or die "Error executing $opt{'count_query'}: ". $count_sth->errstr;
-my $count_arrayref = $count_sth->fetchrow_arrayref;
+# must be an arrayref of the row count, followed by any other totals
+my $count_arrayref = $args{'totals'};
 my $total = $count_arrayref->[0];
 
-my $id = 0;
+# there used to be an option to override this, for highly dubious reasons
+my $self_url = $cgi->url('-path_info' => 1, '-full' =>1);
+
 </%init>
+<%method data_table>
+% my %opt = @_;
+% my $rows = delete $opt{rows};
+% my $self_url = delete $opt{self_url};
+<& /elements/table-grid.html &>
+
+<THEAD>
+<& SELF:header_row,
+  'header'      => $opt{'header'},
+  'header2'     => $opt{'header2'},
+  'sort_fields' => ($opt{'sort_fields'} || $opt{'fields'}),
+&>
+</THEAD>
+
+<TBODY>
+<& SELF:data_rows, rows => $rows, opt => \%opt &>
+</TBODY>
+
+% if ( $opt{'footer'} ) {
+<TFOOT>
+<& SELF:footer_row, row => $opt{'footer'}, opt => \%opt &>
+</TFOOT> 
+% } 
+</TABLE>
+</%method>
+<%method header_row>
+<%args>
+ at sort_fields
+ at header
+ at header2 => ()
+</%args>
+  <TR>
+% my $h2 = 0;
+% my $colspan = 0;
+% my $order_by = $cgi->param('order_by');
+% my $self_url = $cgi->url('-path_info' => 1, '-full' =>1);
+% foreach my $header ( @header ) { 
+%
+%   my $field = shift @sort_fields;
+%
+%   $colspan-- if $colspan > 0;
+%   next if $colspan;
+%
+%   my $label = ref($header) ? $header->{label} : $header;
+%   unless ( ref($field) || !$field ) {
+%     if ( $order_by eq $field ) {
+%       $cgi->param('order_by', "$field DESC");
+%     } else {
+%       $cgi->param('order_by', $field);
+%     }
+%     $label = qq(<A HREF="$self_url?). $cgi->query_string.
+%              qq(">$label</A>);
+%   }
+%
+%   $colspan = ref($header) ? $header->{colspan} : 0;
+%   my $rowspan = 1;
+%   my $style = '';
+%   if ( @header2 ) {
+%     if ( !length($header2[$h2]) ) {
+%       $rowspan = 2;
+%       splice @header2, $h2, 1;
+%     } else {
+%       $h2++;
+%       $style = 'STYLE="border-bottom: none"'
+%     }
+%   }
+    <TH CLASS   = "grid"
+        BGCOLOR = "#cccccc"
+        ROWSPAN = "<% $rowspan %>"
+        <% $colspan ? 'COLSPAN = "'.$colspan.'"' : '' %>
+        <% $style %>
+
+    >
+      <% $label %>
+    </TH>
+% } 
+  </TR>
+
+% if ( @header2 ) {
+  <TR>
+%   foreach my $header ( @header2 ) { 
+%     my $label = ref($header) ? $header->{label} : $header;
+      <TH CLASS="grid" BGCOLOR="#cccccc">
+        <FONT SIZE="-1"><% $label %></FONT>
+      </TH>
+%   } 
+  </TR>
+% }
+</%method>
+<%method data_rows>
+<%args>
+$rows => []
+%opt
+</%args>
+% my %align = (
+%   'l' => 'left',
+%   'r' => 'right',
+%   'c' => 'center',
+%   ' ' => '',
+%   '.' => '',
+% );
+% if ( $opt{align} and !ref($opt{align}) ) {
+%   $opt{align} = [ map $align{$_}, split(//, $opt{align}) ];
+% }
+
+% my $i = 0; # for row striping # XXX CSS - nth-child
+% foreach my $row ( @$rows ) {
+%
+%   my $rowstyle = '';
+%   if ( $row eq $opt{'footer_data'} ) { # XXX CSS - tfoot
+%     $rowstyle = ' STYLE="border-top: dashed 1px black; font-style: italic background-color=#dddddd"';
+%   }
+%
+%   my $trid = '';
+%   if ( $opt{'link_field' } ) {
+%     my $link_field = $opt{'link_field'};
+%     if ( ref($link_field) eq 'CODE' ) {
+%       $trid = &{$link_field}($row);
+%     } else {
+%       $trid = $row->$link_field();
+%     }
+%   }
+    <TR ID="<%$trid |h%>" CLASS="row<% $i % 2 %>"<%$rowstyle%>>
+
+%   if ( $opt{'fields'} ) {
+%
+%     my $links    = $opt{'links'} ? [ @{$opt{'links'}} ] : '';
+%     my $onclicks = $opt{'link_onclicks'} ? [ @{$opt{'link_onclicks'}} ] : [];
+%     my $tooltips = $opt{'tooltips'} ? [ @{$opt{'tooltips'}} ] : [];
+%     my $aligns   = $opt{'align'} ? [ @{$opt{'align'}} ] : '';
+%     my $colors   = $opt{'color'} ? [ @{$opt{'color'}} ] : [];
+%     my $sizes    = $opt{'size'}  ? [ @{$opt{'size'}}  ] : [];
+%     my $styles   = $opt{'style'} ? [ @{$opt{'style'}} ] : [];
+%     my $cstyles  = $opt{'cell_style'} ? [ @{$opt{'cell_style'}} ] : [];
+%
+%     foreach my $field (
+%
+%       map {
+%             if ( ref($_) eq 'ARRAY' ) {
+%
+%               my $tableref = $_;
+%
+%               '<TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0 WIDTH="100%">'.
+%
+%               join('', map {
+%
+%                 my $rowref = $_;
+%
+%                 '<tr>'.
+%
+%                 join('', map {
+%
+%                   my $e = $_;
+%
+%                   '<TD '.
+%                     join(' ', map {
+%                       uc($_).'="'. $e->{$_}. '"';
+%                     }
+%                     grep exists($e->{$_}),
+%                          qw( align bgcolor colspan rowspan
+%                              style valign width )
+%                     ).
+%                   '>'.
+%
+%                   ( $e->{'link'}
+%                       ? '<A HREF="'. $e->{'link'}. '">'
+%                       : ''
+%                   ).
+%                   ( $e->{'onclick'} # don't use with 'link'
+%                       ? '<A HREF="#" onclick="' .
+%                         $e->{'onclick'}.'">'
+%                       : ''
+%                   ).
+%                   ( $e->{'size'}
+%                      ? '<FONT SIZE="'.uc($e->{'size'}).'">'
+%                      : ''
+%                   ).
+%                   ( $e->{'data_style'}
+%                       ? '<'. uc($e->{'data_style'}). '>'
+%                       : ''
+%                   ).
+%                   $e->{'data'}.
+%                   ( $e->{'data_style'}
+%                       ? '</'. uc($e->{'data_style'}). '>'
+%                       : ''
+%                   ).
+%                   ( $e->{'size'} ? '</FONT>' : '' ).
+%                   ( $e->{'link'} || $e->{'onclick'} 
+%                       ? '</A>'
+%                       : '' ).
+%                   '</td>';
+%
+%                 } @$rowref ).
+%
+%                 '</tr>';
+%               } @$tableref ).
+%
+%               '</table>';
+%
+%             } else {
+%               $_;
+%             }
+%           }
+%
+%       map {
+%             if ( ref($_) eq 'CODE' ) {
+%               &{$_}($row);
+%             } elsif ( ref($row) eq 'ARRAY' and 
+%                       $_ =~ /^\d+$/ ) {
+%             # for the 'straight SQL' case: specify fields
+%             # by position
+%               encode_entities($row->[$_]);
+%             } else {
+%               encode_entities($row->$_());
+%             }
+%           }
+%       @{$opt{'fields'}}
+%
+%     ) {
+%
+%       my $class = ( $field =~ /^<TABLE/i ) ? 'inv' : 'grid';
+%       my $class = 'grid';
+%
+%       my $align = $aligns ? shift @$aligns : '';
+%       $align = " ALIGN=$align" if $align;
+%
+%       my $a = '';
+%       if ( $links ) {
+%         my $link = shift @$links;
+%         my $onclick = shift @$onclicks;
+%         my $tooltip = shift @$tooltips;
+%
+%         if (    ! $opt{'agent_virt'}
+%              || ( $opt{'null_link'} && ! $row->agentnum )
+%              || grep { $row->agentnum == $_ }
+%                      @{ $opt{link_agentnums} }
+%            ) {
+%
+%           $link = &{$link}($row)
+%             if ref($link) eq 'CODE';
+%
+%           $onclick = &{$onclick}($row)
+%             if ref($onclick) eq 'CODE';
+%           $onclick = qq( onClick="$onclick") if $onclick;
+%
+%           $tooltip = &{$tooltip}($row)
+%             if ref($tooltip) eq 'CODE';
+%           $tooltip = qq! title="<% $tooltip |h %>"!;
+%#             qq! onmouseover="return overlib(!.
+%#             $m->interp->apply_escapes($tooltip, 'h', 'js_string').
+%#             qq!, FGCLASS, 'tooltip', REF, 'a$id', !.
+%#             qq!REFC, 'LL', REFP, 'UL')"! if $tooltip;
+%
+%           if ( $link ) {
+%             my( $url, $method ) = @{$link};
+%             if ( ref($method) eq 'CODE' ) {
+%               $a = $url. &{$method}($row);
+%             } else {
+%               $a = $url. $row->$method();
+%             }
+%             $a = qq(<A HREF="$a"$onclick$tooltip>);
+%           }
+%           elsif ( $onclick ) {
+%             $a = qq(<A HREF="javascript:void(0);"$onclick>);
+%           }
+%           elsif ( $tooltip ) {
+%             $a = qq(<A $tooltip>);
+%           }
+
+%         }
+%
+%       }
+%
+%       my $font = '';
+%       my $color = shift @$colors;
+%       $color = &{$color}($row) if ref($color) eq 'CODE';
+%       my $size = shift @$sizes;
+%       $size = &{$size}($row) if ref($size) eq 'CODE';
+%       if ( $color || $size ) {
+%         $font = '<FONT '.
+%                 ( $color ? "COLOR=#$color "   : '' ).
+%                 ( $size  ? qq(SIZE="$size" )  : '' ).
+%                 '>';
+%       }
+%
+%       my($s, $es) = ( '', '' );
+%       my $style = shift @$styles;
+%       $style = &{$style}($row) if ref($style) eq 'CODE';
+%       if ( $style ) {
+%         $s = join( '', map "<$_>", split('', $style) );
+%         $es = join( '', map "</$_>", split('', $style) );
+%       }
+%
+%       my $cstyle = shift @$cstyles;
+%       $cstyle = &{$cstyle}($row) if ref($cstyle) eq 'CODE';
+%       $cstyle = qq(STYLE="$cstyle")
+%         if $cstyle;
+
+        <TD CLASS="<% $class %>" <% $align %> <% $cstyle %>><% $a %><% $font %><% $s %><% $field %><% $es %><% $font ? '</FONT>' : '' %><% $a ? '</A>' : '' %></TD>
+
+%     } 
+%
+%   } else { # not $opt{'fields'}
+%
+%     foreach ( @$row ) { 
+        <TD CLASS="grid"><% $_ %></TD>
+%     }
+%
+%   }
+
+    </TR>
+
+%   $i++;
+%
+% } # foreach $row
+</%method>
+<%method footer_row>
+<%args>
+$row
+%opt
+</%args>
+%# don't try to respect all the styling options, just the ones that are
+%# hard to replicate with CSS
+% my %align = (
+%   'l' => 'left',
+%   'r' => 'right',
+%   'c' => 'center',
+%   ' ' => '',
+%   '.' => '',
+% );
+% if ( $opt{align} and !ref($opt{align}) ) {
+%   $opt{align} = [ map $align{$_}, split(//, $opt{align}) ];
+% }
+% my @aligns = @{ $opt{align} };
+
+<TR>
+% foreach my $footer ( @$row ) {
+%   $footer = &{$footer}() if ref($footer) eq 'CODE';
+%   my $align = shift @aligns;
+%   my $style = '';
+%   $style .= "text-align: $align;" if $align;
+    <TD CLASS="grid" STYLE="<% $style %>"><% $footer %></TD>
+% } 
+</TR>
+</%method>
+
diff --git a/httemplate/search/elements/search-xls.html b/httemplate/search/elements/search-xls.html
index 8334497..c4265e8 100644
--- a/httemplate/search/elements/search-xls.html
+++ b/httemplate/search/elements/search-xls.html
@@ -1,13 +1,10 @@
 <%init>
 
 my %args = @_;
-my $type   = $args{'type'};
 my $header = $args{'header'};
 my $rows   = $args{'rows'};
 my %opt    = %{ $args{'opt'} };    
 
-my $style  = $opt{'style'};
-
 my $override = scalar(@$rows) >= 65536 ? 'XLSX' : '';
 
 my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override);
@@ -35,9 +32,38 @@ my $workbook = $format->{class}->new($XLS)
 my $title = $opt{'title'};
 $title =~ s/[\[\]\:\*\?\/\/]//g;
 $title = substr($title, 0, 31);
+
+# append a single worksheet
+$m->comp( 'SELF:worksheet',
+  workbook  => $workbook,
+  title     => $title,
+  opt       => \%opt,
+  header    => $header,
+  rows      => $rows
+);
+
+$workbook->close();# or die "Error creating .xls file: $!";
+
+http_header('Content-Length' => length($data) );
+$m->clear_buffer();
+$m->print($data);
+
+</%init>
+<%method worksheet>
+<%args>
+$workbook
+$title
+%opt
+$header
+$rows
+</%args>
+<%perl>
+
 my $worksheet = $workbook->add_worksheet($title);
 
-$worksheet->protect();
+#$worksheet->protect();
+
+my $style = $opt{style};
 
 my($r,$c) = (0,0);
 
@@ -63,12 +89,29 @@ xl_parse_date_init();
 
 my %bold_format;
 
-my $writer = sub {
+my @widths;
+
+my $writer;
+$writer = sub {
   # Wrapper for $worksheet->write.
   # Do any massaging of the value/format here.
   my ($r, $c, $value, $format) = @_;
   #warn "writer called with format $format\n";
 
+  if ( ref $value eq 'ARRAY' ) {
+    # imitate the write_row() method: write the array into a column starting
+    # with $r.
+    # (currently only used in the footer; to use it anywhere else we'd need
+    # some way to return the number of rows written)
+    foreach my $v (@$value) {
+      $writer->($r, $c, $v, $format);
+      $r++;
+    }
+    return;
+  }
+
+  my $bold = 0;
+  my $date = 0;
   if ( $style->[$c] eq 'b' or $value =~ /<b>/i ) { # the only one in common use
     $value =~ s[</?b>][]ig;
     if ( !exists($bold_format{$format}) ) {
@@ -77,6 +120,7 @@ my $writer = sub {
       $bold_format{$format}->set_bold();
     }
     $format = $bold_format{$format};
+    $bold = 1;
   }
 
   # convert HTML entities
@@ -106,6 +150,7 @@ my $writer = sub {
       $date_format{$format}->set_num_format('mmm dd yyyy');
     }
     $format = $date_format{$format};
+    $date = 1;
   }
   else {
     # String: replace line breaks with newlines
@@ -113,6 +158,14 @@ my $writer = sub {
   }
   #warn "writing with format $format\n";
   $worksheet->write($r, $c, $value, $format);
+
+  # estimate width
+  # use Font::TTFMetrics; # would work, but we can't redistribute the font...
+  my $width = length($value);
+  $width = 11 if $date;
+  $width *= 1.1 if $bold;
+  $width += 1; # pad it a little
+  $widths[$c] = $width if $width > ($widths[$c] || 0);
 };
 
 $writer->( $r, $c++, $_, $header_format ) foreach @$header;
@@ -170,9 +223,9 @@ if ( $opt{'footer'} ) {
   }
 }
 
-$workbook->close();# or die "Error creating .xls file: $!";
-
-http_header('Content-Length' => length($data) );
-$m->print($data);
+for ( my $x = 0; $x < scalar @widths; $x++ ) {
+  $worksheet->set_column($x, $x, $widths[$x]);
+}
 
-</%init>
+</%perl>
+</%method>
diff --git a/httemplate/search/elements/search.html b/httemplate/search/elements/search.html
index 64f3a66..80bc89d 100644
--- a/httemplate/search/elements/search.html
+++ b/httemplate/search/elements/search.html
@@ -106,7 +106,8 @@ Example:
     'disable_maxselect' => '', # set true to disable record/page selection
     'disable_nonefound' => '', # set true to disable the "No matching Xs found"
                                # message
-   
+    'nohtmlheader'      => '', # set true to remove the header and menu bar
+ 
     #handling "disabled" fields in the records
     'disableable' => 1,  # set set to 1 (or column position for "disabled"
                          # status col) to enable if this table has a "disabled"
@@ -200,6 +201,7 @@ Example:
              confmax        => $confmax,
              maxrecords     => $maxrecords,
              offset         => $offset,
+             totals         => $totals,
              opt            => \%opt
           )
 %>
@@ -220,16 +222,6 @@ if ( !$curuser->access_right('Download report data') ) {
   $type = 'html';
 }
 
-my %align = (
-  'l' => 'left',
-  'r' => 'right',
-  'c' => 'center',
-  ' ' => '',
-  '.' => '',
-);
-$opt{align} = [ map $align{$_}, split(//, $opt{align}) ],
-  unless !$opt{align} || ref($opt{align});
-
 if($type =~ /csv|xls/) {
   my $h = $opt{'header'};
   my @del;
@@ -253,6 +245,12 @@ $opt{disable_download} = 0
 $opt{disable_download} = 1
   if $opt{really_disable_download};
 
+# get our queries ready
+my $query = $opt{query} or die "query required";
+my $count_query = $opt{count_query} or die "count_query required";
+# there was a default count_query but it hasn't worked in about ten years
+
+# set up agent restriction
 my @link_agentnums = ();
 my $null_link = '';
 if ( $opt{'agent_virt'} ) {
@@ -264,16 +262,16 @@ if ( $opt{'agent_virt'} ) {
   my $agentnums_sql = $curuser->agentnums_sql(
                         'null'       => $opt{'agent_null'},
                         'null_right' => $opt{'agent_null_right'},
-                        'table'      => $opt{'query'}{'table'},
+                        'table'      => $query->{'table'},
                       );
 
-  $opt{'query'}{'extra_sql'} .=
-    ( $opt{'query'}{'extra_sql'} =~ /WHERE/i || keys %{$opt{'query'}{'hashref'}}
-      ? ' AND '
-      : ' WHERE ' ). $agentnums_sql;
-
-  $opt{'count_query'} .=
-    ( $opt{'count_query'} =~ /WHERE/i ? ' AND ' : ' WHERE ' ).  $agentnums_sql;
+  if ( $query->{'extra_sql'} =~ /\bWHERE\b/i or keys %{$query->{hashref}} ) {
+    $query->{'extra_sql'} .= " AND $agentnums_sql";
+    $count_query .= " AND $agentnums_sql";
+  } else {
+    $query->{'extra_sql'} .= " WHERE $agentnums_sql";
+    $count_query .= " WHERE $agentnums_sql";
+  }
 
   if ( $opt{'agent_pos'} || $opt{'agent_pos'} eq '0'
        and scalar($curuser->agentnums) > 1           ) {
@@ -304,13 +302,13 @@ if ( $opt{'disableable'} ) {
 
   unless ( $cgi->param('showdisabled') ) { #modify searches
 
-    $opt{'query'}{'hashref'}{'disabled'} = '';
-    $opt{'query'}{'extra_sql'} =~ s/^\s*WHERE/ AND/i;
+    $query->{'hashref'}{'disabled'} = '';
+    $query->{'extra_sql'} =~ s/^\s*\bWHERE\b/ AND/i;
 
-    my $table = $opt{'query'}{'table'};
+    my $table = $query->{'table'};
 
-    $opt{'count_query'} .=
-      ( $opt{'count_query'} =~ /WHERE/i ? ' AND ' : ' WHERE ' ).
+    $count_query .= 
+      ( $count_query =~ /\bWHERE\b/i ? ' AND ' : ' WHERE ' ).
       "( $table.disabled = '' OR $table.disabled IS NULL )";
 
   } elsif (    $opt{'disabled_statuspos'}
@@ -357,10 +355,6 @@ my($confmax, $maxrecords, $offset );
 
 unless ( $type =~ /^(csv|xml|\w*.xls)$/) {
 # html mode
-  unless (exists($opt{count_query}) && length($opt{count_query})) {
-    ( $opt{count_query} = $opt{query} ) =~
-      s/^\s*SELECT\s*(.*?)\s+FROM\s/SELECT COUNT(*) FROM /i; #silly vim:/
-  }
 
   unless ( $type eq 'html-print' ) {
 
@@ -393,61 +387,56 @@ $order_by = $cgi->param('order_by') if $cgi->param('order_by');
 
 my $header = [ map { ref($_) ? $_->{'label'} : $_ } @{$opt{header}} ];
 my $rows;
-if ( ref($opt{query}) ) {
-
-  my @query;
-  if (ref($opt{query}) eq 'HASH') {
-    @query = ( $opt{query} );
-
-    if ( $order_by ) {
-      if ( $opt{query}->{'order_by'} ) {
-        if ( $opt{query}->{'order_by'} =~ /^(\s*ORDER\s+BY\s+)?(\S.*)$/is ) {
-          $opt{query}->{'order_by'} = "ORDER BY $order_by, $2";
-        } else {
-          warn "unparsable query order_by: ". $opt{query}->{'order_by'};
-          die "unparsable query order_by: ". $opt{query}->{'order_by'};
-        }
+
+my @query;
+if (ref($query) eq 'HASH') {
+  @query = $query;
+
+  if ( $order_by ) {
+    if ( $query->{'order_by'} ) {
+      if ( $query->{'order_by'} =~ /^(\s*ORDER\s+BY\s+)?(\S.*)$/is ) {
+        $query->{'order_by'} = "ORDER BY $order_by, $2";
       } else {
-        $opt{query}->{'order_by'} = "ORDER BY $order_by";
+        warn "unparsable query order_by: ". $query->{'order_by'};
+        die "unparsable query order_by: ". $query->{'order_by'};
       }
+    } else {
+      $query->{'order_by'} = "ORDER BY $order_by";
     }
-
-    $opt{query}->{'order_by'} .= " $limit";
-
-  } elsif (ref($opt{query}) eq 'ARRAY') {
-    @query = @{ $opt{query} };
-  } else {
-    die "invalid query reference";
   }
 
-  if ( $opt{disableable} && ! $cgi->param('showdisabled') ) {
-    #%search = ( 'disabled' => '' );
-    $opt{'query'}->{'hashref'}->{'disabled'} = '';
-    $opt{'query'}->{'extra_sql'} =~ s/^\s*WHERE/ AND/i;
-  }
+  $query->{'order_by'} .= " $limit";
 
-  #eval "use FS::$opt{'query'};";
-  my @param = qw( select table addl_from hashref extra_sql order_by debug );
-  $rows = [ qsearch( [ map { my $query = $_;
-                             ({ map { $_ => $query->{$_} } @param });
-                           }
-                       @query
-                     ],
-                     #'order_by' => $opt{order_by}. " ". $limit,
-                   )
-          ]; 
+} elsif (ref($query) eq 'ARRAY') {
+  # do we still use this? it was for the old 477 report.
+  @query = @{ $query };
 } else {
-  my $sth = dbh->prepare("$opt{'query'} $limit")
-    or die "Error preparing $opt{'query'}: ". dbh->errstr;
-  $sth->execute
-    or die "Error executing $opt{'query'}: ". $sth->errstr;
-
-  #can get # of rows without fetching them all?
-  $rows = $sth->fetchall_arrayref;
+  die "invalid query reference";
+}
 
-  $header ||= $sth->{NAME};
+if ( $opt{disableable} && ! $cgi->param('showdisabled') ) {
+  #%search = ( 'disabled' => '' );
+  $opt{'query'}->{'hashref'}->{'disabled'} = '';
+  $opt{'query'}->{'extra_sql'} =~ s/^\s*WHERE/ AND/i;
 }
 
+#eval "use FS::$opt{'query'};";
+my @param = qw( select table addl_from hashref extra_sql order_by debug );
+$rows = [ qsearch( [ map { my $query = $_;
+                           ({ map { $_ => $query->{$_} } @param });
+                         }
+                     @query
+                   ],
+                   #'order_by' => $opt{order_by}. " ". $limit,
+                 )
+        ]; 
+
+# run the count query to get number of rows and other totals
+my $count_sth = dbh->prepare($count_query);
+$count_sth->execute
+  or die "Error executing '$count_query': ".$count_sth->errstr;
+my $totals = $count_sth->fetchrow_arrayref;
+
 push @$rows, $opt{'footer_data'} if $opt{'footer_data'};
 
 </%init>

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

Summary of changes:
 FS/FS/Mason.pm                                     |    4 +-
 FS/FS/Query.pm                                     |  118 ++++
 httemplate/elements/freeside.css                   |   18 +
 httemplate/elements/select.html                    |    3 +
 httemplate/search/elements/cust_pay_or_refund.html |   32 +-
 httemplate/search/elements/grouped-search.html     |   49 ++
 httemplate/search/elements/grouped-search/core     |  153 +++++
 httemplate/search/elements/grouped-search/html     |  131 ++++
 .../search/elements/grouped-search/html-print      |   79 +++
 httemplate/search/elements/grouped-search/xls      |   61 ++
 httemplate/search/elements/match-classnum          |   12 +
 .../search/elements/report_cust_pay_or_refund.html |   63 +-
 httemplate/search/elements/search-html.html        |  659 +++++++++++---------
 httemplate/search/elements/search-xls.html         |   73 ++-
 httemplate/search/elements/search.html             |  135 ++--
 15 files changed, 1161 insertions(+), 429 deletions(-)
 create mode 100644 FS/FS/Query.pm
 create mode 100644 httemplate/search/elements/grouped-search.html
 create mode 100644 httemplate/search/elements/grouped-search/core
 create mode 100644 httemplate/search/elements/grouped-search/html
 create mode 100644 httemplate/search/elements/grouped-search/html-print
 create mode 100644 httemplate/search/elements/grouped-search/xls
 create mode 100644 httemplate/search/elements/match-classnum




More information about the freeside-commits mailing list