FS RT #27208 - adding Mobile phone to FS::ConfDefaults::cust_fields_avail and related...
[freeside.git] / FS / FS / UI / Web.pm
1 package FS::UI::Web;
2
3 use strict;
4 use vars qw($DEBUG @ISA @EXPORT_OK $me);
5 use Exporter;
6 use Carp qw( confess );
7 use HTML::Entities;
8 use FS::Conf;
9 use FS::Misc::DateTime qw( parse_datetime day_end );
10 use FS::Record qw(dbdef);
11 use FS::cust_main;  # are sql_balance and sql_date_balance in the right module?
12
13 #use vars qw(@ISA);
14 #use FS::UI
15 #@ISA = qw( FS::UI );
16 @ISA = qw( Exporter );
17
18 @EXPORT_OK = qw( svc_url );
19
20 $DEBUG = 0;
21 $me = '[FS::UID::Web]';
22
23 ###
24 # date parsing
25 ###
26
27 use Date::Parse;
28 sub parse_beginning_ending {
29   my($cgi, $prefix) = @_;
30   $prefix .= '_' if $prefix;
31
32   my $beginning = 0;
33   if ( $cgi->param($prefix.'begin') =~ /^(\d+)$/ ) {
34     $beginning = $1;
35   } elsif ( $cgi->param($prefix.'beginning') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
36     $beginning = parse_datetime($1) || 0;
37   }
38
39   my $ending = 4294967295; #2^32-1
40   if ( $cgi->param($prefix.'end') =~ /^(\d+)$/ ) {
41     $ending = $1 - 1;
42   } elsif ( $cgi->param($prefix.'ending') =~ /^([ 0-9\-\/\:]{1,64})$/ ) {
43     $ending = parse_datetime($1);
44     $ending = day_end($ending) unless $ending =~ /:/;
45   }
46
47   ( $beginning, $ending );
48 }
49
50 =item svc_url
51
52 Returns a service URL, first checking to see if there is a service-specific
53 page to link to, otherwise to a generic service handling page.  Options are
54 passed as a list of name-value pairs, and include:
55
56 =over 4
57
58 =item * m - Mason request object ($m)
59
60 =item * action - The action for which to construct "edit", "view", or "search"
61
62 =item ** part_svc - Service definition (see L<FS::part_svc>)
63
64 =item ** svcdb - Service table
65
66 =item *** query - Query string
67
68 =item *** svc   - FS::cust_svc or FS::svc_* object
69
70 =item ahref - Optional flag, if set true returns <A HREF="$url"> instead of just the URL.
71
72 =back 
73
74 * Required fields
75
76 ** part_svc OR svcdb is required
77
78 *** query OR svc is required
79
80 =cut
81
82   # ##
83   # #required
84   # ##
85   #  'm'        => $m, #mason request object
86   #  'action'   => 'edit', #or 'view'
87   #
88   #  'part_svc' => $part_svc, #usual
89   #   #OR
90   #  'svcdb'    => 'svc_table',
91   #
92   #  'query'    => #optional query string
93   #                # (pass a blank string if you want a "raw" URL to add your
94   #                #  own svcnum to)
95   #   #OR
96   #  'svc'      => $svc_x, #or $cust_svc, it just needs a svcnum
97   #
98   # ##
99   # #optional
100   # ##
101   #  'ahref'    => 1, # if set true, returns <A HREF="$url">
102
103 use FS::CGI qw(rooturl);
104 sub svc_url {
105   my %opt = @_;
106
107   #? return '' unless ref($opt{part_svc});
108
109   my $svcdb = $opt{svcdb} || $opt{part_svc}->svcdb;
110   my $query = exists($opt{query}) ? $opt{query} : $opt{svc}->svcnum;
111   my $url;
112   warn "$me [svc_url] checking for /$opt{action}/$svcdb.cgi component"
113     if $DEBUG;
114   if ( $opt{m}->interp->comp_exists("/$opt{action}/$svcdb.cgi") ) {
115     $url = "$svcdb.cgi?";
116   } else {
117
118     my $generic = $opt{action} eq 'search' ? 'cust_svc' : 'svc_Common';
119
120     $url = "$generic.html?svcdb=$svcdb;";
121     $url .= 'svcnum=' if $query =~ /^\d+(;|$)/ or $query eq '';
122   }
123
124   import FS::CGI 'rooturl'; #WTF!  why is this necessary
125   my $return = rooturl(). "$opt{action}/$url$query";
126
127   $return = qq!<A HREF="$return">! if $opt{ahref};
128
129   $return;
130 }
131
132 sub svc_link {
133   my($m, $part_svc, $cust_svc) = @_ or return '';
134   svc_X_link( $part_svc->svc, @_ );
135 }
136
137 sub svc_label_link {
138   my($m, $part_svc, $cust_svc) = @_ or return '';
139   my($svc, $label, $svcdb) = $cust_svc->label;
140   svc_X_link( $label, @_ );
141 }
142
143 sub svc_X_link {
144   my ($x, $m, $part_svc, $cust_svc) = @_ or return '';
145
146   return $x
147    unless $FS::CurrentUser::CurrentUser->access_right('View customer services');
148
149   confess "svc_X_link called without a service ($x, $m, $part_svc, $cust_svc)\n"
150     unless $cust_svc;
151
152   my $ahref = svc_url(
153     'ahref'    => 1,
154     'm'        => $m,
155     'action'   => 'view',
156     'part_svc' => $part_svc,
157     'svc'      => $cust_svc,
158   );
159
160   "$ahref$x</A>";
161 }
162
163 #this probably needs an ACL too...
164 sub svc_export_links {
165   my ($m, $part_svc, $cust_svc) = @_ or return '';
166
167   my $ahref = $cust_svc->export_links;
168
169   join('', @$ahref);
170 }
171
172 sub parse_lt_gt {
173   my($cgi, $field) = @_;
174
175   my @search = ();
176
177   my %op = ( 
178     'lt' => '<',
179     'gt' => '>',
180   );
181
182   foreach my $op (keys %op) {
183
184     warn "checking for ${field}_$op field\n"
185       if $DEBUG;
186
187     if ( $cgi->param($field."_$op") =~ /^\s*\$?\s*(-?[\d\,\s]+(\.\d\d)?)\s*$/ ) {
188
189       my $num = $1;
190       $num =~ s/[\,\s]+//g;
191       my $search = "$field $op{$op} $num";
192       push @search, $search;
193
194       warn "found ${field}_$op field; adding search element $search\n"
195         if $DEBUG;
196     }
197
198   }
199
200   @search;
201
202 }
203
204 ###
205 # cust_main report subroutines
206 ###
207
208
209 =item cust_header [ CUST_FIELDS_VALUE ]
210
211 Returns an array of customer information headers according to the supplied
212 customer fields value, or if no value is supplied, the B<cust-fields>
213 configuration value.
214
215 =cut
216
217 use vars qw( @cust_fields @cust_colors @cust_styles @cust_aligns );
218
219 sub cust_header {
220
221   warn "FS::UI:Web::cust_header called"
222     if $DEBUG;
223
224   my $conf = new FS::Conf;
225
226   my %header2method = (
227     'Customer'                 => 'name',
228     'Cust. Status'             => 'ucfirst_cust_status',
229     'Cust#'                    => 'custnum',
230     'Name'                     => 'contact',
231     'Company'                  => 'company',
232
233     # obsolete but might still be referenced in configuration
234     '(bill) Customer'          => 'name',
235     '(service) Customer'       => 'ship_name',
236     '(bill) Name'              => 'contact',
237     '(service) Name'           => 'ship_contact',
238     '(bill) Company'           => 'company',
239     '(service) Company'        => 'ship_company',
240     '(bill) Day phone'         => 'daytime',
241     '(bill) Night phone'       => 'night',
242     '(bill) Fax number'        => 'fax',
243  
244     'Customer'                 => 'name',
245     'Address 1'                => 'bill_address1',
246     'Address 2'                => 'bill_address2',
247     'City'                     => 'bill_city',
248     'State'                    => 'bill_state',
249     'Zip'                      => 'bill_zip',
250     'Country'                  => 'bill_country_full',
251     'Day phone'                => 'daytime', # XXX should use msgcat, but how?
252     'Night phone'              => 'night',   # XXX should use msgcat, but how?
253     'Mobile phone'             => 'mobile',  # XXX should use msgcat, but how?
254     'Fax number'               => 'fax',
255     '(bill) Address 1'         => 'bill_address1',
256     '(bill) Address 2'         => 'bill_address2',
257     '(bill) City'              => 'bill_city',
258     '(bill) State'             => 'bill_state',
259     '(bill) Zip'               => 'bill_zip',
260     '(bill) Country'           => 'bill_country_full',
261     '(service) Address 1'      => 'ship_address1',
262     '(service) Address 2'      => 'ship_address2',
263     '(service) City'           => 'ship_city',
264     '(service) State'          => 'ship_state',
265     '(service) Zip'            => 'ship_zip',
266     '(service) Country'        => 'ship_country_full',
267     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
268     'Payment Type'             => 'payby',
269     'Current Balance'          => 'current_balance',
270   );
271   $header2method{'Cust#'} = 'display_custnum'
272     if $conf->exists('cust_main-default_agent_custid');
273
274   my %header2colormethod = (
275     'Cust. Status' => 'cust_statuscolor',
276   );
277   my %header2style = (
278     'Cust. Status' => 'b',
279   );
280   my %header2align = (
281     'Cust. Status' => 'c',
282     'Cust#'        => 'r',
283   );
284
285   my $cust_fields;
286   my @cust_header;
287   if ( @_ && $_[0] ) {
288
289     warn "  using supplied cust-fields override".
290           " (ignoring cust-fields config file)"
291       if $DEBUG;
292     $cust_fields = shift;
293
294   } else {
295
296     if (    $conf->exists('cust-fields')
297          && $conf->config('cust-fields') =~ /^([\w\. \|\#\(\)]+):?/
298        )
299     {
300       warn "  found cust-fields configuration value"
301         if $DEBUG;
302       $cust_fields = $1;
303     } else { 
304       warn "  no cust-fields configuration value found; using default 'Cust. Status | Customer'"
305         if $DEBUG;
306       $cust_fields = 'Cust. Status | Customer';
307     }
308   
309   }
310
311   @cust_header = split(/ \| /, $cust_fields);
312   @cust_fields = map { $header2method{$_} || $_ } @cust_header;
313   @cust_colors = map { exists $header2colormethod{$_}
314                          ? $header2colormethod{$_}
315                          : ''
316                      }
317                      @cust_header;
318   @cust_styles = map { exists $header2style{$_} ? $header2style{$_} : '' }
319                      @cust_header;
320   @cust_aligns = map { exists $header2align{$_} ? $header2align{$_} : 'l' }
321                      @cust_header;
322
323   #my $svc_x = shift;
324   @cust_header;
325 }
326
327 sub cust_sort_fields {
328   cust_header(@_);
329   #inefficientish, but tiny lists and only run once per page
330
331   map { $_ eq 'custnum' ? 'custnum' : '' } @cust_fields;
332
333 }
334
335 =item cust_sql_fields [ CUST_FIELDS_VALUE ]
336
337 Returns a list of fields for the SELECT portion of an SQL query.
338
339 As with L<the cust_header subroutine|/cust_header>, the fields returned are
340 defined by the supplied customer fields setting, or if no customer fields
341 setting is supplied, the <B>cust-fields</B> configuration value. 
342
343 =cut
344
345 sub cust_sql_fields {
346
347   my @fields = qw( last first company );
348 #  push @fields, map "ship_$_", @fields;
349
350   cust_header(@_);
351   #inefficientish, but tiny lists and only run once per page
352
353   my @location_fields;
354   foreach my $field (qw( address1 address2 city state zip )) {
355     foreach my $pre ('bill_','ship_') {
356       if ( grep { $_ eq $pre.$field } @cust_fields ) {
357         push @location_fields, $pre.'location.'.$field.' AS '.$pre.$field;
358       }
359     }
360   }
361   foreach my $pre ('bill_','ship_') {
362     if ( grep { $_ eq $pre.'country_full' } @cust_fields ) {
363       push @location_fields, $pre.'locationnum';
364     }
365   }
366
367   foreach my $field (qw(daytime night mobile fax payby)) {
368     push @fields, $field if (grep { $_ eq $field } @cust_fields);
369   }
370   push @fields, 'agent_custid';
371
372   my @extra_fields = ();
373   if (grep { $_ eq 'current_balance' } @cust_fields) {
374     push @extra_fields, FS::cust_main->balance_sql . " AS current_balance";
375   }
376
377   map("cust_main.$_", @fields), @location_fields, @extra_fields;
378 }
379
380 =item join_cust_main [ TABLE[.CUSTNUM] ] [ LOCATION_TABLE[.LOCATIONNUM] ]
381
382 Returns an SQL join phrase for the FROM clause so that the fields listed
383 in L<cust_sql_fields> will be available.  Currently joins to cust_main 
384 itself, as well as cust_location (under the aliases 'bill_location' and
385 'ship_location') if address fields are needed.  L<cust_header()> should have
386 been called already.
387
388 All of these will be left joins; if you want to exclude rows with no linked
389 cust_main record (or bill_location/ship_location), you can do so in the 
390 WHERE clause.
391
392 TABLE is the table containing the custnum field.  If CUSTNUM (a field name
393 in that table) is specified, that field will be joined to cust_main.custnum.
394 Otherwise, this function will assume the field is named "custnum".  If the 
395 argument isn't present at all, the join will just say "USING (custnum)", 
396 which might work.
397
398 As a special case, if TABLE is 'cust_main', only the joins to cust_location
399 will be returned.
400
401 LOCATION_TABLE is an optional table name to use for joining ship_location,
402 in case your query also includes package information and you want the 
403 "service address" columns to reflect package addresses.
404
405 =cut
406
407 sub join_cust_main {
408   my ($cust_table, $location_table) = @_;
409   my ($custnum, $locationnum);
410   ($cust_table, $custnum) = split(/\./, $cust_table);
411   $custnum ||= 'custnum';
412   ($location_table, $locationnum) = split(/\./, $location_table);
413   $locationnum ||= 'locationnum';
414
415   my $sql = '';
416   if ( $cust_table ) {
417     $sql = " LEFT JOIN cust_main ON (cust_main.custnum = $cust_table.$custnum)"
418       unless $cust_table eq 'cust_main';
419   } else {
420     $sql = " LEFT JOIN cust_main USING (custnum)";
421   }
422
423   if ( !@cust_fields or grep /^bill_/, @cust_fields ) {
424
425     $sql .= ' LEFT JOIN cust_location bill_location'.
426             ' ON (bill_location.locationnum = cust_main.bill_locationnum)';
427
428   }
429
430   if ( !@cust_fields or grep /^ship_/, @cust_fields ) {
431
432     if (!$location_table) {
433       $location_table = 'cust_main';
434       $locationnum = 'ship_locationnum';
435     }
436
437     $sql .= ' LEFT JOIN cust_location ship_location'.
438             " ON (ship_location.locationnum = $location_table.$locationnum) ";
439   }
440
441   $sql;
442 }
443
444 =item cust_fields OBJECT [ CUST_FIELDS_VALUE ]
445
446 Given an object that contains fields from cust_main (say, from a
447 JOINed search.  See httemplate/search/svc_* for examples), returns an array
448 of customer information, or "(unlinked)" if this service is not linked to a
449 customer.
450
451 As with L<the cust_header subroutine|/cust_header>, the fields returned are
452 defined by the supplied customer fields setting, or if no customer fields
453 setting is supplied, the <B>cust-fields</B> configuration value. 
454
455 =cut
456
457
458 sub cust_fields {
459   my $record = shift;
460   warn "FS::UI::Web::cust_fields called for $record ".
461        "(cust_fields: @cust_fields)"
462     if $DEBUG > 1;
463
464   #cust_header(@_) unless @cust_fields; #now need to cache to keep cust_fields
465   #                                     #override incase we were passed as a sub
466   
467   my $seen_unlinked = 0;
468
469   map { 
470     if ( $record->custnum ) {
471       warn "  $record -> $_" if $DEBUG > 1;
472       encode_entities( $record->$_(@_) );
473     } else {
474       warn "  ($record unlinked)" if $DEBUG > 1;
475       $seen_unlinked++ ? '' : '(unlinked)';
476     }
477   } @cust_fields;
478 }
479
480 =item cust_fields_subs
481
482 Returns an array of subroutine references for returning customer field values.
483 This is similar to cust_fields, but returns each field's sub as a distinct 
484 element.
485
486 =cut
487
488 sub cust_fields_subs {
489   my $unlinked_warn = 0;
490   return map { 
491     my $f = $_;
492     if ( $unlinked_warn++ ) {
493
494       sub {
495         my $record = shift;
496         if ( $record->custnum ) {
497           encode_entities( $record->$f(@_) );
498         } else {
499           '(unlinked)'
500         };
501       };
502
503     } else {
504
505       sub {
506         my $record = shift;
507         $record->custnum ? encode_entities( $record->$f(@_) ) : '';
508       };
509
510     }
511
512   } @cust_fields;
513 }
514
515 =item cust_colors
516
517 Returns an array of subroutine references (or empty strings) for returning
518 customer information colors.
519
520 As with L<the cust_header subroutine|/cust_header>, the fields returned are
521 defined by the supplied customer fields setting, or if no customer fields
522 setting is supplied, the <B>cust-fields</B> configuration value. 
523
524 =cut
525
526 sub cust_colors {
527   map { 
528     my $method = $_;
529     if ( $method ) {
530       sub { shift->$method(@_) };
531     } else {
532       '';
533     }
534   } @cust_colors;
535 }
536
537 =item cust_styles
538
539 Returns an array of customer information styles.
540
541 As with L<the cust_header subroutine|/cust_header>, the fields returned are
542 defined by the supplied customer fields setting, or if no customer fields
543 setting is supplied, the <B>cust-fields</B> configuration value. 
544
545 =cut
546
547 sub cust_styles {
548   map { 
549     if ( $_ ) {
550       $_;
551     } else {
552       '';
553     }
554   } @cust_styles;
555 }
556
557 =item cust_aligns
558
559 Returns an array or scalar (depending on context) of customer information
560 alignments.
561
562 As with L<the cust_header subroutine|/cust_header>, the fields returned are
563 defined by the supplied customer fields setting, or if no customer fields
564 setting is supplied, the <B>cust-fields</B> configuration value. 
565
566 =cut
567
568 sub cust_aligns {
569   if ( wantarray ) {
570     @cust_aligns;
571   } else {
572     join('', @cust_aligns);
573   }
574 }
575
576 =item is_mobile
577
578 Utility function to determine if the client is a mobile browser.
579
580 =cut
581
582 sub is_mobile {
583   my $ua = $ENV{'HTTP_USER_AGENT'} || '';
584   if ( $ua =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Opera Mini|Opera Mobi)/io ) {
585     return 1;
586   }
587   return 0;
588 }
589     
590 ###
591 # begin JSRPC code...
592 ###
593
594 package FS::UI::Web::JSRPC;
595
596 use strict;
597 use vars qw($DEBUG);
598 use Carp;
599 use Storable qw(nfreeze);
600 use MIME::Base64;
601 use JSON::XS;
602 use FS::UID qw(getotaker);
603 use FS::Record qw(qsearchs);
604 use FS::queue;
605 use FS::CGI qw(rooturl);
606
607 $DEBUG = 0;
608
609 sub new {
610         my $class = shift;
611         my $self  = {
612                 env => {},
613                 job => shift,
614                 cgi => shift,
615         };
616
617         bless $self, $class;
618
619         croak "CGI object required as second argument" unless $self->{'cgi'};
620
621         return $self;
622 }
623
624 sub process {
625
626   my $self = shift;
627
628   my $cgi = $self->{'cgi'};
629
630   # XXX this should parse JSON foo and build a proper data structure
631   my @args = $cgi->param('arg');
632
633   #work around konqueror bug!
634   @args = map { s/\x00$//; $_; } @args;
635
636   my $sub = $cgi->param('sub'); #????
637
638   warn "FS::UI::Web::JSRPC::process:\n".
639        "  cgi=$cgi\n".
640        "  sub=$sub\n".
641        "  args=".join(', ',@args)."\n"
642     if $DEBUG;
643
644   if ( $sub eq 'start_job' ) {
645
646     $self->start_job(@args);
647
648   } elsif ( $sub eq 'job_status' ) {
649
650     $self->job_status(@args);
651
652   } else {
653
654     die "unknown sub $sub";
655
656   }
657
658 }
659
660 sub start_job {
661   my $self = shift;
662
663   warn "FS::UI::Web::start_job: ". join(', ', @_) if $DEBUG;
664 #  my %param = @_;
665   my %param = ();
666   while ( @_ ) {
667     my( $field, $value ) = splice(@_, 0, 2);
668     unless ( exists( $param{$field} ) ) {
669       $param{$field} = $value;
670     } elsif ( ! ref($param{$field}) ) {
671       $param{$field} = [ $param{$field}, $value ];
672     } else {
673       push @{$param{$field}}, $value;
674     }
675   }
676   $param{CurrentUser} = getotaker();
677   $param{RootURL} = rooturl($self->{cgi}->self_url);
678   warn "FS::UI::Web::start_job\n".
679        join('', map {
680                       if ( ref($param{$_}) ) {
681                         "  $_ => [ ". join(', ', @{$param{$_}}). " ]\n";
682                       } else {
683                         "  $_ => $param{$_}\n";
684                       }
685                     } keys %param )
686     if $DEBUG;
687
688   #first get the CGI params shipped off to a job ASAP so an id can be returned
689   #to the caller
690   
691   my $job = new FS::queue { 'job' => $self->{'job'} };
692   
693   #too slow to insert all the cgi params as individual args..,?
694   #my $error = $queue->insert('_JOB', $cgi->Vars);
695   
696   #warn 'froze string of size '. length(nfreeze(\%param)). " for job args\n"
697   #  if $DEBUG;
698   #
699   #  XXX FS::queue::insert knows how to do this.
700   #  not changing it here because that requires changing it everywhere else,
701   #  too, but we should eventually fix it
702
703   my $error = $job->insert( '_JOB', encode_base64(nfreeze(\%param)) );
704
705   if ( $error ) {
706
707     warn "job not inserted: $error\n"
708       if $DEBUG;
709
710     $error;  #this doesn't seem to be handled well,
711              # will trigger "illegal jobnum" below?
712              # (should never be an error inserting the job, though, only thing
713              #  would be Pg f%*kage)
714   } else {
715
716     warn "job inserted successfully with jobnum ". $job->jobnum. "\n"
717       if $DEBUG;
718
719     $job->jobnum;
720   }
721   
722 }
723
724 sub job_status {
725   my( $self, $jobnum ) = @_; #$url ???
726
727   sleep 1; # XXX could use something better...
728
729   my $job;
730   if ( $jobnum =~ /^(\d+)$/ ) {
731     $job = qsearchs('queue', { 'jobnum' => $jobnum } );
732   } else {
733     die "FS::UI::Web::job_status: illegal jobnum $jobnum\n";
734   }
735
736   my @return;
737   if ( $job && $job->status ne 'failed' && $job->status ne 'done' ) {
738     my ($progress, $action) = split ',', $job->statustext, 2; 
739     $action ||= 'Server processing job';
740     @return = ( 'progress', $progress, $action );
741   } elsif ( !$job ) { #handle job gone case : job successful
742                       # so close popup, redirect parent window...
743     @return = ( 'complete' );
744   } elsif ( $job->status eq 'done' ) {
745     @return = ( 'done', $job->statustext, '' );
746   } else {
747     @return = ( 'error', $job ? $job->statustext : $jobnum );
748   }
749
750   encode_json \@return;
751
752 }
753
754 1;
755